Skip to content

Marks and acts: the reusable half of a sign - #5

Merged
czei merged 10 commits into
masterfrom
pose-cycling-and-point-to-point
Sep 2, 2026
Merged

Marks and acts: the reusable half of a sign#5
czei merged 10 commits into
masterfrom
pose-cycling-and-point-to-point

Conversation

@czei

@czei czei commented Sep 2, 2026

Copy link
Copy Markdown
Owner

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 PalettePartition and 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.py is the missing half. An act is handed a duck-typed context
    and knows nothing else, so an app passes itself and inherits nothing.
  • effects/mark.py supplies a mark for everything with no app to own one.
    Without it, a build's closing ctx.show() had nothing underneath: the drops
    landed, the overlay detached, the panel went black, and the act returned True.
  • Seven functions, 39 selections. reveal_via / hide_via each wrap 12
    transitions and treatment_dwell wraps 11 treatments, so selectable() returns
    one entry per choice rather than per function: 15 builds, 11 dwells, 13 exits.
  • play_sign() draws all three from an ActScheduler and repeats, leading with
    the least-recently-seen and never playing two of a family back to back.
  • PoseCycler and MotionAnimator(path="point_to_point"), the two primitives a
    drawn character needs and a deck of acts cannot supply.
  • PARTITION_BUILDERS / builder_for() resolve a treatment's partition
    nickname 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 (its pre_render_hook means start() never calls the swap
callback, so a mark handed to it stays hidden while the act reports success) and the
two map_route treatments (they need a mark's own stroke paths, which is app
knowledge).

Two fixes, both found by running the documentation's own examples

  • 3066d63PixelMark.attach() dropped off-panel cells from the bitmap but
    kept them in slots, so the mark described pixels it never drew. Only
    treatment_dwell noticed, because it hands ctx.slots to a panel-sized
    PalettePartition, and it raised IndexError. from_text overflows at about
    eleven characters and play_sign always draws a dwell, so an ordinary wordmark
    took the sign down on its first cycle.
  • 80596f7run_headless(app, frames=N) bounded only one of the two program
    shapes. An app that never returns from setup() (the while self.running shape a
    generated sign uses) never reached the counting loop, so frames was silently
    ignored 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. 7eb4575 adds docs/guide/acts.md and fixes three pages that
were not merely silent but actively teaching the hand-rolled version of code now in
the library: character-animation.md taught _fly_pose / _big_pose, which are
PoseCycler with different arguments; AGENTS.md said "this is the only per-pixel
loop you may write" over the loop PixelMark.from_art now is; and
palette-treatments.md left the reader to guess map_anchor_distance from the
nickname "anchor".

Verification

  • make test-unit: 1165 passed. make lint-errors clean. mkdocs build --strict clean.
  • Each of the three new commits was run independently in a detached worktree and is
    green on its own, so bisect and per-commit CI stay clean.
  • Every code sample and every count in the new chapter was executed against the live
    library. 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:
    capabilities()["composition"] is 1,762 characters of JSON rather than 2,087, and
    the 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

czei and others added 10 commits August 29, 2026 05:39
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
@czei
czei merged commit 4a51d3b into master Sep 2, 2026
5 of 6 checks passed
@czei
czei deleted the pose-cycling-and-point-to-point branch September 2, 2026 15:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant