Marks and acts: the reusable half of a sign - #5
Merged
Conversation
Every treatment advertises the partition it wants as a NICKNAME — `HaloPulse.PARTITION` is "radial" — and nothing in the library resolved a nickname to a callable. The catalogue emitted the bare string, so a reader got "partition: radial" and had to guess `map_radial`. Twelve of the thirteen are `map_` + the nickname. That is worse than no convention: it is regular enough to be trusted and then guessed, and the one that breaks it is "anchor", whose builder is `map_anchor_distance`. A code-generating agent spent an entire run guessing at exactly that name, building a probe harness out of coloured bars to test argument shapes, and never started designing. So: - `PARTITION_BUILDERS` and `builder_for()` in palette_partition — the inverse of the existing `treatments_for()`. - Every catalogue treatment now carries `partition_call`, rendered from the LIVE signature so it cannot drift: `map_anchor_distance(pixel_slots, anchor_x, n=10)`. - A new `composition` category: the slots -> map -> PalettePartition -> treatment recipe, all ten builders with signatures, ActScheduler, and the transition and treatment lookups. 2,087 characters, less than one panel image. The catalogue named every effect and not one combinator, and a treatment cannot run without a partition — so it documented thirteen effects that could not be built from it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JCUCEbWF2nQj85FXPyem4i
The dwells were already portable: a palette treatment takes a PalettePartition
and nothing else, which is why twelve of a reference sign's fifteen are
one-liners. Builds and exits were not — they were written inside the app that
owned the mark and reached into its tiles, its layout and its palette directly,
so reusing one meant copying it.
This is the missing half. An act is handed a context and knows nothing else:
ctx.slots the mark's lit cells, (x, y) -> palette index, or bare cells
ctx.colors the ramp those indices point into (optional)
ctx.display what to start an effect against
ctx.running falsy means stop
ctx.frame() present one frame; False means the surface went away
ctx.show() put the mark in its final place
ctx.hide() clear it
swarm_build, swarm_unbuild and drip_in, plus act_factory/supported_acts mirroring
the transition registry. Duck-typed on purpose: an app already has these under
its own names and should not have to inherit anything.
**Verified against a real sign.** The three acts drive darkowl-led-logo's actual
228-cell mark with darkowl unmodified — the context is wired straight to its own
_show_logo, _hide_all and _logo_slots. drip completes in 35 frames, swarm in 182,
unswarm in 189, and unswarm correctly leaves zero tiles visible where the builds
leave seven.
Two things the port surfaced:
- **Slots alone do not describe a mark.** Per-pixel indices point into a ramp,
and SwarmReveal raises on an index_map with no text_colors rather than guessing
— correctly, since a guessed palette is a sign in colours nobody chose. Hence
ctx.colors, and a flat reveal when there is none.
- **The two acts differed by accident, not by design.** One checked `running`
and the other did not; one bounded at 2000 steps and the other at 2500. The
one that ignored `running` would keep a stopping sign on screen for another two
thousand frames. Both now share one driver.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JCUCEbWF2nQj85FXPyem4i
Every build in acts.py ends the same way: hide the mark, run an overlay showing
its pixels arrive, then call ctx.show() to hand the real thing back and drop the
overlay. That last step assumes something real is underneath. darkowl has it —
glyph tiles it can un-hide — and the portability check borrowed exactly that.
Anything else had nothing to borrow: the drops land, the overlay detaches, and
the panel goes black while the act cheerfully returns True.
PixelMark is the minimal version. Lit cells and their colours in, one bitmap,
one palette and one tile out, show() and hide(). No layouts, no glyph placement,
no app.
mark = PixelMark.from_text(display, "BLUE RIDGE", y=6)
mark.attach(display)
await drip_in(mark.context(display))
Verified as a picture, not a return value: that call puts BLUE RIDGE on the
panel in amber, from a font, with no art authored anywhere. Which is the point —
a deck of acts can now be assembled and judged before anything is drawn.
Details that are decisions rather than defaults:
- **Attached hidden.** Every act's first move is to clear the panel, so attaching
visible would flash the finished mark for one frame before the act that
assembles it begins.
- **Index 0 is not lit.** 0 means "no ink here" in ASCII art and the built
palette reserves 0 for transparency, so a cell carrying 0 is skipped. Lighting
it would give every mark a rectangular background.
- **Off-panel cells are dropped, not raised on.** Art is authored by hand and a
wordmark placed one column too far right should not take the sign down.
The tests check LIT PIXELS after each act rather than the return value, because
returning True over a black panel is the exact failure this prevents.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JCUCEbWF2nQj85FXPyem4i
A reference sign's thirty-seven acts are mostly not bespoke code: twenty-four are a transition or a palette treatment applied to the mark, chosen by name. So the promotion is not twenty-four functions, it is three. reveal_via(ctx, transition=...) 12 builds hide_via(ctx, transition=...) 12 exits treatment_dwell(ctx, treatment=) 11 dwells Plus wink_in, and the swarm/drip already here. Seven act functions, 39 distinct selections. DWELLS is a third deck because an act is build -> dwell -> exit and the middle one is where the variety lives. The partition builders take different arguments — an anchor column, a centre, nothing — so _partition_for supplies them from the mark's own bounding box. That is the whole trick: a treatment names the partition it wants, the mark knows its own geometry, and nothing in between has to be written per sign. **Two things are refused rather than offered broken**, because a menu entry that cannot work is worse than an absent one and a silent no-op is worse than both: - **RouteCircuit and PacketTrace** want map_route, which needs a mark's glyph stroke paths and terminus pixels. That is app knowledge, not derivable from a set of cells. - **Drop from Sky** is a different thing wearing the same word. It hooks pre_render_hook and animates a content Label's x/y through the display process; its start() never calls the swap callback, so a mark handed to it stays hidden while the act reports success. transitions_available() excludes anything carrying that hook. Three things the tests found, each of which would have shipped silently: - **A treatment theme is EXACTLY five stops** — every class unpacks "base, dim, flat, warm, hot". A mark's palette is however many colours its art needed, so _theme resamples to five, endpoints kept. Refusing instead would mean "your wordmark has six colours so you may not have a heat sweep". - **GradientDwell needs lo and hi.** The ramp's ends are the obvious answer; _treatment_extras reads the signature and excludes any treatment wanting an argument this module cannot invent. - **Not every transition has detach().** DropFromSky does not, and an act must not fail on the way out of a transition that succeeded. The catalogue test runs EVERY advertised name rather than a sample. A menu whose entries do not all work is worse than a shorter menu: the reader cannot tell which half is real, so they stop trusting any of it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JCUCEbWF2nQj85FXPyem4i
PixelMark.from_art takes rows of characters and a map from character to colour — the format pixel art is authored in — so a drawing goes on the panel without being converted into anything first. Verified against real generated art rather than a fixture: darkowl_v8's OWL20, 290 cells, revealed by Iris Snap and animated by HaloPulse. Neither act knows anything about owls, and the art knows nothing about either act. Two decisions: - **An unmapped character is a hole, not a guess.** That is how "." and " " become background without being special-cased, and it means a character nobody defined leaves a gap rather than putting a shape on the panel its author never drew. (normalize_art takes the opposite view for a different reason: repairing a typo at import, where substituting transparent would delete the sprite.) - **The palette holds only the colours used**, in first-seen order, so a mark carries what it needs rather than whatever the art module happened to define. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JCUCEbWF2nQj85FXPyem4i
play_sign draws build -> dwell -> exit from ActScheduler and repeats until told to stop. That is what a sign IS in this design: a reference sign's 1,755 acts are 13 builds x 15 dwells x 9 exits drawn from by a picker, not a written sequence, and it does not visibly repeat because the picker leads with the least-recently-seen and never plays two of a family back to back. selectable() is the menu, one entry per CHOICE rather than per function — seven functions, 39 selections — and it lives here now rather than in the caller that displays it. Each entry carries its family, and that is where the judgement is: a treatment's family is its PARTITION, because two treatments animating the same grouping of pixels genuinely do look alike, which is the distinction a viewer makes and the one the picker needs. Nine dwell families across eleven treatments. **A choice is a kind AND a name**, and the test that found this is the one worth keeping. Every transition is both a build and an exit, so selecting by bare name made "Pixel Dissolve" — kept to END on — also open the sign. A bare name still selects every kind, which is the forgiving reading when nobody has said otherwise, but "exit:Pixel Dissolve" says the thing that was meant. A deck with no exit is refused rather than played half: a sign that ended mid-build leaves the panel in a state no act chose. Verified over the generated BLUE RIDGE COFFEE wordmark: four cycles, 1,129 frames, and every sampled moment looks different while the art never changes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JCUCEbWF2nQj85FXPyem4i
A deck animates a mark the same way for every customer. A drawn *character* cannot be animated that way — darkowl's owl flies in carrying the letters, and no act can know there is an owl. Both things it needs were written by hand in darkowl_logo.py and missing from the library. PoseCycler advances a list of tiles as one moving subject, exactly one visible at a time. `_fly_pose` is it with two tiles at period 3; `_big_pose` is it with order (0, 1, 2, 1) at period 2 — which is why the beat order is its own argument and not just len(tiles). MotionAnimator gains `point_to_point`: from_xy, to_xy, a frame count and a named curve, computed with easing.interp so a curve behaves here the way the same curve behaves in every transition. traverse_lr crosses and exits; this one LANDS, and like traverse it does not recenter at detach — snapping a subject home would undo the whole move. It also takes `poses`, so the subject can flap while it flies. MOTION_PATHS is exported because a host that lets something else choose a path — a config file, a request, a model — should validate against the animator's own list rather than keep a copy that drifts. A drift-guard test asserts every name in the tuple moves something and that a name outside it does not. PixelMark.tile is the third piece and the least obvious: the animators take a TileGrid, and a host driving a mark along a path had no way to hand them one without reaching into a private attribute. Refusals rather than defaults throughout — point_to_point with no endpoints raises rather than quietly travelling from (0, 0) to (0, 0), because a move that goes nowhere is the failure mode with nothing to read. test/unit: 1137 -> 1148. lint-errors clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JCUCEbWF2nQj85FXPyem4i
`attach()` dropped an off-panel cell from the BITMAP and kept it in `slots`, so the mark went on describing pixels it never drew. Six of the seven acts survived that. `treatment_dwell` did not: it hands `ctx.slots` straight to a panel-sized PalettePartition, which raised IndexError from inside the paint loop. So a wordmark one column too wide took the sign down on its first dwell, which is the exact failure the drop was there to prevent. `from_text` overflows at about eleven characters and `play_sign` always draws a dwell, so "BLUE RIDGE COFFEE" is enough to hit it, and that string is the whole point of `from_text`: audition a deck against a real wordmark before drawing any art. The narrowing belongs in `attach()` because that is where the panel's bounds are first known. Shape is preserved on purpose: a mapping stays a mapping so per-pixel indices survive, an iterable stays a plain list of cells. Only the cells that reached the bitmap remain, which also means the builds stop animating 94 pixels of a 243-pixel wordmark that were never going to appear. test/unit: three cases, one per way the promise was broken -- the mapping, the bare iterable, and the regression itself (a text mark wider than the panel now completes a dwell). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzkFnmiBQrxzPEpT3SNyZh
`frames` bounded one of the two program shapes and silently ignored the other. An app whose `setup()` returns is driven by the harness's own loop, which counts to `frames`. An app that never returns from `setup()`, the `while self.running` shape a generated sign uses, never reached that loop at all: `run_headless(app, frames=20)` rendered until something killed the process. The cap counts at `display.show()`, the one call both shapes make exactly once per frame, and raises through it at the limit. Three details are load-bearing: - **The wrapper goes on the display INSTANCE and comes off again.** Wrapping `UnifiedDisplay.show` on the class leaks into every later app in the same process and counts each frame once per wrap. - **The frame signature is read before the raise**, because teardown runs as the exception unwinds and drops the display, the active PerformanceManager and every recorded frame. Read it afterwards and there is nothing left to read. Only the two ends are hashed: `advanced` compares first to last, and hashing every frame in between costs enough wall time to make an unpaced run slower than the hardware it is meant to be outrunning. - **BaseException, not Exception**, since it has to unwind out of arbitrary app code and out of this harness, and both catch `Exception` broadly. It never escapes `run_headless_async`. A self-driving app never goes through `step_frame()`, so its own counter stays at zero however much it painted; the RunResult reports what the cap counted instead. The queue shape returns from `setup()` long before the cap can fire, so its behaviour is unchanged, and a test pins that. The price of stopping a loop that never planned to stop: the unwind goes THROUGH `show()`, so whatever the app's loop does after showing its last frame does not run for that frame. A test pins that too, because it is a real difference and not a rounding error. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzkFnmiBQrxzPEpT3SNyZh
Seven commits added 1,933 lines and twenty-one public names, and touched no documentation at all. This is that documentation. `docs/guide/acts.md` is the new chapter: PixelMark's two constructors and the four behaviours that are decisions rather than defaults, the seven acts, the duck-typed context, `selectable()`, `play_sign()`, and a section on what is refused and why. That last one matters most. Drop from Sky, the two map_route treatments, and a treatment wanting arguments the module cannot invent are all absent on purpose, and a reader who does not know that reads a shorter menu as a bug. Three pages were not merely silent but actively teaching the hand-rolled version of code now in the library: - **character-animation.md** taught `_fly_pose` and `_big_pose` as methods you write. They are `PoseCycler` with different arguments, which is what the commit adding it says in as many words. - **AGENTS.md** said "Convert to a Bitmap ONCE (this is the only per-pixel loop you may write)" over the loop that `PixelMark.from_art` now is, listed the reveals under "never write your own" without the acts that exist so an app need not, and hand-wrote an ACTS tuple beside `ActScheduler` where `play_sign` is the whole loop already written. - **palette-treatments.md** left the reader to guess `map_anchor_distance` from the nickname "anchor", which is the exact run a generating agent burned. `AGENTS.md`'s catalogue key list had also gone stale: it omitted `composition`, which is the one an agent building a sign should read first, plus five others. CLAUDE.md now describes acts as a SECOND contract, deliberately distinct from Transition and not a merger of it, so a later session does not read the BUILDS/DWELLS/EXITS dispatch as the plugin architecture that section forbids. `effects/__init__.py`'s import map gains `acts` and `mark`; with no eager imports it is the only discovery path there is. Verified rather than asserted. Every code sample and every count in the new chapter was executed against the live library, and the repo's existing `test_documented_imports` gate picked up 14 new cases from these pages and passes them. Two figures from earlier commit messages did not reproduce and are not repeated here: `capabilities()["composition"]` is 1,762 characters of JSON rather than 2,087, and the darkowl frame counts cannot be checked from this repo, so the changelog cites what the tests actually assert. make test-unit: 1165 passed. lint-errors clean. mkdocs build --strict clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzkFnmiBQrxzPEpT3SNyZh
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Ten commits. Seven build a portable act system on top of the existing effects; two
fix defects found while documenting it; one is the documentation.
What this adds
A sign is a mark plus a stream of acts over it, where an act is three beats:
build, dwell, exit. The palette treatments were already portable dwells, because a
treatment takes a
PalettePartitionand nothing else. Builds and exits were not:they got written inside the app that owned the mark and reached into its tiles, its
layout and its palette, so reusing one meant copying it.
effects/acts.pyis the missing half. An act is handed a duck-typed contextand knows nothing else, so an app passes itself and inherits nothing.
effects/mark.pysupplies a mark for everything with no app to own one.Without it, a build's closing
ctx.show()had nothing underneath: the dropslanded, the overlay detached, the panel went black, and the act returned
True.reveal_via/hide_viaeach wrap 12transitions and
treatment_dwellwraps 11 treatments, soselectable()returnsone entry per choice rather than per function: 15 builds, 11 dwells, 13 exits.
play_sign()draws all three from anActSchedulerand repeats, leading withthe least-recently-seen and never playing two of a family back to back.
PoseCyclerandMotionAnimator(path="point_to_point"), the two primitives adrawn character needs and a deck of acts cannot supply.
PARTITION_BUILDERS/builder_for()resolve a treatment's partitionnickname to the callable that builds it, and
capabilities()["composition"]catalogues the combinators the catalogue never named.
Two things are refused rather than offered broken, and that judgement is the point:
Drop from Sky(itspre_render_hookmeansstart()never calls the swapcallback, so a mark handed to it stays hidden while the act reports success) and the
two
map_routetreatments (they need a mark's own stroke paths, which is appknowledge).
Two fixes, both found by running the documentation's own examples
3066d63—PixelMark.attach()dropped off-panel cells from the bitmap butkept them in
slots, so the mark described pixels it never drew. Onlytreatment_dwellnoticed, because it handsctx.slotsto a panel-sizedPalettePartition, and it raisedIndexError.from_textoverflows at abouteleven characters and
play_signalways draws a dwell, so an ordinary wordmarktook the sign down on its first cycle.
80596f7—run_headless(app, frames=N)bounded only one of the two programshapes. An app that never returns from
setup()(thewhile self.runningshape agenerated sign uses) never reached the counting loop, so
frameswas silentlyignored and the run went until something killed the process.
Documentation
The seven feature commits added 1,933 lines and 21 public names and touched no
documentation at all.
7eb4575addsdocs/guide/acts.mdand fixes three pages thatwere not merely silent but actively teaching the hand-rolled version of code now in
the library:
character-animation.mdtaught_fly_pose/_big_pose, which arePoseCyclerwith different arguments;AGENTS.mdsaid "this is the only per-pixelloop you may write" over the loop
PixelMark.from_artnow is; andpalette-treatments.mdleft the reader to guessmap_anchor_distancefrom thenickname
"anchor".Verification
make test-unit: 1165 passed.make lint-errorsclean.mkdocs build --strictclean.green on its own, so bisect and per-commit CI stay clean.
library. The repo's existing
test_documented_importsgate picked up 14 new casesfrom these pages and passes them.
capabilities()["composition"]is 1,762 characters of JSON rather than 2,087, andthe darkowl frame counts cannot be checked from this repo.
Not yet verified on hardware. All of this is desktop and simulator only.
🤖 Generated with Claude Code
https://claude.ai/code/session_01WzkFnmiBQrxzPEpT3SNyZh