feat(udg): a press tier — assemble UX the way screenprinting does #148

Merged
peterswimm merged 4 commits from feat/udg-press-tier into main 2026-09-16 18:36:09 +00:00
Owner

Assemble UX the way screenprinting does. PR #147 shipped three monochrome plates as EPS — one path, one fill, no colour operators, true print separations drawn to be inked by whoever prints them. The UDG had nowhere to put them: all nine tiers describe flat properties, so nothing could say "this panel is a hatched frame in terracotta, misregistered 2px, over cocoa paper, with the copy on top."

Adds a tenth tier. A press is a substrate plus an ordered list of pulls; each pull is a plate inked in a pigment at a registration offset, with opacity, blend, fit. The off-register fringe is the aesthetic, not a defect.

What it looks like

Rendered from the real dist/spelwork/skin.css + dist/assets/plates.css — no mockups.
Interactive one-pager: scratchpad/press-review.html (self-contained, opens from a
checkout with no build). Rebuild it all with python3 scratchpad/build_press_review.py.

The two presses. Two pulls fit ::before/::after with no extra DOM; a third needs
one wrapper — which makes ~3 the honest budget, and a three-colour run the classic job.

The two presses

Re-inking. The same press token with only its two pigment vars re-pointed — mechanically
exactly what a [data-theme] block does. Pigments are DTCG aliases, so a press carries no
per-theme variant of its own. (spelwork ships light only, so this shows the mechanism rather
than pretending a theme exists.)

Re-inking: one token, different pigments

Degradation. flattened is identical to live by design — same appearance, baked to
static hex. Only substrate only visibly drops the press, and that is the contract for
email and CLI, stated rather than hoped for.

Degradation across surfaces

The known gap, measured. A frame plate stretched far from its native ratio distorts —
its thickness is filled geometry, so a non-uniform scale scales bar weight anisotropically.
Both panels are 860×190, magnified 3× on the top-left corner.

fit: stretch vs 9-slice at 3x

Scope

Tier — Brand/UDG/products/spelwork/dtcg/press.json; press added to DTCG_TIERS; a DTCG {alias} resolver and a structured→CSS var-family emit in extract.py.

Plates — plate category in assets.py; the three PR #147 EPS registered in assets.yml; opt-in just plates (ghostscript + poppler) converting to a committed, provenance-stamped derivative in Brand/source/derived/; dist/assets/plates.css mask handles.

Block binding — press_eligible on section and column_set; a press field validated in forgeblock_validate.py against the authored source; docs/examples/pressed-panel.forgeblock.json.

Native — structured udgPressMap in Dart and Swift plus the JSON manifest. No sync-manifest.yml change needed: the existing dart-colors / json-tokens-macos consumers already carry every tier, so a press flows the moment a product declares one.

Preview — a Press frame on the design board: each press composed, its separations sheet, and its degradation strip.

Docs — docs/UDG_PRESS_AUTHORING.md, cross-linked from Brand/UDG/README.md, UDG_LABELING_GRAMMAR.md and CLAUDE.md.

Three decisions the repo made, not me

  • A press is a token, never a block. wcag.audit_channels() requires every block to degrade to text · BBS · SMS · RCS · ABC, and the catalog is derived from forgeblock.yml rather than curated. Decoration has no plain-text reduction, so a printed_panel block would have to invent one to pass the gate. A field on blocks that already paint a band means unknowing renderers paint the substrate — degradation by construction, not by policy.
  • Structured $value, not a baked CSS string. A string would have ridden the existing verbatim emit with zero generator change. TOKEN_GAP_PROPOSALS.md rules otherwise: a token is editable by the designer only if it is explicit. A composite string hides every pigment and offset from the FSE editor, the artifact, and Rituals.
  • Authored per product, not in the baseline. A press references brand-specific semantic tokens; chestnutstand, cosmiccrisp, pardner, sundered and truancy ship alternate palettes without bg.surface/text.primary, and a baseline press hard-failed their build.

Two words changed from the approved sketch

