@usableapp/cardds 0.7.86 → 0.7.88

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,40 @@
2
2
 
3
3
  > For: a project that consumes `@usableapp/cardds` and its AI — what changed between versions, written as before → after, so a screen can be brought up to date without reading the source. Newest first. A consumer never edits cardds; if something here leaves you without a way to do what you did before, report the gap.
4
4
 
5
+ ## 0.7.88 — 2026-10-13
6
+
7
+ Nothing here breaks an existing screen: a prop pair (`Card leave` + `onLeft`) and three knobs are added and none removed, no class or markup of an existing screen changed (a `Card` without `leave` is as before), and an `EmptyState` with its knob unset stands where it did in 0.7.87.
8
+
9
+ ### Added
10
+ - **`Card leave` + `onLeft`: the card floats away, and the app is told when it has gone** (asked after a send: one `Card` standing in a `Page`, with a `StampOn main arrive` that has just landed on it, leaves, and the app then changes the page). Before → after: the only thing that left was the `PostDesk`'s own card (`state="sent"`), and a `Card` could be hidden only by the app taking it out of the tree, with no motion and no word of when → `leave` (a boolean, the app's, controlled like an input) sends the card up and out with a small turn, all it holds with it, and `onLeft` is called once when the motion has ended (at once with reduced motion, or when the card has no motion). Set back to `false` the card stands again where it was, and a leave set back before it ended says nothing. A card that has left takes no taps and is `inert` (out of the accessibility tree); it moves by `translate` and `rotate` only, so the room round it does not change. If a send fails the app never sets `leave` and the card stays. A card in a box that clips (a scrolling `StackSheet` body clips at its edge) is cut there as it goes. A card that is a `SheetStack` sheet (`stack`) ignores `leave`. A card given `leave` (true or false) wears `card--leaver`, and `card--away` while it is asked to go: two new classes, on no other card. Knobs `--leave-rise` (160%, how far it goes up, a share of its own height) and `--leave-turn` (-5deg); the time is `--motion-sheet`. Under `data-finish="sticker"` a leaving lone card turns to `--leave-turn` from its lean and back. Stories `Card · Leaves`, `Controlled` and `AfterTheSend` (the `StampOn arrive` → `leave` → `onLeft` sequence); the `Card` JSDoc, and so `cardds get Card` and `cardds find "card flies away"`, name it.
11
+ - **`Row htmlFor`: a row that is a label can name the control it presses.** Before → after: `Row as="label"` typed a div's attributes, so `htmlFor` was a type error (it worked when run) → `htmlFor` is a prop. It is needed when the row's `end` holds a button before its `Check` (a picked row's own move, "open the map"): a label presses the first control inside it, so without `htmlFor` a touch on the row's words pressed that button. Story `Row · PickShowsItsMove`: pick one row, the picked row shows its button, a second touch keeps the pick.
12
+ - **`cardds check` warns on a label row that holds a button and names no control** (`row-label-button`): `<Row as="label">` with an `IconBtn`, `Btn` or `Link` inside and no `htmlFor`. Before, the check was silent and the row's words pressed the button.
13
+ - **`--empty-state-at`: where an `EmptyState` stands in the free room of what holds it.** A number from 0 to 1, the share of the free room that stands above it. Before → after: alone in a `Step` it stood two parts from the top and three from the bottom (40%) and a theme could not move it → that is the knob's default, 0.4, and `:root { --empty-state-at: 0.2 }` in a project's theme puts it a fifth down on every screen (or set it on a `Screen` / `Step` for the ones inside). Alone in a `SheetBody` (a `Sheet`, or a `StackSheet` of a `SheetStack`) it stood at the top of the body with no way to lower it → still at the top by default (0), and the knob lowers it by that share of the body's free room, 0.4 putting it where a `Step` puts it. The body is a flex column only while an `EmptyState` is its only child; the geometry of every other body is as before. Stories `EmptyState · HigherUp` and `EmptyState · InASheet`; the `EmptyState` JSDoc, and so `cardds get EmptyState`, names the knob.
14
+
15
+ ### Changed
16
+ - **The `PostDesk`'s leave and the `Card`'s are one mechanic.** Before → after: `post.css` wrote the sent card's `--_y: -160%`, `rotate: -5deg` and its `translate` / `rotate` transition for itself → one rule in card.css (LEAVE) holds the transition and reads `--leave-rise` and `--leave-turn` for both holders, and post.css keeps only what is its own (the half's `--_x`). Nothing changes for a screen: a `PostDesk` sent draws, moves and times as in 0.7.87 (computed style of all 449 stories identical before and after), and a theme that set neither knob sees no difference.
17
+
18
+ ## 0.7.87 — 2026-10-12
19
+
20
+ Nothing here breaks an existing screen: no class and no markup changed, six knobs are added and none removed; three mechanics that two files each wrote twice are now one rule each, and every screen without `data-finish` draws as in 0.7.86.
21
+
22
+ ### Added
23
+ - **Four finishes of a card's paper, four values of one attribute: `<html data-finish="lit | print | sticker | ink">`, beside any palette or none** (Lh 2026-10-12, ruled from the timebank home with each injected; teeth, tape, ticket and slab rejected). Before → after: `data-finish="lit"` was a trial (not in the README) and the only value → `lit` (light from above) is released as it was, and three more: `print` (printed paper: an ink line in the card's own ink, a hard misregistered offset, grain over the card, a dotted rule under a lone card's head), `sticker` (a die-cut sticker: a white cut line, a short hard drop, a lone card leans, buttons with a bottom edge that press down), `ink` (a poster / comic print: a thick line and a hard offset in the surface's ink). No attribute = nothing moves. Under every finish the wireframe's two borders step back and a card in a closed stack's base stays flat (one generic `[data-finish]` block, as `[data-palette]`). Six knobs: `--print-off` · `--print-grain` · `--sticker-cut` · `--sticker-tilt` (0deg = no lean) · `--ink-w` · `--ink-off`. README "Finish". Gallery: the finish is its own switch beside the palette (key `f`; `lit` is no longer a palette), stories `Card · Finishes` and `Home · Finish`.
24
+ - **The catalog knows the finishes.** `npx cardds find "printed paper"` (or `"sticker"`, `"finish"`) answers the finish first, kind `finish`, with its knobs; `cardds list` shows the four after the components; `find_components` over the MCP the same. Read from css/finish.css's own header, never a kept list; one field `finishes` on dist/catalog.json.
25
+ - **`cardds get` / `get_component` now list a component's knobs (joined by its classes, so a knob named for an old name is found), what it composes and what composes it; `find` matches the role's note and stands-in, so a question by intention finds the piece.** `knobs` is each row of docs/knobs.md whose selector names a class the component emits (`--action-note-*` under `BarNote`, `--tile-badge-*` under `JourneyBadge`, `--modal-drawer-*` under `ModalAsk`, `--band-*` under `NumberBand`), with its default, what it sets and the selector; `composes` is the catalog names the component's own source renders or calls (`Fab` → `IconBtn`, `Icon`), `composedBy` the inverse. `find "turned on or off"` is `Toggle`, `find "digit by digit"` is `Pin`; and the component NAMED by a word (`Card`, `Sheet`, `Stamp`) now ranks before the family that contains it (`FeedCard`, `SheetStack`), whatever their other words say; and a screen that answers fewer of a question's words than the best component comes after the components, so `find "a setting turned on or off"` is `Toggle` first, not eight screens that say "off" (a screen that answers every word, as `Snackbar · Success` does for "good news in passing", still comes first). The catalog's `classes` now include the `className="…"` literals a component writes, not only its `cx()` arguments (BEM parts such as `.snackbar__body` were missing, and 230 of the 640 knobs would have joined nothing). dist/catalog.json 1.78 → 1.87 MB.
26
+ - **tests/twins.spec.js: what is written twice under two names fails the suite** — two keyframes with one body, two rules with one declaration set (three declarations or more; the pairs that are not twins are named in the test with why), or two pieces in one role cell of docs/roles.md with no ruling written on either row (no "stands in for", no note). The census of 2026-10-12 found these with three hand scripts; now the suite does. Plain node over css/*.css and docs/roles.md; a block the reader cannot close throws naming the file, and the rules read are counted against the files' own braces.
27
+
28
+ ### Changed
29
+ - **The postmark is struck as a stamp is.** Before → after: on a `PostDesk` the postmark slid in from up-left, turning, in `--motion-press` → it is shown large and faint and pressed down past its size to rest, as a `StampOn arrive` is, in `--stamp-on-arrive-time` (default `--motion-sheet`); the one `@keyframes strike` lives in tokens.css and both read it (Lh 2026-10-12: one strike). Where it rests is unchanged.
30
+ - **One `turn` keyframe for the ring that turns while waiting.** Before → after: `Spinner` read `spinner-turn` (numbers.css) and a busy `FormNote` read `form-note-turn` (forms.css), two keyframes with one body → both read `@keyframes turn` in tokens.css, with the system's other shared motion (`--motion-*`); same 0.9s, linear, infinite, still with reduced motion.
31
+ - **One rule for a pick's hidden native input.** Before → after: `.sticky-pick__swatch > input` (sticky.css) repeated the six declarations of `:is(.chip--pick, .mood__opt, .tile--pick) > input` (choice.css) → the swatch is a fourth holder in choice.css's `:is()` list and sticky.css has no copy; same specificity (`:is`), same look and press.
32
+ - **One rule for the scrolling screen that becomes a column.** Before → after: actions.css made a non-fill `.screen` a full-height flex column when its last child is an `.action-bar`, and card.css made it one when it holds a `.screen__centre`, the same three declarations twice → one rule in step.css, `.screen:not(.screen--fill):has(> .action-bar:last-child, > .screen__centre)`; the bar's sticky row (actions.css) and the centre's flex (card.css) are as before.
33
+ - **`StackSheet`: a long title's run is measured by the Sheet's measurer.** Before → after: the stack measured its heading's inner span by its rounded `scrollWidth` and set `--_over` / `--_run` on its own → one `runOver` in sheetMeasure.ts measures the words themselves (a Range, ceiled) for the `Sheet` and the `StackSheet` alike, so the stack's run reaches its last letter by the Sheet's rule — measured on screen · Home at 2.2rem: `--_over` 37 → 38 px, `--_run` 4.2 → 4.3 s; 30 px a second plus the pauses, as before (the stack never ran the 6 s fallback). `--stack-sheet-run-time` and `--sheet-title-run-time` unchanged; the two keyframes stay.
34
+ - **`useStackCard`: one `docPointer()` and one `armSwallow()` serve both pulls** (the open sheet's fold and reload, the peeked head's lift) — they were written twice in stackCard.ts. Nothing changes for a screen.
35
+ - **`setRefs(own, ref)` in src/state.ts hands a node to a component's own ref and the caller's** — `Rows`, `CardFeed` and `Screen` wrote it three times. Nothing changes for a screen.
36
+ - **`pressable()` in src/pressable.ts: a box that is a `<button>` when it can be pressed** — `Stamp`, `StampPlace`, `StampCell` and `Sticky` wrote it four times; every story's rendered html is byte-identical (454 stories diffed). Nothing changes for a screen.
37
+ - **Tooling: the style snapshot holds every animation still before it reads a style** — one that loops (a spinner's turn, a grip's dissolve, a title's run) at its first frame, one that ends (a modal's drop, a sheet's slide) at its end, and the Boot story is sampled once its splash has left. Before, `snapshot:check` against the tree's own CSS named four rows that were time, not style (a spinner mid-turn in three stories, the splash mid-fade); now `snapshot:write` then `snapshot:check` on one tree reports identical.
38
+
5
39
  ## 0.7.86 — 2026-10-11
6
40
 
7
41
  Nothing here breaks an existing screen: every prop is new and optional, and a screen that does not use them draws and behaves as in 0.7.85.
package/README.md CHANGED
@@ -146,12 +146,18 @@ npx cardds find [words…] [--represents person] [--shape group] [--interact pic
146
146
  # by the AXES first (docs/roles.md: what it stands for · one or a group · what the member can do), words second;
147
147
  # the screens already drawn, then components, then what is used with them (nothing → a hint, not a list)
148
148
  npx cardds scaffold <id> # a whole file from a screen: tsx (the code) or --html (a static document linking cardds.css)
149
- npx cardds get <Name> [--html] # one component: its role, props (a ReactNode prop is a slot) + variations with code
149
+ npx cardds get <Name> [--html] # one component: its role, props (a ReactNode prop is a slot), what it composes, its knobs + variations with code
150
150
  npx cardds list # every name by what it is for: one · group-brief · group-detail, the show-only first, then by what the member can do
151
151
  npx cardds check <dir> # the rules, before a screen is called done
152
152
  npx cardds guide # the few rules no checker sees, in one short text
153
153
  ```
154
154
 
155
+ `get` lists the component's **knobs** — each `--name` with its default, what it sets and the selector that reads it, joined by the classes the
156
+ component emits, so a knob named for an old name (`--action-note-*`) is found under the component that has it now (`BarNote`). It says what the
157
+ component **composes** (the catalog names its own source renders or calls: `Fab` → `IconBtn`, `Icon`) and what it is **composed by** (the inverse).
158
+ `find` matches the role's note and what a component stands in for as well as its name and description, so a question by intention ("a setting
159
+ turned on or off", "a code digit by digit") finds the piece (`Toggle`, `Pin`).
160
+
155
161
  `list` · `get` · `find` · `scaffold` take `--json` (the function's result) and `--root <path>` (a cardds checkout: rebuilt first, so never stale). The same
156
162
  five answers over MCP — `list_components` · `get_component` · `find_components` · `scaffold_screen` · `check_source`:
157
163
 
@@ -935,6 +941,25 @@ Copy any block in `palette.css`, rename, retune:
935
941
  }
936
942
  ```
937
943
 
944
+ ## Finish
945
+
946
+ The finish of a card's paper — a style apart from colour, beside any palette or none:
947
+
948
+ ```html
949
+ <html data-palette="orchard" data-finish="print"> <!-- or on any ancestor; no attribute = the flat look -->
950
+ ```
951
+
952
+ Four values, one attribute (`css/finish.css`; the header names each and its knobs):
953
+
954
+ | `data-finish` | the paper | knobs (set in `theme.css`, as any knob) |
955
+ |---|---|---|
956
+ | `lit` | lit from above: a lit top edge, a hairline ring, a layered shadow that grows with height; filled buttons stand up and go down under the finger; fields sunk | the shadows every component reads (`--shadow-card`, `--shadow-float`, `--shadow-sheet`) |
957
+ | `print` | printed paper: an ink line in the card's own ink, a hard misregistered offset, grain over the card, a dotted rule under a lone card's head | `--print-off` (the offset, 3px) · `--print-grain` (the grain's opacity, 0.32) |
958
+ | `sticker` | a die-cut sticker: a white cut line, a short hard drop, a lone card leans (never one in a stack, a list or a feed), buttons with a bottom edge that press down | `--sticker-cut` (5px) · `--sticker-tilt` (0.8deg; `0deg` = straight) |
959
+ | `ink` | a poster / comic print: a thick line in the surface's ink round every boundary and control, a hard offset in the same ink | `--ink-w` (2.5px) · `--ink-off` (5px) |
960
+
961
+ Under every finish the wireframe's two borders step back (`--card-border`, `--action-border`; ink puts its own line there), a card in a closed stack's base stays flat, and the information inside a card stays flat — only a container or a control gets an edge. Colours are mixes of the roles, so a finish sits in any palette. The gallery's nav has a finish switch (key `f`); `Card · Finishes` shows the four side by side, `Home · Finish` a home under `print`.
962
+
938
963
  ## Scale: everything is rem
939
964
 
940
965
  Every length token resolves to rem — a multiplier × one of four rem bases
package/bin/cardds.mjs CHANGED
@@ -97,22 +97,27 @@ const asText = {
97
97
  ? [...row('helpers: ', cs.filter((c) => HELPERS.has(c.name))), ...row('structure: ', cs.filter((c) => !HELPERS.has(c.name)))]
98
98
  : [...row('show ', cs.filter((c) => !c.interact)), ...catalog.KINDS.flatMap((k) => row(k.padEnd(10), cs.filter((c) => c.interact && c.interact.includes(k))))];
99
99
  return catalog.SHAPES.map((shape) => [shape, components.filter((c) => c.shape === shape)])
100
- .filter(([, cs]) => cs.length).flatMap(([shape, cs]) => [shape, ...section(shape, cs)]).join('\n');
100
+ .filter(([, cs]) => cs.length).flatMap(([shape, cs]) => [shape, ...section(shape, cs)])
101
+ .concat(components.finishes.length ? ['finish (data-finish, the paper — beside any palette)', ...components.finishes.map((f) => ` ${f.name.padEnd(8)}${catalog.sentence(f.is)}${f.knobs.length ? ` · knobs ${f.knobs.map((k) => k.name).join(' ')}` : ''}`)] : []).join('\n');
101
102
  },
102
103
  get: (c, { html }) => [
103
104
  `${c.name} (${c.group})${c.element ? ` — forwards the <${c.element}> attributes` : ''}`,
104
105
  one(c.description),
105
106
  ...(c.role ? [`role: represents ${[c.role.represents, c.role.count, c.role.density, c.role.job].flat().join(' · ')}${c.role.standsInFor.length ? `; stands in for: ${c.role.standsInFor.map((s) => (s.condition ? `${s.name} (${s.condition})` : s.name)).join('; ')}` : ''}`] : []),
107
+ ...(c.composes.length ? [`composes: ${c.composes.join(' · ')}`] : []),
108
+ ...(c.composedBy.length ? [`composed by: ${c.composedBy.join(' · ')}`] : []),
106
109
  ...(c.params ? ['\nparameters', ...(c.params.length ? [] : [' (none)']), ...c.params.map((p) => ` ${p.name}${p.optional && !p.default ? '?' : ''}: ${p.type}${p.default ? ` = ${p.default}` : ''}`)] : [
107
110
  '\nprops',
108
111
  ...c.props.map((p) => ` ${p.name}${p.optional ? '?' : ''}: ${p.type}${p.slot ? ' [slot]' : ''}${p.description ? ` — ${one(p.description)}` : ''}`)]),
112
+ `\nknobs (${c.knobs.length})${c.knobs.length ? '' : ' — nothing to retune from a theme'}`,
113
+ ...c.knobs.map((k) => ` ${k.name}: ${k.default} — ${k.sets} on ${k.on}`),
109
114
  `\nvariations (${c.variations.length})`,
110
115
  ...c.variations.map((v) => `\n ${v.id} — ${one(v.description)}\n${indent(v.tsx)}${html ? `\n html:\n${indent(v.html, ' ')}` : ''}`),
111
116
  ...(c.alsoUsedIn ? [`\nalso used in: ${c.alsoUsedIn.join(' ')}\n(--all prints their code too)`] : []),
112
117
  ].join('\n'),
113
118
  find: (r) => [
114
119
  ...(r.fallback ? [`nothing matches "${r.query}" — ${r.hint} (${r.screens} screens drawn)`]
115
- : r.results.length ? r.results.map((x) => `${x.kind === 'screen' ? 'screen ' : 'component '}${x.id ?? x.name}${x.role ? ` [${[...x.role.represents, x.role.shape, x.role.interact ? x.role.interact.join('+') : 'show'].join('·')}]` : ''} — ${x.summary}`) : [r.hint]),
120
+ : r.results.length ? r.results.map((x) => `${x.kind.padEnd(10)}${x.id ?? x.name}${x.role ? ` [${[...x.role.represents, x.role.shape, x.role.interact ? x.role.interact.join('+') : 'show'].join('·')}]` : ''} — ${x.summary}${x.kind === 'finish' && x.knobs.length ? ` · knobs ${x.knobs.join(' ')}` : ''}`) : [r.hint]),
116
121
  ...(r.usedTogether?.length ? [`\nused together: ${r.usedTogether.map((u) => `${u.name} (${u.stories})`).join(' ')}`] : []),
117
122
  ].join('\n'),
118
123
  scaffold: (r) => typeof r === 'string' ? r.trimEnd() : r.html ? r.html.trimEnd() : r.map((x) => `${x.id} — ${x.summary}`).join('\n'),
@@ -130,7 +135,7 @@ function ask(cmd, argv) {
130
135
  if (cmd === 'mcp') return import('../lib/mcp.mjs').then((m) => m.serve({ root }));
131
136
  const cat = catalog.load({ root });
132
137
  const q = argv.join(' ');
133
- const result = cmd === 'list' ? catalog.list(cat) : cmd === 'get' ? catalog.get(cat, q, { html, all }) : cmd === 'find' ? catalog.find(cat, q, axes) : catalog.scaffold(cat, q || undefined, { format: html ? 'html' : 'tsx', css });
138
+ const result = cmd === 'list' ? Object.assign(catalog.list(cat), { finishes: catalog.finishes(cat) }) : cmd === 'get' ? catalog.get(cat, q, { html, all }) : cmd === 'find' ? catalog.find(cat, q, axes) : catalog.scaffold(cat, q || undefined, { format: html ? 'html' : 'tsx', css });
134
139
  if (result.error) { console.error(`cardds ${cmd}: ${result.error}${result.closest ? ` — closest: ${result.closest.join(', ')}` : ''}`); return 1; }
135
140
  if (result.warning && !json) console.error(`cardds scaffold: ${result.warning}`);
136
141
  console.log(json ? JSON.stringify(result) : asText[cmd](result, { html }));
package/css/actions.css CHANGED
@@ -273,10 +273,9 @@
273
273
  display: flex;
274
274
  gap: var(--sp-2);
275
275
  }
276
- /* a bar INSIDE a scrolling (not fill) screen (Lh 2026-09-24, 0.7.0): the screen is a column at least one phone tall, the bar its
276
+ /* a bar INSIDE a scrolling (not fill) screen (Lh 2026-09-24, 0.7.0): the screen is a column at least one phone tall (step.css), the bar its
277
277
  last row — at the foot when the words are short, and sticky to the viewport's foot (above the keyboard) while they scroll;
278
278
  it takes the room it needs in the column, so nothing is added to the content for it */
