bloom-sheet --theme option and a calligraphic theme #66

Merged
eureka merged 1 commit from weksa/bloom-sheet-theme into main 2026-11-11 18:56:33 +00:00 AGit
5 changed files with 192 additions and 2 deletions

View file

@ -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`.

View file

@ -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

View file

@ -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")

View 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)

View 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}