ink → pigment: --ink already means text colour in the wireframe kit, 31 uses inside artifact.py's own stylesheet. stack → pulls: shell_kind: stack is taken in surface_profiles.yml.

Testing

  • just check — green. Block count unchanged at 33; no block was added, so the channel-reduction gate is untouched.
  • python -m pytest generator/tests/ — 209 pass (25 new). Covers the var-family expansion, alias-to-var resolution, the native emit carrying token paths rather than baked hex, and the ForgeBlock binding. Negative cases are inline, not fixture files — _test_ignore: true is honoured by both the validator and pytest discovery, so a marked-ignore bad fixture would be exercised by nothing.
  • yamllint --strict .forgejo/workflows/ — clean.
  • dist/ is reproducible: a second just build from the committed state produces no diff.
  • Rendered in Chrome at each step. Three defects were only visible that way and are fixed: masks dropping to a solid block of pigment, fit silently letterboxing, and plate handles failing to resolve. The §4 CSS recipe in the authoring doc was rendered verbatim before publishing.

Impact Statement

Blast radius is small and bounded. dist/ churn is confined to spelwork (the only product declaring a press) plus the shared asset index — every other product is byte-identical, because _press_frame returns "" and the design board's renumbering skips empty frames. No existing token, block or surface changed meaning.

Nothing downstream moves yet. Rituals declares no press, so udg_tokens.{dart,swift,json} gain no press symbols and the sibling repo is untouched. Whether Rituals ships one — and with which art and pigments — is a brand decision, deliberately left to @ville rather than invented here.

What ville is being asked to sign off: the vocabulary (press · pulls · plate · pigment · registration · substrate), which becomes a public contract across three repos, and the two spelwork presses rendered in the Press frame of dist/spelwork/artifact.html. Renaming after the native emit lands downstream would be a cross-repo break, so the naming decision wants to happen now.

Risks accepted, stated plainly:

  • just plates needs ghostscript + poppler. Only when a plate's EPS changes — verification is stdlib-only, so CI and a normal just build never need either.
  • plates.css is 277 KB of self-contained data-URIs, in line with the existing 210 KB icon sprite. Relative paths were tried first and are unusable: a relative url() in a custom property resolves against the using document.
  • Known gap: a frame plate stretched far from its native ratio distorts (see above). Correction to an earlier revision of this description: I had written that 9-slice was unavailable — it is not. It works on Blink as well as WebKit, needs the -webkit-mask-box-image-* longhands (the shorthand silently yields an empty mask) plus a second intrinsically-sized plate export, and Firefox degrades cleanly to the stretch mask. Corner + edge plates remain the fully portable alternative. Which one is a design call, now backed by the comparison above rather than by my prose. Not implemented here — it is its own PR.
  • Open question for design: a pull covers its whole element — no anchor or extent — so a rule that should sit at one edge cannot yet be expressed.

Reversibility: high. The tier is additive and product-scoped; deleting press.json returns every product to byte-identical output.

Assemble UX the way screenprinting does. PR #147 shipped three monochrome plates as EPS — one path, one fill, no colour operators, true print separations drawn to be inked by whoever prints them. The UDG had nowhere to put them: all nine tiers describe flat properties, so nothing could say *"this panel is a hatched frame in terracotta, misregistered 2px, over cocoa paper, with the copy on top."* Adds a tenth tier. A **press** is a **substrate** plus an ordered list of **pulls**; each pull is a **plate** inked in a **pigment** at a **registration** offset, with `opacity`, `blend`, `fit`. The off-register fringe is the aesthetic, not a defect. ## What it looks like Rendered from the real `dist/spelwork/skin.css` + `dist/assets/plates.css` — no mockups. Interactive one-pager: `scratchpad/press-review.html` (self-contained, opens from a checkout with no build). Rebuild it all with `python3 scratchpad/build_press_review.py`. **The two presses.** Two pulls fit `::before`/`::after` with no extra DOM; a third needs one wrapper — which makes ~3 the honest budget, and a three-colour run the classic job. ![The two presses](https://git.toilville.dev/Toilville/toilville-design/raw/commit/8b80c9a/scratchpad/press-review/01-presses.png) **Re-inking.** The same press token with only its two pigment vars re-pointed — mechanically exactly what a `[data-theme]` block does. Pigments are DTCG aliases, so a press carries no per-theme variant of its own. (spelwork ships light only, so this shows the mechanism rather than pretending a theme exists.) ![Re-inking: one token, different pigments](https://git.toilville.dev/Toilville/toilville-design/raw/commit/8b80c9a/scratchpad/press-review/02-reinking.png) **Degradation.** `flattened` is identical to `live` *by design* — same appearance, baked to static hex. Only `substrate only` visibly drops the press, and that is the contract for email and CLI, stated rather than hoped for. ![Degradation across surfaces](https://git.toilville.dev/Toilville/toilville-design/raw/commit/8b80c9a/scratchpad/press-review/03-degradation.png) **The known gap, measured.** A frame plate stretched far from its native ratio distorts — its thickness is *filled geometry*, so a non-uniform scale scales bar weight anisotropically. Both panels are 860×190, magnified 3× on the top-left corner. ![fit: stretch vs 9-slice at 3x](https://git.toilville.dev/Toilville/toilville-design/raw/commit/8b80c9a/scratchpad/press-review/04-fit.png) ## Scope **Tier** — `Brand/UDG/products/spelwork/dtcg/press.json`; `press` added to `DTCG_TIERS`; a DTCG `{alias}` resolver and a structured→CSS var-family emit in `extract.py`. **Plates** — `plate` category in `assets.py`; the three PR #147 EPS registered in `assets.yml`; opt-in `just plates` (ghostscript + poppler) converting to a committed, provenance-stamped derivative in `Brand/source/derived/`; `dist/assets/plates.css` mask handles. **Block binding** — `press_eligible` on `section` and `column_set`; a `press` field validated in `forgeblock_validate.py` against the authored source; `docs/examples/pressed-panel.forgeblock.json`. **Native** — structured `udgPressMap` in Dart and Swift plus the JSON manifest. No `sync-manifest.yml` change needed: the existing `dart-colors` / `json-tokens-macos` consumers already carry every tier, so a press flows the moment a product declares one. **Preview** — a **Press** frame on the design board: each press composed, its separations sheet, and its degradation strip. **Docs** — `docs/UDG_PRESS_AUTHORING.md`, cross-linked from `Brand/UDG/README.md`, `UDG_LABELING_GRAMMAR.md` and `CLAUDE.md`. ### Three decisions the repo made, not me - **A press is a token, never a block.** `wcag.audit_channels()` requires every block to degrade to text · BBS · SMS · RCS · ABC, and the catalog is *derived* from `forgeblock.yml` rather than curated. Decoration has no plain-text reduction, so a `printed_panel` block would have to invent one to pass the gate. A field on blocks that already paint a band means unknowing renderers paint the substrate — degradation by construction, not by policy. - **Structured `$value`, not a baked CSS string.** A string would have ridden the existing verbatim emit with *zero* generator change. `TOKEN_GAP_PROPOSALS.md` rules otherwise: *a token is editable by the designer only if it is explicit.* A composite string hides every pigment and offset from the FSE editor, the artifact, and Rituals. - **Authored per product, not in the baseline.** A press references brand-specific semantic tokens; chestnutstand, cosmiccrisp, pardner, sundered and truancy ship alternate palettes without `bg.surface`/`text.primary`, and a baseline press hard-failed their build. ### Two words changed from the approved sketch `ink` → **`pigment`**: `--ink` already means *text colour* in the wireframe kit, 31 uses inside `artifact.py`'s own stylesheet. `stack` → **`pulls`**: `shell_kind: stack` is taken in `surface_profiles.yml`. ## Testing - `just check` — green. **Block count unchanged at 33**; no block was added, so the channel-reduction gate is untouched. - `python -m pytest generator/tests/` — 209 pass (25 new). Covers the var-family expansion, alias-to-var resolution, the native emit carrying token paths rather than baked hex, and the ForgeBlock binding. Negative cases are **inline**, not fixture files — `_test_ignore: true` is honoured by both the validator and pytest discovery, so a marked-ignore bad fixture would be exercised by nothing. - `yamllint --strict .forgejo/workflows/` — clean. - `dist/` is reproducible: a second `just build` from the committed state produces no diff. - Rendered in Chrome at each step. Three defects were only visible that way and are fixed: masks dropping to a solid block of pigment, `fit` silently letterboxing, and plate handles failing to resolve. The §4 CSS recipe in the authoring doc was rendered verbatim before publishing. ## Impact Statement **Blast radius is small and bounded.** `dist/` churn is confined to **spelwork** (the only product declaring a press) plus the shared asset index — every other product is byte-identical, because `_press_frame` returns `""` and the design board's renumbering skips empty frames. No existing token, block or surface changed meaning. **Nothing downstream moves yet.** Rituals declares no press, so `udg_tokens.{dart,swift,json}` gain no press symbols and the sibling repo is untouched. Whether Rituals ships one — and with which art and pigments — is a brand decision, deliberately left to @ville rather than invented here. **What ville is being asked to sign off:** the vocabulary (`press` · `pulls` · `plate` · `pigment` · `registration` · `substrate`), which becomes a public contract across three repos, and the two spelwork presses rendered in the Press frame of `dist/spelwork/artifact.html`. Renaming after the native emit lands downstream would be a cross-repo break, so the naming decision wants to happen now. **Risks accepted, stated plainly:** - `just plates` needs ghostscript + poppler. Only when a plate's EPS changes — verification is stdlib-only, so CI and a normal `just build` never need either. - `plates.css` is 277 KB of self-contained data-URIs, in line with the existing 210 KB icon sprite. Relative paths were tried first and are unusable: a relative `url()` in a custom property resolves against the *using* document. - **Known gap:** a frame plate stretched far from its native ratio distorts (see above). **Correction to an earlier revision of this description:** I had written that 9-slice was unavailable — it is not. It works on Blink as well as WebKit, needs the `-webkit-mask-box-image-*` *longhands* (the shorthand silently yields an empty mask) plus a second intrinsically-sized plate export, and Firefox degrades cleanly to the stretch mask. Corner + edge plates remain the fully portable alternative. Which one is a design call, now backed by the comparison above rather than by my prose. Not implemented here — it is its own PR. - **Open question for design:** a pull covers its whole element — no anchor or extent — so a rule that should sit at one edge cannot yet be expressed. **Reversibility:** high. The tier is additive and product-scoped; deleting `press.json` returns every product to byte-identical output.
PR #147 shipped three monochrome screenprint plates as EPS (line-horizontal,
line-diagonal, rectangle): one path, one fill, no colour operators — true print
separations, drawn to be inked by whoever prints them. The UDG had nowhere to
put them. All nine tiers describe flat properties, so nothing could say "this
panel is a hatched frame in terracotta, misregistered 2px, over cocoa paper,
with the copy on top."

Adds a tenth tier. A press is a substrate plus an ordered list of pulls; each
pull is a plate inked in a pigment at a registration offset, with opacity,
blend and fit. The off-register fringe is the aesthetic, not a defect.

Three decisions, each forced by something already in the repo:

- A press is a token, never a block. wcag.audit_channels() requires every block
  to degrade to text/BBS/SMS/RCS/ABC, and the catalog is derived from
  forgeblock.yml rather than curated. Decoration has no plain-text reduction, so
  a printed_panel block would have to invent one to pass the gate. Instead:
  a `press_eligible` flag on section and column_set, and a `press` field that
  unknowing renderers ignore — degradation by construction, not by policy.

- $value is structured, not a pre-composed CSS string. A string would ride the
  existing verbatim emit with zero generator change, but TOKEN_GAP_PROPOSALS.md
  sets the rule: a token is editable by the designer only if it is explicit.
  A baked composite hides every pigment and offset from the FSE editor, the
  artifact and Rituals. A press expands into a var family instead.

- Pigments are DTCG aliases, so a press needs no theme variants: the alias emits
  as a CSS var and the existing [data-theme] blocks re-point it.

The tier is authored per product, not in the baseline — a press references
brand-specific semantic tokens, and five products ship alternate palettes
without them.

Plates are masks, not images: CSS cannot ink an externally-referenced SVG. Two
CSS rules bit during this work and are now encoded rather than rediscovered —
a relative url() inside a custom property resolves against the USING document
(so plates.css ships self-contained data-URIs), and a var() inside a custom
property resolves where that property is DECLARED (so handles must load at
:root, or the mask silently drops and raw pigment floods the panel). An SVG
also honours its own viewBox ratio regardless of mask-size, so mask copies
carry preserveAspectRatio="none" or `fit` is a no-op.

