bloom-sheet --theme option and a calligraphic theme #66
5 changed files with 192 additions and 2 deletions
|
|
@ -2,6 +2,14 @@
|
|||
|
||||
Record each change to the skill together with the evidence that motivated it.
|
||||
|
||||
## 2026-11-11: bloom-sheet `--theme`, and a calligraphic theme
|
||||
|
||||
Weksa, follow-up `weksa-body:follow_up:chat-s7306-7307` (draft PR pending Meta's verdict on the look).
|
||||
|
||||
Evidence. Meta confirmed bloom-sheet as the decoration script for the Zyphos Atlas (GameCult/Zyphos PR 7) and asked for a calligraphic look; it was built as a page file kept outside the skill and run from a scratch copy of the scripts. Preview: https://minds.gamecult.org/weksa/zyphos-atlas-sample/. Building it hit a trap: `bloom_sheet.py` replaces its three marker strings everywhere in the page file, including inside TeX comments, so a comment naming a marker spliced the whole content into the preamble.
|
||||
|
||||
What changed. `bloom_sheet.py --theme NAME` reads `themes/NAME.tex` instead of `page.tex`; without it nothing changes (a before and after build of the same sheet with `SOURCE_DATE_EPOCH=1 FORCE_SOURCE_DATE=1` compares byte-equal, PDF and PNG, once the PDF's `/ID` is blanked: the ID differs on every run, even of unchanged code). `themes/calligraphic.tex` is the first theme: ink on parchment, Zapf Chancery through `pzc`. A page file now has to carry `@@CONTENTS@@` and `@@TITLE@@` exactly once (`@@FONTS@@` at most once), or the build stops and says so; `test_theme.py` pins the marker rule, that an unknown or path-like theme is refused, and that every theme defines the macros, colours and font families the default does. `grow.py` is untouched.
|
||||
|
||||
## 2026-11-11: ygg-verify.sh runs on the Yggdrasil seat
|
||||
|
||||
Hands, follow-up `eureka-substrate:follow_up:chat-mind-20261111T150417226Z-t35`.
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ gets a Soul pass before merge.
|
|||
| `tools/testparse.sh` | Sourced library: failing test names and pass/fail/skip counts from cargo, dotnet, vitest, jest and pytest logs, and test files that failed to load (`LOADFAIL`). Shared by the summary and the kill table. | `tools/stopgap/ygg-verify.sh`, `tools/mutation-table.sh` | not named by a brief; it lives and dies with them |
|
||||
| `tools/test-tools.sh` | Local fixture check of the parsers and the summary over `tools/fixtures/logs` (real cargo and mutation logs, plus hand-written dotnet, vitest, jest, pytest, exit-0-but-failed and no-verdict logs), and of the mutation runners' host drivers and container halves (`tools/mutation-table.sh`, `tools/cargo-mutants.sh`) under a mock docker, a fake `cargo mutants` and real slot locks. Run it before merging a change to the scripts above. | whoever changes those scripts | `SKILL.md` (Shared tools) |
|
||||
| `tools/context-pack.py` | `py -3 tools/context-pack.py <spec file or -> <worktree> [--since <sha>] [--only <k>\|r<k>[,...]] [--max-lines <n>]`. Carries a cut_spec's `file_changes` and `reads` anchors (read rows `r<k>`; a read absent at base is MISSING) from the spec's base to the worktree by `git diff` hunks (the working tree included) and prints one header, a status table (SAME, MOVED, TOUCHED, RENAMED, NEW, GONE, OUT_OF_RANGE, UNRESOLVED, MISSING, plus SYMBOL_MISSING when an anchor's `symbol` is absent from its window) and windows of at most 100 lines numbered in worktree lines, then one capped caller list per symbol (`git grep`, hits outside the windows), within `--max-lines` (800). Exit 3 when an anchor cannot be carried, 4 when the spec is unreadable. The spec is the document `huginn view --raw <cut_spec id>` prints, saved to a file or piped to `-`; context-pack never fetches it, and an Unavailable from huginn is the caller's one retry. `--since <sha>` re-prints only the windows edited since that commit. Standard library plus git; writes nothing. | Hands, Soul, Imagination | `references/briefs.md` (Imagination map, Hands, Soul), `SKILL.md` (step 1) |
|
||||
| `tools/bloom-sheet/bloom_sheet.py` | `bloom-sheet notes.md -o sheet.png [--visitor a.svg]...`: a markdown document as one neon A4 page. The first top-level heading is the title, every other heading a card sized by its text; a plant grows through what's left, and each SVG visitor gets a disc it grows around and is drawn with the plant's pen, so it must be flat strokes or fills that touch; large fills become a wash and a heavy outline. PDF or PNG, about 2.5 minutes. `grow.py` owns the plant, `page.tex` the look, `test_visitors.py` what a visitor may be. | Minds and Self, for info renders | the `bloom-sheet` skill; `bloom-sheet` on Yggdrasil's PATH |
|
||||
| `tools/bloom-sheet/bloom_sheet.py` | `bloom-sheet notes.md -o sheet.png [--visitor a.svg]...`: a markdown document as one neon A4 page. The first top-level heading is the title, every other heading a card sized by its text; a plant grows through what's left, and each SVG visitor gets a disc it grows around and is drawn with the plant's pen, so it must be flat strokes or fills that touch; large fills become a wash and a heavy outline. `--theme NAME` swaps the page file for `themes/NAME.tex` (`calligraphic`: ink on parchment). PDF or PNG, about 2.5 minutes. `grow.py` owns the plant, `page.tex` the look, `test_visitors.py` what a visitor may be, `test_theme.py` what a theme must be. | Minds and Self, for info renders | the `bloom-sheet` skill; `bloom-sheet` on Yggdrasil's PATH |
|
||||
|
||||
## From a Self seat on Yggdrasil
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ snap to the sheet's palette, near-black paints the ground (a cutout), and
|
|||
opacity is ignored. All of it must touch, reading as one thing; a closed
|
||||
outline counts as covering its inside. Text, images and gradients are refused.
|
||||
|
||||
--theme NAME swaps the page file for themes/NAME.tex (`calligraphic`: ink on parchment, Zapf Chancery).
|
||||
|
||||
Needs python3-numpy, python3-markdown-it, LuaLaTeX with tikz and tcolorbox,
|
||||
pdftoppm for PNG, the Ubuntu, IBM Plex Mono and Montserrat fonts, and for
|
||||
visitors python3-svgelements and python3-shapely.
|
||||
|
|
@ -328,6 +330,34 @@ def read_visitor(svg):
|
|||
return marks
|
||||
|
||||
|
||||
MARKS = ("@@FONTS@@", "@@CONTENTS@@", "@@TITLE@@")
|
||||
|
||||
|
||||
def page_source(theme):
|
||||
"""The page file's text: page.tex, or themes/<theme>.tex. A theme is a whole page file; it must provide every
|
||||
macro page.tex does (grow.py writes calls to them). Each marker is replaced wherever its text appears, a TeX
|
||||
comment included, so a marker may appear once in a page file and never in a comment; @@FONTS@@ may be left out
|
||||
by a theme that sets its own fonts."""
|
||||
if theme is None:
|
||||
path = HERE / "page.tex"
|
||||
else:
|
||||
path = HERE / "themes" / f"{theme}.tex"
|
||||
if not re.fullmatch(r"[a-z0-9-]+", theme) or not path.is_file():
|
||||
names = sorted(p.stem for p in (HERE / "themes").glob("*.tex"))
|
||||
sys.exit(f"bloom-sheet: no theme {theme!r}; themes: {', '.join(names) or 'none'}")
|
||||
text = path.read_text(encoding="utf8")
|
||||
check_markers(text, path.name)
|
||||
return text
|
||||
|
||||
|
||||
def check_markers(text, name):
|
||||
for mark in MARKS:
|
||||
n = text.count(mark)
|
||||
if n > 1 or (n == 0 and mark != "@@FONTS@@"):
|
||||
sys.exit(f"bloom-sheet: {name} has {mark} {n} times, not once "
|
||||
"(a marker inside a TeX comment is replaced too)")
|
||||
|
||||
|
||||
def build(args, source):
|
||||
title, subtitle, cards = parse(source, args.level)
|
||||
if args.title is not None: title = args.title
|
||||
|
|
@ -369,7 +399,7 @@ def build(args, source):
|
|||
d = Path(tmp)
|
||||
(d / "spec.json").write_text(json.dumps(
|
||||
{"seed": seed, "panels": panels, "title_box": title_box, "swash": swash, "visitors": visitors}), encoding="utf8")
|
||||
page = (HERE / "page.tex").read_text(encoding="utf8")
|
||||
page = page_source(args.theme)
|
||||
for mark, value in (("@@FONTS@@", FONTS), ("@@CONTENTS@@", "\n".join(contents)), ("@@TITLE@@", title_tex)):
|
||||
page = page.replace(mark, value)
|
||||
(d / "sheet.tex").write_text(page, encoding="utf8")
|
||||
|
|
@ -425,6 +455,7 @@ def main():
|
|||
ap.add_argument("--subtitle", help="small line under the title")
|
||||
ap.add_argument("--level", type=int, default=6, help="headings deeper than this are labels inside a card, not cards (default: every heading is a card)")
|
||||
ap.add_argument("--visitor", action="append", default=[], metavar="SVG", help="an SVG of strokes or one solid shape to perch in the garden; repeat for more (about three suit a page)")
|
||||
ap.add_argument("--theme", metavar="NAME", help="look of the page: themes/NAME.tex instead of page.tex (default: page.tex, the neon sheet)")
|
||||
ap.add_argument("--seed", type=int, help="plant seed (default: from the document, so a sheet rebuilds the same)")
|
||||
ap.add_argument("--dpi", type=int, default=200, help="PNG resolution (default 200)")
|
||||
ap.add_argument("-v", "--verbose", action="store_true")
|
||||
|
|
|
|||
50
tools/bloom-sheet/test_theme.py
Normal file
50
tools/bloom-sheet/test_theme.py
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
#!/usr/bin/env python3
|
||||
"""What a theme must be. Run: python3 test_theme.py
|
||||
Needs python3-markdown-it and python3-numpy (bloom_sheet imports them); no LaTeX."""
|
||||
import re, sys
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(HERE))
|
||||
from bloom_sheet import FONTS, MARKS, check_markers, page_source
|
||||
|
||||
fails = 0
|
||||
|
||||
|
||||
def check(name, ok):
|
||||
global fails
|
||||
print(("ok " if ok else "FAIL"), name)
|
||||
fails += not ok
|
||||
|
||||
|
||||
def refused(name, thunk, reason):
|
||||
try:
|
||||
thunk()
|
||||
check(f"{name}: refused", False)
|
||||
except SystemExit as e:
|
||||
check(f"{name}: refused for {reason}", reason in str(e))
|
||||
|
||||
|
||||
def defined(tex):
|
||||
"""Every macro, font family and colour a page file defines."""
|
||||
return set(re.findall(r"\\(?:newcommand|DeclareRobustCommand|renewcommand)\{(\\\w+)\}", tex)
|
||||
+ re.findall(r"\\newfontfamily(\\\w+)", tex)
|
||||
+ re.findall(r"\\(?:definecolor|colorlet)\{(\w+)\}", tex))
|
||||
|
||||
|
||||
default = page_source(None)
|
||||
check("default is page.tex, unchanged", default == (HERE / "page.tex").read_text(encoding="utf8"))
|
||||
themes = sorted((HERE / "themes").glob("*.tex"))
|
||||
check("at least one theme ships", bool(themes))
|
||||
for t in themes:
|
||||
text = page_source(t.stem)
|
||||
check(f"{t.stem}: markers once (fonts at most once)", all(text.count(m) == 1 for m in MARKS[1:]) and text.count(MARKS[0]) <= 1)
|
||||
missing = sorted((defined(default) | defined(FONTS)) - defined(text))
|
||||
check(f"{t.stem}: defines what page.tex does (missing: {missing})", not missing)
|
||||
good = "@@CONTENTS@@ @@TITLE@@"
|
||||
check_markers(good, "good.tex")
|
||||
refused("marker in a comment", lambda: check_markers(good + "\n% fills @@TITLE@@ here", "bad.tex"), "@@TITLE@@ 2 times")
|
||||
refused("marker missing", lambda: check_markers("@@CONTENTS@@", "bad.tex"), "@@TITLE@@ 0 times")
|
||||
refused("unknown theme", lambda: page_source("nope"), "no theme 'nope'")
|
||||
refused("path as theme", lambda: page_source("../page"), "no theme")
|
||||
sys.exit(1 if fails else 0)
|
||||
101
tools/bloom-sheet/themes/calligraphic.tex
Normal file
101
tools/bloom-sheet/themes/calligraphic.tex
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
% Calligraphic theme: the plant grower drawn in ink on parchment (bloom-sheet --theme calligraphic). Made for the Zyphos Atlas.
|
||||
% Replaces page.tex whole: it defines every macro page.tex does, since grow.py writes calls to them.
|
||||
% bloom_sheet.py fills the contents and title markers (never write the marker text in a comment: it is replaced
|
||||
% everywhere); there is no fonts marker, so the fonts below are ours.
|
||||
% Colour names keep the skill's (neon*) because grow.py writes them; here they are inks.
|
||||
\documentclass{article}
|
||||
\usepackage[paperwidth=210mm,paperheight=297mm,margin=0mm]{geometry}
|
||||
\usepackage{fontspec}
|
||||
\usepackage[T1]{fontenc}
|
||||
\usepackage{xcolor}
|
||||
\usepackage{tikz}
|
||||
\usepackage{array}
|
||||
\usepackage[most]{tcolorbox}
|
||||
\tcbuselibrary{fitting}
|
||||
|
||||
% ---------- Inks on parchment ----------
|
||||
\definecolor{ground}{HTML}{F1EADA} % paper; visitor cutouts use it
|
||||
\definecolor{groundtop}{HTML}{F6F0E1}
|
||||
\definecolor{groundfoot}{HTML}{E8DFC8}
|
||||
\definecolor{panelbg}{HTML}{FAF6EA}
|
||||
\definecolor{heading}{HTML}{231C27}
|
||||
\definecolor{body}{HTML}{2B2630}
|
||||
\definecolor{muted}{HTML}{6B6558}
|
||||
\definecolor{neonorange}{HTML}{8C2F39} % oxblood
|
||||
\definecolor{neonsky}{HTML}{3F6B5E} % verdigris
|
||||
\definecolor{neonviolet}{HTML}{3A3F6E} % indigo ink
|
||||
\definecolor{neonrose}{HTML}{A87A32} % ochre
|
||||
\definecolor{neonwhite}{HTML}{C09A3E} % gold leaf (flower centres, buds)
|
||||
\colorlet{accent}{neonorange}
|
||||
|
||||
% ---------- The hand ----------
|
||||
% Zapf Chancery through NFSS (psnfss `pzc`): LuaTeX's fontspec cannot load the Type 1 file directly.
|
||||
% It has one weight, so strong and soft are colour, not weight.
|
||||
\newcommand{\hand}{\fontencoding{T1}\fontfamily{pzc}\fontseries{m}\fontshape{n}\selectfont}
|
||||
\newcommand{\labelfont}{\hand}
|
||||
\newcommand{\thinfont}{\hand}
|
||||
\newcommand{\speakfont}{\hand}
|
||||
\newcommand{\numfont}{\hand}
|
||||
|
||||
% ---------- Ink primitives, called by the generated layer ----------
|
||||
% A flat ink fill with a hairline edge: a pen stroke, no glow. #2 is the percent of ink kept.
|
||||
\newcommand{\neonfill}[3]{\path[fill=#1!#2!black!6, draw=#1!85!black, line width=0.12pt, line join=round] #3;}
|
||||
\newcommand{\neondot}[4]{%
|
||||
\fill[#1!70!white, opacity=0.30] (#2,#3) circle ({1.7*#4});
|
||||
\fill[#1!85!black] (#2,#3) circle (#4);}
|
||||
\newcommand{\petalwash}[2]{\fill[#1, opacity=0.13] #2;}
|
||||
% A ruled frame: parchment panel, a heavy and a hairline rule.
|
||||
\newcommand{\neonframe}[2]{%
|
||||
\fill[panelbg, opacity=0.92] #2;
|
||||
\draw[#1!80!black, line width=0.7pt] #2;
|
||||
\draw[#1!80!black, line width=0.2pt, opacity=0.7] #2;}
|
||||
|
||||
% ---------- Text atoms ----------
|
||||
% Defined in the preamble (a parameter macro inside a panel breaks tcolorbox fit).
|
||||
\newcommand{\head}[1]{{\hand\color{accent}#1}\par\vspace{0.25em}}
|
||||
\newcommand{\sub}[1]{\par\vspace{0.25em}{\hand\color{muted}#1}\par}
|
||||
\DeclareRobustCommand{\code}[1]{\leavevmode{\color{neonsky}#1}}
|
||||
\DeclareRobustCommand{\strong}[1]{\leavevmode{\color{heading}#1}}
|
||||
\DeclareRobustCommand{\soft}[1]{\leavevmode{\color{heading}#1}}
|
||||
\DeclareRobustCommand{\faint}[1]{\leavevmode{\color{muted}#1}}
|
||||
\newcommand{\li}[3]{\par{\leftskip=\dimexpr #1em - 1em\relax\hangindent=1em\hangafter=1\noindent\makebox[1em][l]{\color{accent}#2}#3\par}}
|
||||
\newcommand{\quoted}[1]{\par{\leftskip=0.8em\color{heading}#1\par}}
|
||||
\newcommand{\codeline}[1]{\par{\color{neonsky}\hangindent=1em\hangafter=1\noindent#1\par}}
|
||||
\newcommand{\hairline}{\par\vspace{0.2em}{\color{muted}\rule{\linewidth}{0.2pt}}\par\vspace{0.2em}}
|
||||
\newcommand{\cell}[1]{\raggedright\arraybackslash#1}
|
||||
|
||||
% ---------- One panel (as the skill's) ----------
|
||||
\newcommand{\panel}[6]{%
|
||||
\node[anchor=south west, inner sep=0pt] at (#1mm,#2mm) {%
|
||||
\begin{tcolorbox}[blank, fit, width=#3mm, height=#4mm,
|
||||
left=3.1mm, right=3.1mm, top=3mm, bottom=2.6mm,
|
||||
fontupper=\hand\color{body}\linespread{1.04}\selectfont,
|
||||
before upper={\colorlet{accent}{#5}\parindent0pt\parskip0.14em\raggedright}]
|
||||
#6
|
||||
\end{tcolorbox}};}
|
||||
|
||||
@@CONTENTS@@
|
||||
|
||||
\pagecolor{ground}
|
||||
\pagestyle{empty}
|
||||
\begin{document}
|
||||
\begin{tikzpicture}[remember picture, overlay, x=1mm, y=1mm]
|
||||
\begin{scope}[shift={(current page.south west)}]
|
||||
|
||||
% ---- ground: parchment with a darker foot and foxed corners ----
|
||||
\shade[top color=groundtop, bottom color=groundfoot, middle color=ground] (0,0) rectangle (210,297);
|
||||
\foreach \i in {1,...,24} {
|
||||
\fill[neonrose, opacity=0.0045] (0,297) circle ({70*\i/24});
|
||||
\fill[neonorange, opacity=0.0035] (210,0) circle ({85*\i/24});
|
||||
}
|
||||
% a ruled margin
|
||||
\draw[neonorange!70!black, line width=0.5pt, opacity=0.55] (2.6,2.6) rectangle (207.4,294.4);
|
||||
\draw[neonorange!70!black, line width=0.15pt, opacity=0.55] (3.6,3.6) rectangle (206.4,293.4);
|
||||
|
||||
\input{layer.tex}
|
||||
|
||||
@@TITLE@@
|
||||
|
||||
\end{scope}
|
||||
\end{tikzpicture}
|
||||
\end{document}
|
||||
Loading…
Reference in a new issue