279
- .screen:not(.screen--fill):has(> .action-bar:last-child) { display: flex; flex-direction: column; min-height: 100dvh; }
280
279
  .screen:not(.screen--fill) > .action-bar:last-child {
281
280
  position: sticky; bottom: var(--kb, 0rem);
282
281
  max-width: none;
package/css/card.css CHANGED
@@ -25,6 +25,15 @@
25
25
  screens. Children shrink to the track; they never size the card. */
26
26
  .card > * { min-width: 0; }
27
27
 
28
+ /* LEAVE — a card that goes: up and out with a small turn, as one piece (translate and rotate only, so nothing round it moves). The post desk's
29
+ card once `sent` and a `Card` with `leave` read the same two knobs; the time is --motion-sheet. `.card--leaver` is a card whose app may ask it
30
+ to go (it holds the transition, so it comes back as it went); `.card--away` is one that has been asked. A knob of the time would read dead:
31
+ the knob walk switches motion off */
32
+ :is(.card--leaver, .post-desk__card) { transition: translate var(--motion-sheet) ease, rotate var(--motion-sheet) ease; }
33
+ :is(.card--away, .post-desk--sent .post-desk__card) { --_y: calc(var(--leave-rise, 160%) * -1); rotate: var(--leave-turn, -5deg); }
34
+ .card--away { translate: 0 var(--_y); }
35
+ @media (prefers-reduced-motion: reduce) { :is(.card--leaver, .post-desk__card) { transition: none; } }
36
+
28
37
  .card--2 { --card-bg: var(--card-2-bg); --card-ink: var(--card-2-ink); --card-muted: var(--card-2-muted); }
29
38
  /* dark/accent slot: accent-toned controls would vanish — the slot re-points
30
39
  the accent tokens, so every accent consumer (btn--primary, chip--on,
@@ -212,12 +221,11 @@
212
221
  actions.css) and scrolls up like any page — nothing is capped, nothing
213
222
  shrinks, no scroll box inside a box.
214
223
  So on a .screen that is NOT --fill the group is in flow: the column is at
215
- least one phone tall, the group takes the room after the top bar and
224
+ least one phone tall (step.css), the group takes the room after the top bar and
216
225
  centres its children in it. The screen's own inset stays the inset.
217
226
  <div class="screen"><header class="topbar">…</header>
218
227
  <div class="screen__centre"><article class="card">…</article><div class="pager__at">…</div></div>
219
228
  </div><div class="action-bar">…</div> */
220
- .screen:not(.screen--fill):has(> .screen__centre) { min-height: 100dvh; display: flex; flex-direction: column; }
221
229
  .screen:not(.screen--fill) > .screen__centre { flex: 1; padding-inline: 0; align-content: center; }
222
230
 
223
231
  /* ---- the card that changes slides: out one side, the next in from the other ----
package/css/choice.css CHANGED
@@ -84,8 +84,8 @@
84
84
  .chip-grid > .chip { justify-self: stretch; justify-content: center; min-height: var(--btn-h); padding-inline: var(--sp-2); }
85
85
  .chip--pick { position: relative; } /* the input covers the whole chip, on a row as in a grid */
86
86
  /* the hidden input, the same shape wherever a native radio/checkbox drives a picked look:
87
- a chip, a mood dot, a tile (media.css) */
88
- :is(.chip--pick, .mood__opt, .tile--pick) > input { appearance: none; position: absolute; inset: 0; margin: 0; opacity: 0; cursor: inherit; }
87
+ a chip, a mood dot, a tile (media.css), a sticky's tone swatch (sticky.css) — the native input lies over its whole box, unseen */
88
+ :is(.chip--pick, .mood__opt, .tile--pick, .sticky-pick__swatch) > input { appearance: none; position: absolute; inset: 0; margin: 0; opacity: 0; cursor: inherit; }
89
89
  :is(.chip--pick, .tile--pick):has(:checked) { background: var(--accent); color: var(--on-accent); border-color: transparent; }
90
90
  /* --sign: the trailing + that turns 45° into an × when picked — one mask, one rotate, no swap (Lh 2026-09-16) */
91
91
  .chip--sign { padding-right: var(--sp-2); }
package/css/finish.css CHANGED
@@ -1,17 +1,35 @@
1
1
  /* ============================================================
2
- cardds/finish.css — FINISH: light, shadow and edges. A style of its own (Lh 2026-10-02: the plain
3
- look read flat, "not finished"), apart from colour: palette.css says WHICH colours, this says how
4
- the material is lit. Apply with <html data-finish="lit"> beside any palette (or none, or "own").
5
- No attribute = nothing here applies — the flat look, unchanged.
2
+ cardds/finish.css — FINISH: the finish of a card's paper. A style of its own (Lh 2026-10-02: the plain
3
+ look read flat, "not finished"), apart from colour: palette.css says WHICH colours, this says what the
4
+ material is and how it is lit. Apply with <html data-finish="…"> (or on any ancestor) beside any palette,
5
+ or none, or "own". No attribute = nothing here applies — the flat look, unchanged.
6
6
 
7
- The light comes from above:
7
+ FINISHES — four values of the one attribute (Lh 2026-10-12, from the timebank home with each injected:
8
+ four finishes, variations of one concept, never new pieces; teeth · tape · ticket · slab were rejected):
9
+ lit = lit from above: a lit top edge, a hairline ring and a layered shadow that grows with height; filled buttons stand up and go down under the finger; fields and pin cells are sunk.
10
+ print = printed paper: an ink line in the card's own ink, a hard misregistered offset instead of a blurred shadow, paper grain over the card, a dotted rule under the head as on a printed form; information inside stays flat.
11
+ sticker = a die-cut sticker: a white cut line round the card, a short hard drop, a slight tilt on a lone card, buttons with a bottom edge that press down.
12
+ ink = a poster / comic print: a thick line in the surface's ink round every boundary and control, a hard offset in the same ink.
13
+ KNOBS (set in a theme, as any knob; docs/knobs.md lists them):
14
+ print --print-off (the misregistration: how far the second pass missed) · --print-grain (the grain's opacity; a ::after, so the knob walk cannot probe it)
15
+ sticker --sticker-cut (the paper left round the print) · --sticker-tilt (a lone card's lean; 0deg for none — a card in a stack or a list never leans)
16
+ ink --ink-w (the line) · --ink-off (the offset)
17
+
18
+ Information inside a card stays flat under every finish: only a container or a control gets an edge.
19
+ Colours are mixes of the roles (--on-surface, --card-ink, --accent, currentColor), never plain black,
20
+ so a finish sits in any palette; the one plain white is the sticker's backing paper and the lit edge's light.
21
+ ============================================================ */
22
+
23
+ /* every finish: the finish IS the edge, so the wireframe's two borders step back (as [data-palette] in palette.css); ink puts its own line there below */
24
+ [data-finish] { --card-border: transparent; --action-border: transparent; }
25
+
26
+ /* ============================================================
27
+ lit — the light comes from above:
8
28
  - a thing that stands UP (card, sheet, button) has a lit top edge, a hairline ring and a shadow
9
29
  that grows with its height: card 1 · sheet / float 2 — the layers cardds already has;
10
- - a thing that is SUNK (a field, a pin cell) has its shadow inside, under its top edge;
11
- - information inside a card stays flat: only a container or a control gets an edge.
30
+ - a thing that is SUNK (a field, a pin cell) has its shadow inside, under its top edge.
12
31
  Shadows are mixed from the ink (--on-surface), never plain black, so they sit in the palette.
13
32
  ============================================================ */
14
-
15
33
  [data-finish="lit"] {
16
34
  --_shade: var(--on-surface);
17
35
  --finish-edge: inset 0 1px 0 rgb(255 255 255 / 0.55); /* the lit top edge */
@@ -37,9 +55,6 @@
37
55
  --finish-sheen: linear-gradient(to bottom, rgb(255 255 255 / 0.16), rgb(255 255 255 / 0) 60%);
38
56
  --finish-sunk: inset 0 1px 2px color-mix(in srgb, var(--_shade) 14%, transparent), 0 1px 0 rgb(255 255 255 / 0.5);
39
57
 
40
- /* the shadow is the edge: the wireframe's lines step back (as in every colour palette) */
41
- --card-border: transparent;
42
- --action-border: transparent;
43
58
  --outline: color-mix(in srgb, currentColor 14%, transparent);
44
59
  }
45
60
 
@@ -80,3 +95,74 @@
80
95
 
81
96
  /* ---- sunk: what the member types into ---- */
82
97
  [data-finish="lit"] :is(.field, .pin__cell) { box-shadow: var(--finish-sunk); }
98
+
99
+ /* ============================================================
100
+ print — printed paper (riso / zine): the second pass missed by --print-off, so the card's shadow is a
101
+ hard offset in its own ink; a line in that ink round the card; grain over the whole card; the head
102
+ underlined by a dotted rule as on a printed form. A lone card's head is a form's head; a stacked
103
+ sheet's, a row card's, a cover's (controls over a picture) and a folded feed card's (the head is the
104
+ whole card) have no rule.
105
+ ============================================================ */
106
+ [data-finish="print"] {
107
+ --_ink: color-mix(in srgb, currentColor 70%, transparent);
108
+ --outline: var(--_ink);
109
+ }
110
+ [data-finish="print"] .card {
111
+ border: var(--border-w) solid var(--_ink);
112
+ box-shadow: var(--print-off, 3px) var(--print-off, 3px) 0 0 color-mix(in srgb, var(--card-ink) 28%, transparent);
113
+ isolation: isolate; /* the grain's blend stays inside the card */
114
+ }
115
+ /* the grain: a ::after, not ::before — .card--cover's scrim is its ::before. Over everything in the card (z-index 1, the foot's own level), touches pass through it */
116
+ [data-finish="print"] .card::after {
117
+ content: ""; position: absolute; inset: 0; border-radius: inherit; pointer-events: none; z-index: 1;
118
+ opacity: var(--print-grain, 0.32); mix-blend-mode: multiply;
119
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0.3 0 0 0 0 0.25 0 0 0 0 0.2 0 0 0 0.9 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
120
+ }
121
+ /* a dark card (the brand tone, a cover): multiply would only blacken it, so the grain lightens, fainter */
122
+ [data-finish="print"] :is(.card--3, .card--cover)::after { mix-blend-mode: screen; opacity: calc(var(--print-grain, 0.32) * 0.56); }
123
+ [data-finish="print"] .card:not(.card--row, .card--cover, .feed-card--small, .sheet-stack > *, .sheet-stack > :not(.sheet-stack__base) > *) > .card__head {
124
+ padding-bottom: var(--sp-2);
125
+ border-bottom: var(--rule-w) dotted color-mix(in srgb, currentColor 45%, transparent);
126
+ }
127
+ [data-finish="print"] :is(.btn, .chip) {
128
+ border-color: var(--_ink);
129
+ box-shadow: 2px 2px 0 0 color-mix(in srgb, currentColor 35%, transparent);
130
+ }
131
+ [data-finish="print"] .btn--primary { box-shadow: 2px 2px 0 0 color-mix(in srgb, var(--card-ink) 60%, transparent); }
132
+ [data-finish="print"] :is(.btn, .chip):active { box-shadow: none; translate: 2px 2px; }
133
+ [data-finish="print"] .card__thumb { border-style: dashed; }
134
+
135
+ /* ============================================================
136
+ sticker — a die-cut sticker on a sheet: --sticker-cut of white paper left round the print, a short hard
137
+ drop under it, a lone card leans by --sticker-tilt (the next one the other way); chunky buttons with a
138
+ bottom edge that press down. A card in a stack, in a list, in a feed, a sheet or a modal never leans:
139
+ those are placed by their holder, a lean would cut or cross them.
140
+ ============================================================ */
141
+ [data-finish="sticker"] { --outline: color-mix(in srgb, currentColor 18%, transparent); }
142
+ [data-finish="sticker"] .card {
143
+ box-shadow:
144
+ 0 0 0 var(--sticker-cut, 5px) #fff,
145
+ 0 calc(var(--sticker-cut, 5px) + 4px) 0 0 color-mix(in srgb, var(--on-surface) 16%, transparent),
146
+ 0 calc(var(--sticker-cut, 5px) + 12px) 20px -8px color-mix(in srgb, var(--on-surface) 22%, transparent);
147
+ }
148
+ [data-finish="sticker"] .card:not(.sheet, .modal, .card--away, .sheet-stack *, .card-list > *, .card-feed > *) { rotate: var(--sticker-tilt, 0.8deg); }
149
+ [data-finish="sticker"] .card:not(.sheet, .modal, .card--away, .sheet-stack *, .card-list > *, .card-feed > *):nth-of-type(even) { rotate: calc(-1 * var(--sticker-tilt, 0.8deg)); }
150
+ [data-finish="sticker"] .card-list { gap: var(--sp-5); }
151
+ [data-finish="sticker"] .btn {
152
+ box-shadow: 0 3px 0 0 color-mix(in srgb, currentColor 30%, transparent);
153
+ border-color: color-mix(in srgb, currentColor 25%, transparent);
154
+ }
155
+ [data-finish="sticker"] .btn--primary { box-shadow: 0 3px 0 0 color-mix(in srgb, var(--btn-primary-bg, var(--accent)) 55%, var(--on-surface)); }
156
+ [data-finish="sticker"] .btn:active { box-shadow: none; translate: 0 3px; }
157
+ [data-finish="sticker"] .chip { box-shadow: 0 0 0 3px #fff; }
158
+
159
+ /* ============================================================
160
+ ink — a poster / comic print: --ink-w of the surface's ink round every boundary and control, and a hard
161
+ offset of --ink-off in the same ink. The two wireframe borders come back as that line.
162
+ ============================================================ */
163
+ [data-finish="ink"] { --card-border: var(--on-surface); --action-border: var(--on-surface); --outline: currentColor; }
164
+ [data-finish="ink"] .card { border: var(--ink-w, 2.5px) solid var(--on-surface); box-shadow: var(--ink-off, 5px) var(--ink-off, 5px) 0 0 var(--on-surface); }
165
+ [data-finish="ink"] :is(.btn, .chip, .icon-btn:not(.icon-btn-group > *)) { border: var(--ink-w, 2.5px) solid currentColor; box-shadow: 3px 3px 0 0 currentColor; }
166
+ [data-finish="ink"] .btn--primary { border-color: var(--on-surface); box-shadow: 3px 3px 0 0 var(--on-surface); }
167
+ [data-finish="ink"] .toggle { box-shadow: 2px 2px 0 0 var(--on-surface); }
168
+ [data-finish="ink"] .card__thumb { border-width: var(--ink-w, 2.5px); }
package/css/forms.css CHANGED
@@ -78,5 +78,4 @@
78
78
  .form-note { display: flex; align-items: center; justify-content: center; gap: var(--sp-2); margin: 0; font: var(--form-note-font, var(--type-caption)); color: var(--form-note-ink, var(--card-muted)); text-align: center; }
79
79
  .form-note > .icon { flex: none; }
80
80
  .form-note--error { color: var(--form-note-error-ink, var(--signal-error)); }
81
- @media (prefers-reduced-motion: no-preference) { .form-note--busy > .icon { animation: form-note-turn 0.9s linear infinite; } }
82
- @keyframes form-note-turn { to { rotate: 1turn; } }
81
+ @media (prefers-reduced-motion: no-preference) { .form-note--busy > .icon { animation: turn 0.9s linear infinite; } } /* `turn`: tokens.css */
package/css/numbers.css CHANGED
@@ -257,8 +257,7 @@
257
257
  The ring turns; words beside it are optional and take the caption's look. Still when motion is reduced. */
258
258
  .spinner { display: inline-flex; align-items: center; justify-content: center; gap: var(--sp-2); vertical-align: middle; font: var(--type-caption); color: var(--spinner-ink, currentColor); }
259
259
  .spinner > .icon { flex: none; }
260
- @media (prefers-reduced-motion: no-preference) { .spinner > .icon { animation: spinner-turn 0.9s linear infinite; } }
261
- @keyframes spinner-turn { to { rotate: 1turn; } }
260
+ @media (prefers-reduced-motion: no-preference) { .spinner > .icon { animation: turn 0.9s linear infinite; } } /* `turn`: tokens.css */
262
261
 
263
262
  /* ---- progress: one straight line, the done share solid, the rest faint (Lh 2026-09-22) — a voice note's
264
263
  timeline, a download, a step count as a line. <div class="progress" role="progressbar" style="--progress-at: 0.4"></div>
package/css/post.css CHANGED
@@ -199,10 +199,9 @@
199
199
  width: calc(var(--_p) * 2);
200
200
  aspect-ratio: 3 / 2;
201
201
  translate: var(--_x) var(--_y);
202
- transition: translate var(--motion-sheet) ease, rotate var(--motion-sheet) ease;
203
202
  }
204
203
  .post-desk--address .post-desk__card { --_x: calc(100cqi - var(--_p) * 2); }
205
- .post-desk--sent .post-desk__card { --_y: -160%; rotate: -5deg; }
204
+ /* sent: the card leaves by card.css LEAVE, as a `Card` with `leave` does */
206
205
  .post-desk__half { min-width: 0; min-height: 0; padding: var(--post-desk-pad, var(--sp-5)); display: flex; flex-direction: column; }
207
206
  .post-desk__half--address { border-inline-start: var(--rule-w) solid var(--_rule); gap: var(--sp-4); }
208
207
  .post-desk__write {
@@ -234,10 +233,9 @@
234
233
  .post-desk__slot { border: 0; background: transparent; color: inherit; }
235
234
  @media (prefers-reduced-motion: no-preference) {
236
235
  .post-desk__slot > .stamp { animation: post-desk-stick var(--motion-quick) ease-out both; }
237
- .post-desk .postmark { animation: post-desk-strike var(--motion-press) ease-in both; }
236
+ .post-desk .postmark { animation: strike var(--stamp-on-arrive-time, var(--motion-sheet)) ease-in both; } /* struck as a StampOn is (`strike`: tokens.css), in the same time */
238
237
  }
239
238
  @keyframes post-desk-stick { from { translate: 0 calc(-1 * var(--sp-8)); rotate: 14deg; opacity: 0; } }
240
- @keyframes post-desk-strike { from { translate: calc(-1 * var(--sp-3)) calc(-1 * var(--sp-6)); rotate: -24deg; opacity: 0; } }
241
239
  /* the other half's showing edge brings it across: a tap-wide strip out to the screen's edge */
242
240
  .post-desk__reach { position: absolute; inset-block: 0; width: var(--tap); border: 0; background: transparent; }
243
241
  .post-desk__reach--address { inset-inline-end: calc(-1 * var(--screen-pad)); }
@@ -272,7 +270,6 @@
272
270
  .post-desk__note { margin: 0; font: var(--type-caption); color: var(--post-desk-note-ink, var(--on-surface-muted)); min-height: 1.35em; }
273
271
  /* what stands where the card was, once it has left */
274
272
  .post-desk__gone { position: absolute; inset: 0; display: grid; grid-template-columns: minmax(0, 1fr); align-content: center; gap: var(--sp-2); }
275
- @media (prefers-reduced-motion: reduce) { .post-desk__card { transition: none; } }
276
273
 
277
274
  /* ============================================================
278
275
  post-box — KEEPING many. The cards whole and landscape, standing one behind another; each shows its top edge: who, where,
package/css/stack.css CHANGED
@@ -321,7 +321,7 @@
321
321
  }
322
322
  /* a card standing in this room is FLAT (Lh 2026-10-09): the room clips at its own edges, and a card's shadow cut there drew a straight
323
323
  line under the bar and along the first sheet. Its ground and its radius are what say "card" here. */
324
- :is([data-palette], [data-finish="lit"]) .sheet-stack__base .card { box-shadow: none; }
324
+ :is([data-palette], [data-finish]) .sheet-stack__base .card { box-shadow: none; }
325
325
 
326
326
  /* ============================================================
327
327
  .sheet-stack--flood — a closed stack whose open sheet FLOODS the screen: it rises over the top bar and fills the whole screen
package/css/stamp.css CHANGED
@@ -259,9 +259,8 @@
259
259
  @media (prefers-reduced-motion: no-preference) { .stamp-on__remove { animation: stamp-on-remove-in var(--motion-press) ease-out; } }
260
260
  @keyframes stamp-on-remove-in { from { opacity: 0; scale: 0.6; } }
261
261
 
262
- /* --arrive: the stamp sticks ITSELF (StampOn `arrive`) — struck onto its place: shown large and faint, pressed a little past its
263
- size, then at rest. The component reads the animation's name to tell the app it has landed. Knob: --stamp-on-arrive-time */
262
+ /* --arrive: the stamp sticks ITSELF (StampOn `arrive`) — struck onto its place (`strike`: tokens.css). The component reads the
263
+ animation's name to tell the app it has landed. Knob: --stamp-on-arrive-time */
264
264
  @media (prefers-reduced-motion: no-preference) {
265
- .stamp-on--arrive { animation: stamp-on-strike var(--stamp-on-arrive-time, var(--motion-sheet)) ease-in both; }
265
+ .stamp-on--arrive { animation: strike var(--stamp-on-arrive-time, var(--motion-sheet)) ease-in both; }
266
266
  }
267
- @keyframes stamp-on-strike { from { scale: 2; opacity: 0; } 60% { scale: 0.92; opacity: 1; } }
package/css/step.css CHANGED
@@ -110,6 +110,12 @@
110
110
  padding-bottom: var(--bottom-h, 0rem); /* exactly the bottom layer (measured by Screen): a page scrolls ONLY when the layer would cover its words, and then just far enough to clear them — at 100% nothing scrolls (Lh 2026-09-23) */
111
111
  }
112
112
 
113
+ /* a SCROLLING (not fill) screen becomes a column at least one phone tall when it holds a bottom or a centre:
114
+ an .action-bar as its last child (Lh 2026-09-24, 0.7.0: the bar is the column's last row — at the foot when the words are short,
115
+ sticky to the viewport's foot while they scroll; actions.css) or a .screen__centre (Lh 2026-09-12: the group is in flow, takes the
116
+ room after the top bar and centres its children in it; a long one makes the page longer and runs under the bar; card.css) */
117
+ .screen:not(.screen--fill):has(> .action-bar:last-child, > .screen__centre) { display: flex; flex-direction: column; min-height: 100dvh; }
118
+
113
119
  /* ============================================================
114
120
  TOP + CONTENT in one flex column; BOTTOM is a LAYER over them (Lh 2026-09-23, second call — replaces "the bar is a row"):
115
121
  in a .screen--fill the content runs to the screen's bottom edge, and the bar lies over it at the foot — it covers what is
@@ -179,7 +185,12 @@
179
185
  object-fit: cover;
180
186
  }
181
187
  /* an EMPTY STATE is a component in the content box too (Lh 2026-09-23 — it no longer floats in a Centre over the screen): alone in a
182
- Step it stands at the human centre of the content, 40% down (two parts of room above it, three below) */
183
- .step:has(> .empty-state)::before { content: ""; flex: 2 1 0; }
184
- .step:has(> .empty-state)::after { content: ""; flex: 3 1 0; }
188
+ Step it stands at the human centre of the content, 40% down (two parts of room above it, three below). --empty-state-at is the share of the
189
+ free room that stands above it (a theme's 0.2 puts it a fifth down); a sheet's body holds an only EmptyState by the same share, but defaults to 0: at the top, as it always stood */
190
+ .step:has(> .empty-state)::before { content: ""; flex: calc(var(--empty-state-at, 0.4) * 5) 1 0; }
191
+ .step:has(> .empty-state)::after { content: ""; flex: calc(5 - var(--empty-state-at, 0.4) * 5) 1 0; }
185
192
  .step > .empty-state { flex: none; }
193
+ .sheet__body:has(> .empty-state:only-child) { display: flex; flex-direction: column; gap: 0; }
194
+ .sheet__body:has(> .empty-state:only-child)::before { content: ""; flex: calc(var(--empty-state-at, 0) * 5) 1 0; }
195
+ .sheet__body:has(> .empty-state:only-child)::after { content: ""; flex: calc(5 - var(--empty-state-at, 0) * 5) 1 0; }
196
+ .sheet__body > .empty-state:only-child { flex: none; }
package/css/sticky.css CHANGED
@@ -182,7 +182,7 @@
182
182
  box-shadow: inset 0 0 0 var(--border-w) var(--ink-tint); /* a pale tone still has an edge on a pale ground */
183
183
  cursor: pointer;
184
184
  }
185
- .sticky-pick__swatch > input { appearance: none; position: absolute; inset: 0; margin: 0; opacity: 0; cursor: inherit; }
185
+ /* its hidden input: the picks' one rule, choice.css */
186
186
  .sticky-pick__swatch:has(> :checked) { outline: var(--rule-w) solid currentColor; outline-offset: var(--sp-0); }
187
187
  .sticky-pick__swatch:has(> :focus-visible) { outline: var(--focus); outline-offset: var(--sp-1); }
188
188
 
package/css/tokens.css CHANGED
@@ -265,3 +265,9 @@
265
265
  @property --_lead { syntax: "<length>"; inherits: true; initial-value: 0px; } /* the room above a step's words: registered so a cqh share (a no-bar step, 15% of the SCREEN) resolves where it is declared — on the step, against the screen — not on the words, against the step */
266
266
  @property --sheet-half { syntax: "<number>"; inherits: true; initial-value: 0.5; }
267
267
  @property --sheet-3q { syntax: "<number>"; inherits: true; initial-value: 0.25; }
268
+
269
+ /* the ring that turns while waiting: Spinner, FormNote busy */
270
+ @keyframes turn { to { rotate: 1turn; } }
271
+ /* a mark struck onto paper — shown large and faint, pressed a little past its size, then at rest: StampOn `arrive`, the post desk's
272
+ postmark (Lh 2026-10-12: one strike for both; the postmark used to slide in from up-left, turning). Both read --stamp-on-arrive-time */
273
+ @keyframes strike { from { scale: 2; opacity: 0; } 60% { scale: 0.92; opacity: 1; } }