EPS conversion is opt-in (`just plates`, ghostscript + poppler) and its SVG
derivative is committed with a provenance stamp, so build and CI verify
freshness without either binary. poppler emits percentage-rgb, which
_norm_color_value silently passes through — normalised at conversion, or the
plate would bake a literal colour into a token-driven asset.

Content always prints above the press; `z` is declared on every pull but only
`under` is implemented, and `over` is rejected by name (an overprint above text
makes the fg/bg pair the WCAG gate measures undecidable).

Known gap: a frame plate stretched far from its native ratio smears its
hatching. The fix is corner + edge plates, the way a screenprinter builds a
border — not an SVG 9-slice hack.

Verified: just check green (block count unchanged at 33 — no block was added),
209 tests pass, workflows lint clean, and dist/ churn is confined to spelwork
plus the shared asset index.
docs(udg): how to add a press
All checks were successful
PR policy check / policy-gate (pull_request) Successful in 0s
Security Scans / Trivy filesystem scan (pull_request) Successful in 38s
Security Scans / Gitleaks secret scan (pull_request) Successful in 41s
Test Generator / lint-workflows (pull_request) Successful in 19s
Block Validation / validate (pull_request) Successful in 1m7s
Test Generator / lint-prose (pull_request) Successful in 25s
UDG dist drift gate / dist/ matches dtcg + udg sources (pull_request) Successful in 16s
Copy coverage / Design copy reaching rendered HTML (pull_request) Successful in 35s
Test Generator / test (pull_request) Successful in 3m51s
90a8f9e21a
The tier shipped without a procedure. Adds docs/UDG_PRESS_AUTHORING.md — land
the plate, author the press, bind it, consume it — and cross-links it from the
three places someone would start: the model in Brand/UDG/README.md, the block
binding in UDG_LABELING_GRAMMAR.md, and CLAUDE.md's key commands.

Procedure only; it points at the authorities rather than restating their values.

Carries the four rules that cost an afternoon during the build, because each
one fails silently-plausible rather than loudly:

  - load plates.css at :root, or the mask drops and raw pigment floods the panel
  - never put a relative url() in a custom property
  - `fit` needs preserveAspectRatio="none" or stretch silently letterboxes
  - normalise poppler's percentage-rgb, or a literal colour bakes into a
    token-driven asset

Also records the two open gaps honestly: a frame plate smears when stretched
far from its native ratio (fix is corner + edge plates, not a 9-slice hack),
and a pull has no anchor or extent, so a rule that should sit at one edge
cannot yet be expressed.

The §4 CSS recipe was rendered verbatim from the doc against the real
skin.css + plates.css before publishing — it is copy-paste, not illustrative.

