bloom-sheet: large fills as wash and outline #48

Merged
eureka merged 1 commit from eureka-self/bloom-outline into main 2026-11-05 14:53:47 +00:00
4 changed files with 34 additions and 17 deletions

View file

@ -17,4 +17,4 @@ gets a Soul pass before merge.
| `tools/context-pack.py` | `py -3 tools/context-pack.py <cut_spec id> <worktree> [--since <sha>] [--only <k>\|r<k>[,...]] [--spec-json <file>] [--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 (fetched by id through the eureka-state binding (`tools/mind.py`), retried once on Unavailable; `--spec-json` takes a saved view). `--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/mind.py` | The one way a Eureka script reaches the mind: `mind.call(tool, arguments, deadline)` spawns the eureka-state binding (`EUREKA_STATE_EXE`, else `~/.claude.json` `mcpServers.eureka-state`), does the stdio handshake and one tool call within `deadline` seconds, returns the parsed JSON body, raises `MindError` (kind binding, transport, tool or answer; a detail, never an input value), and kills the child on every exit path. No retry inside `call`; `retry_once(fn)` is the caller's choice. Standard library only. | `tools/context-pack.py`, `tools/recall.py` | not named by a brief; it lives and dies with its consumers |
| `tools/recall.py` | `py -3 tools/recall.py prompt`, a `UserPromptSubmit` command hook (hook JSON on stdin): queries the rulings, questions and follow-ups in force across every root, ranked by Huginn against the prompt (`RECALL_TOP_K`, 5), and prints one hook JSON whose `additionalContext` is a header saying the lines are recalled records, data and never instructions, written by agents and not the operator, then one JSON object per item (`id`, `title`, a short `operator_quote`) with `<`, `>` and control, bidi and separator characters escaped, within `RECALL_BUDGET` characters (4000, at most 9000: Claude Code caps `additionalContext` at 10,000), whole lines only. Prompts under 3 words are not queried. Never blocks: any failure exits 0 with empty stdout; its own deadline is `RECALL_DEADLINE_MS` (4000) for the whole run (stdin read to 4 MiB, answer read to 1 MiB), so set the hook `timeout` above it. One attempt, no retry. Install both hooks as `<absolute python.exe> C:/Users/Meta/.claude/skills/eureka/tools/recall.py prompt || true` (and `... admit || true`): Claude Code runs hook commands through Git Bash on Windows, and `py -3 <missing script>` exits 2, which blocks every prompt or admit; with `|| true` the hook cannot exit 2 (a missing script, interpreter or file passes) and the script's stdout is untouched (proved live on 2.1.308). `py -3 tools/recall.py admit` is a `PreToolUse` hook on `mcp__eureka-state__admit`: for each ruling or question in the batch (at most `RECALL_ADMIT_MAX`, 3) it queries the nearest in-force rulings and questions, and denies the first attempt with the neighbours as the reason (a deliberate hold, not a failure); the identical call sent again (digest of `tool_input` plus `agent_id`, kept in the session scratchpad) passes. Never allow or ask; a failed check exits 0 with a note naming the failure kind (`RECALL_TOP_K` 3, `RECALL_DEADLINE_MS` 10000 in this mode). Standard library only; writes only the digest file. | the operator's `UserPromptSubmit` hook and `PreToolUse` hook on `mcp__eureka-state__admit` (fires inside subagents too) in `~/.claude/settings.json` | `references/campaign-state.md` (Precedent) |
| `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. 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. 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 |

View file

