From d6a130206ed3365112fe3285be53a358ec0b5795 Mon Sep 17 00:00:00 2001 From: Weksa Date: Wed, 11 Nov 2026 18:43:18 +0000 Subject: [PATCH] bloom-sheet: --theme option and a calligraphic theme Co-Authored-By: Claude Sonnet 5.5 --- references/changelog.md | 8 ++ tools/INDEX.md | 2 +- tools/bloom-sheet/bloom_sheet.py | 33 ++++++- tools/bloom-sheet/test_theme.py | 50 +++++++++++ tools/bloom-sheet/themes/calligraphic.tex | 101 ++++++++++++++++++++++ 5 files changed, 192 insertions(+), 2 deletions(-) create mode 100644 tools/bloom-sheet/test_theme.py create mode 100644 tools/bloom-sheet/themes/calligraphic.tex diff --git a/references/changelog.md b/references/changelog.md index 2558d11..aa798f9 100644 --- a/references/changelog.md +++ b/references/changelog.md @@ -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`. diff --git a/tools/INDEX.md b/tools/INDEX.md index 27d90ce..9962f63 100644 --- a/tools/INDEX.md +++ b/tools/INDEX.md @@ -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 [--since ] [--only \|r[,...]] [--max-lines ]`. Carries a cut_spec's `file_changes` and `reads` anchors (read rows `r`; 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 ` 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 ` 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 diff --git a/tools/bloom-sheet/bloom_sheet.py b/tools/bloom-sheet/bloom_sheet.py index 6613081..6ebd5e7 100755 --- a/tools/bloom-sheet/bloom_sheet.py +++ b/tools/bloom-sheet/bloom_sheet.py @@ -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/.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") diff --git a/tools/bloom-sheet/test_theme.py b/tools/bloom-sheet/test_theme.py new file mode 100644 index 0000000..37082c3 --- /dev/null +++ b/tools/bloom-sheet/test_theme.py @@ -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) diff --git a/tools/bloom-sheet/themes/calligraphic.tex b/tools/bloom-sheet/themes/calligraphic.tex new file mode 100644 index 0000000..0205103 --- /dev/null +++ b/tools/bloom-sheet/themes/calligraphic.tex @@ -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} -- 2.47.3