Verified: just check green, 209 tests pass, and every `just` target the guide
names exists.
docs(udg): rendered press examples for review
All checks were successful
Test Generator / lint-workflows (pull_request) Successful in 19s
Test Generator / lint-prose (pull_request) Successful in 20s
Security Scans / Gitleaks secret scan (pull_request) Successful in 27s
Security Scans / Trivy filesystem scan (pull_request) Successful in 32s
UDG dist drift gate / dist/ matches dtcg + udg sources (pull_request) Successful in 13s
Copy coverage / Design copy reaching rendered HTML (pull_request) Successful in 26s
PR policy check / policy-gate (pull_request) Successful in 0s
Block Validation / validate (pull_request) Successful in 53s
Test Generator / test (pull_request) Successful in 3m19s
edd7c53959
#148 described the press tier in prose. Ville reviews pictures.

Adds scratchpad/press-review.html — self-contained, opens from a checkout with
no build — plus three stills the PR body embeds. Follows the precedent set by
scratchpad/udg-block-renderer-review.html (#328), which exists because #174's
review link never arrived.

Everything is rendered from the real dist/spelwork/skin.css and
dist/assets/plates.css. Nothing here is a mockup: if the tokens change and the
artifact is not rebuilt, the difference is visible rather than hidden.

Three things the prose could not show:
  - the misregistration reading as intended rather than as a defect
  - re-inking: one token, pigments re-pointed, which is mechanically what a
    [data-theme] block does — spelwork ships light only, so this demonstrates
    the mechanism rather than pretending a theme exists
  - degradation side by side, including that `flattened` is identical to `live`
    BY DESIGN and only `substrate only` visibly drops the press

build_press_review.py regenerates all of it. Section pages are intermediates
and go to the gitignored out/press-review/, so a rebuild never dirties git
status; only the stills and the one-page artifact are committed.
docs(udg): correct the known gap — 9-slice works, and here is the evidence
All checks were successful
Security Scans / Gitleaks secret scan (pull_request) Successful in 40s
Security Scans / Trivy filesystem scan (pull_request) Successful in 49s
Test Generator / lint-workflows (pull_request) Successful in 20s
Block Validation / validate (pull_request) Successful in 1m20s
Test Generator / lint-prose (pull_request) Successful in 31s
PR policy check / policy-gate (pull_request) Successful in 0s
UDG dist drift gate / dist/ matches dtcg + udg sources (pull_request) Successful in 16s
Copy coverage / Design copy reaching rendered HTML (pull_request) Successful in 36s
Test Generator / test (pull_request) Successful in 3m31s
8b80c9a64d
I wrote that the frame-stretch fix was corner + edge plates, "not an SVG
9-slice hack (mask-border is effectively Safari-only)". Testing it disproves
that, and the claim was sitting in a PR under review, so it is corrected in
both places it appeared.

What is actually true, measured rather than read off a support table:

  - Being vector is not the lever. The plate already IS an SVG; the frame's
    thickness is FILLED geometry, so a non-uniform scale scales bar weight
    anisotropically. At 860x190 the vertical bars come out ~4x the weight of
    the horizontal, wobble stretched into coarse teeth.
  - 9-slice fixes it, on Blink as well as WebKit. Equal bar weight, wobble
    intact on every edge.
  - It needs the -webkit-mask-box-image-* LONGHANDS: the shorthand silently
    yields an empty mask, which floods the panel with pigment — the same
    failure mode as the :root handle bug.
  - It needs a second, intrinsically-sized plate export. Slice percentages
    resolve against intrinsic size, and the plates emitted for `fit` are
    viewBox-only with preserveAspectRatio="none", which `fit` requires.
  - Firefox ignores mask-border but degrades cleanly to the stretch mask when
    both are set. Verified in Firefox, not assumed.

Also: `fit: cover` fully fixes TEXTURE plates (crop, not distort) but is
unreliable for a frame — at some aspect ratios it crops the border band away.

The review artifact gains a fourth section carrying the comparison at 3x
magnification, so the choice between 9-slice and corner + edge plates is a
design call made against evidence rather than against my prose. Corner + edge
plates remain the fully portable answer and need no engine support.

Not implementing `fit: slice` here — it is a second plate export plus a new
enum value, and belongs in its own PR rather than growing this one.
peterswimm deleted branch feat/udg-press-tier 2026-09-16 18:36:09 +00:00
Sign in to join this conversation.
No description provided.