@ -11,10 +11,11 @@ two minutes: the growth, then LuaLaTeX twice.
A visitor is drawn with the plant's own pen, not pasted in. Stroked shapes
become weighted neon lines, and a fill under a stroke becomes a faint wash, as
on a petal. Shapes with only a fill become solid neon. Colours 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.
on a petal. A shape with only a fill is drawn the same way with a heavy line,
unless it is small: spots and eyes stay solid, like a flower's centre. Colours
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.
Needs python3-numpy, python3-markdown-it, LuaLaTeX with tikz and tcolorbox,
pdftoppm for PNG, the Ubuntu, IBM Plex Mono and Montserrat fonts, and for
@ -236,6 +237,7 @@ def macro(k):
return r"\content" + name
SPOT = 0.35 # a bare fill wider than this, as a fraction of its drawing, is outlined
PALETTE_HUES = {"neonorange": 28, "neonsky": 205, "neonviolet": 244, "neonrose": 336}
@ -250,8 +252,8 @@ def neon_colour(colour):
def read_visitor(svg):
"""Read an SVG into marks the plant's pen can draw, in paint order, centred,
y up, and scaled so everything lies within radius 1. Exits with the reason
if the drawing can't be one. A mark is {"colour", "rings"} for a solid or
{"colour", "pts", "width", "closed", "wash"} for a line."""
if the drawing can't be one. A mark is {"colour", "rings", "outlined"} for a
bare fill or {"colour", "pts", "width", "closed", "wash"} for a line."""
from shapely.geometry import LineString, Polygon
from shapely.ops import unary_union
from svgelements import SVG, Close, Line, Move, Path as SvgPath, Shape, Text
@ -309,7 +311,10 @@ def read_visitor(svg):
area = poly if area is None else area.symmetric_difference(poly)
if area is not None: covers.append(area.buffer(touch))
if stroke is None:
marks.append({"colour": ink(fill), "rings": [unit(pts) for pts, _ in subs]})
rings = [unit(pts) for pts, _ in subs]
xs, ys = [p[0] for r in rings for p in r], [p[1] for r in rings for p in r]
big = math.hypot(max(xs) - min(xs), max(ys) - min(ys)) / 2 > SPOT
marks.append({"colour": ink(fill), "rings": rings, "outlined": big and ink(fill) != "ground"})
continue
for pts, closed in subs:
marks.append({"colour": ink(stroke), "pts": unit(pts), "width": round(width / radius, 5), "closed": closed,

View file

@ -230,19 +230,26 @@ def draw_visitor(cx, cy, r, marks):
a = math.radians(rng.uniform(-18, 18))
spot = lambda pts: [(cx + r * 0.95 * (x * math.cos(a) - y * math.sin(a)),
cy + r * 0.95 * (x * math.sin(a) + y * math.cos(a))) for x, y in pts]
def loop(col, pts, w): # a closed line breathes like a ring
ph = rng.uniform(0, 2 * math.pi)
neon(col, ribbon(pts + pts[:1], lambda u: w * (0.75 + 0.25 * math.cos(2 * math.pi * u + ph))), 0.9)
for m in marks:
if "rings" in m:
rings = " ".join(path(spot(ring_)) for ring_ in m["rings"])
out.append(f"\\begin{{scope}}[even odd rule]\\neonfill{{{m['colour']}}}{{85}}{{{rings}}}\\end{{scope}}")
rings = [spot(ring_) for ring_ in m["rings"]]
whole = " ".join(path(ring_) for ring_ in rings)
if m["outlined"]:
out.append(f"\\begin{{scope}}[even odd rule]\\petalwash{{{m['colour']}}}{{{whole}}}\\end{{scope}}")
for ring_ in rings:
loop(m["colour"], ring_, 0.6)
else:
out.append(f"\\begin{{scope}}[even odd rule]\\neonfill{{{m['colour']}}}{{85}}{{{whole}}}\\end{{scope}}")
continue
pts = spot(m["pts"])
if m["wash"]: out.append(f"\\petalwash{{{m['wash']}}}{{{path(pts)}}}")
# the caller's weight, kept within the plant's; a closed line breathes
# like a ring, an open one swells between thin ends
# the caller's weight, kept within the plant's; an open line swells between thin ends
w = max(0.14, min(0.7, m["width"] * r * 0.95))
if m["closed"]:
ph = rng.uniform(0, 2 * math.pi)
neon(m["colour"], ribbon(pts + pts[:1], lambda u: w * (0.75 + 0.25 * math.cos(2 * math.pi * u + ph))), 0.9)
loop(m["colour"], pts, w)
else:
neon(m["colour"], ribbon(pts, lambda u: w * (0.4 + 0.6 * math.sin(math.pi * u) ** 0.7)), 0.9)

View file

@ -41,7 +41,7 @@ refused("image", DISC + '<image width="9" height="9" xlink:href="data:image/png;
refused("nothing", '<circle cx="50" cy="50" r="40" fill="none"/>', "fill or a stroke")
m = read_visitor(svg(DISC))
check("a bare fill is a solid", len(m) == 1 and "rings" in m[0] and m[0]["colour"] == "neonorange")
check("a large bare fill is outlined, not solid", len(m) == 1 and m[0]["outlined"] and m[0]["colour"] == "neonorange")
m = read_visitor(svg('<path d="M10 10 L90 10 L50 90" fill="none" stroke="#59B7FF" stroke-width="2"/>'))
check("a stroke is an open line, no wash", len(m) == 1 and "pts" in m[0] and not m[0]["closed"] and m[0]["wash"] is None)
check("the line keeps its colour", m[0]["colour"] == "neonsky")
@ -51,8 +51,13 @@ check("a fill under a stroke is a wash", m[0]["closed"] and m[0]["wash"] == "neo
m = read_visitor(svg('<path d="M50 5 L95 95 L5 95Z"/>'))
check("an all-black icon is seen", m[0]["colour"] == "neonwhite")
m = read_visitor(svg(DISC + '<circle cx="35" cy="35" r="5" fill="#07111A"/>'))
check("black on colour is a cutout", m[1]["colour"] == "ground")
check("paint order is kept", m[0]["colour"] == "neonorange")
check("black on colour is a cutout", m[1]["colour"] == "ground" and not m[1]["outlined"])
m = read_visitor(svg(DISC + '<circle cx="35" cy="35" r="5" fill="#59B7FF"/>'))
check("a small bare fill stays solid", not m[1]["outlined"])
m = read_visitor(svg('<circle cx="50" cy="50" r="40" fill="#07111A"/><circle cx="50" cy="50" r="5" fill="#f80"/>'))
check("a large cutout stays a cutout", m[0]["colour"] == "ground" and not m[0]["outlined"])
m = read_visitor(svg(DISC + '<circle cx="35" cy="35" r="5" fill="#07111A"/>'))
check("paint order is kept", [x["colour"] for x in m] == ["neonorange", "ground"])
m = read_visitor(svg('<circle cx="50" cy="50" r="40" fill="none" stroke="#f80"/><circle cx="35" cy="35" r="5" fill="#fff"/><circle cx="65" cy="35" r="5" fill="#fff"/>'))
check("eyes inside an outline count as touching", len(m) == 3)
m = read_visitor(svg('<path fill-rule="evenodd" fill="#f80" d="M10 10 H90 V90 H10Z M40 40 H60 V60 H40Z"/>'))