@usableapp/cardds 0.7.0 → 0.7.2

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,43 @@
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.2 — 2026-09-24
6
+
7
+ ### `Banner` is one light band (breaking)
8
+ - Before: `summary` was a `Chip on` (with its chevron Icon) that opened a `card--3` body under it; `tone` 2 | 3.
9
+ After: ONE band — a light tint (`--ink-tint`), a hairline above and below, edge to edge of its card or screen; the
10
+ summary line and the body share it. `summary` = words (the chevron is the Banner's own); `tone` is gone. Fix:
11
+ `<Banner summary={<Chip on>Save 2 h… <Icon …/></Chip>}>` → `<Banner summary="Save 2 h…">`. Knobs `--banner-bg`,
12
+ `--banner-border`, `--banner-summary-pad-y`, `--banner-summary-font`. A read-only note inside a sheet or card is a
13
+ `Banner`, not a `Card sm`.
14
+
15
+ ### New
16
+ - `Splash` (template): the app's own mark (its Lottie player, an img — cardds plays nothing) + one `text` line, the group's
17
+ middle on `--screen-centre`; `done` (the app is ready) · `min` (ms at least) · `onLeft` (after the fade; unmount then);
18
+ reduced motion = no fade. Knobs `--splash-mark-w`, `--splash-mark-x` / `-y` (nudge a lottie's drawing onto the line),
19
+ `--splash-at`, `--splash-bg`, `--splash-text-ink`, `--splash-gap`.
20
+ - `Rows heading` — a list grouped (Today · This week), not spaced out. Knobs `--rows-head-ink`, `--rows-group-gap`.
21
+ - `Chip muted`, `Dropdown muted` — an untouched default reads quiet; drop `muted` once the member picks.
22
+ - README rules 11–14: a screen is scanned, not read — edges, show don't explain, quiet defaults, a frame says "tap me"
23
+ (inside a card only a control has an edge; a count is `Text caption muted`, never a `Chip`).
24
+
25
+ ### Fixed
26
+ - `Modal` with no `Drawer` stretched to the whole stage in Safari 18 (iOS 18.6): it is centred by `top: 50%` +
27
+ `translate: -50%` now.
28
+ - `Tile stat` has no frame (it is read, not pressed).
29
+ - `StackSheet` head: tabs 8px apart (`--stack-sheet-tabs-gap`), the title on the discs' middle
30
+ (`--stack-sheet-title-shift`), ONE line always — a title that does not fit runs sideways, one way, and starts again
31
+ (`--stack-sheet-run-time`); reduced motion = an ellipsis.
32
+
33
+ ## 0.7.1 — 2026-09-24
34
+
35
+ ### The bar fixed from outside is gone
36
+ - Before: an `ActionBar` written after `</Screen>` (in `<body>` or an app shell) was fixed to the viewport, and the screen
37
+ / step kept `--bar-reserve` of room for it. After: `.action-bar` is in flow; a bar is always the Screen's `bottom` (its
38
+ last child) — a layer over the content's foot in a fill screen, a sticky last row on a scrolling one (0.7.0). A bar left
39
+ outside a Screen now sits wherever it was written. `--bar-reserve` is still published (the centred card's cap reads it);
40
+ nothing reserves room with it any more. `ActionBar inline` stays, for a bare bar in a preview.
41
+
5
42
  ## 0.7.0 — 2026-09-24
6
43
 
7
44
  Breaking — the clean-up promised since 0.6.5. Four renames/removals, one new way.
package/README.md CHANGED
@@ -410,17 +410,9 @@ Demo: the *skeleton* block at the top of `elements.html`.
410
410
  5. **`.screen` carries the horizontal inset and nothing vertical.** The inset
411
411
  is `--screen-pad`; cards and on-surface UI sit flush inside it, with no
412
412
  per-component side padding. Top and bottom are zero: vertical room only
413
- exists as a reserve for something fixed parked over the screen, so each bar
414
- adds its own — `.appbar` the top (`.screen:has(> .appbar)`), `.action-bar`
415
- and `.float-bar` the bottom, each from its own height token. Whatever
416
- parent hosts the bar counts (`:has(> .action-bar) .screen`): `<body>` or an
417
- app shell alike. A page with no bars needs no override, and `.screen--fill`
418
- is excluded from the bottom reserves — there a `.step` carries the bar's
419
- room itself, as a bottom margin, reading the same `--bar-reserve`. The
420
- reserves use the nominal control height, so a bar whose button wraps to two
421
- lines can still overlap the last line of content.
422
- **In a `.screen--fill` none of this applies to a bar in the screen** (2026-09-23): it is the column's last
423
- part and the browser measures it — the reserves are the old way for a bar outside a fill screen.
413
+ is taken by what is in the column. The bottom bar is always the screen's last child (0.7.1 — the bar fixed to
414
+ the viewport from outside and its reserves are gone): in a `.screen--fill` a layer over the content's foot, on a
415
+ scrolling screen a `sticky` last row. `--bar-reserve` stays published only for a `.card--centre`'s cap.
424
416
  **Padding is published, not copied.** `.screen` sets `--screen-pad` and
425
417
  `--screen-gap`, its appbar reserve sets `--header-gap`, and `.card` sets
426
418
  `--card-pad` and `--card-gap`; anything that has to cancel a container's
@@ -505,6 +497,34 @@ Demo: the *skeleton* block at the top of `elements.html`.
505
497
  `--on-accent` to its own ink and bg, so every accent consumer (buttons,
506
498
  checks, toggles, rings, selected days) inverts without extra rules.
507
499
 
500
+ **A screen is scanned, not read** (Lh 2026-09-24) — a member arrives with one
501
+ question and hunts for its answer. Rules 11–13 serve that:
502
+
503
+ 11. **Everything holds to edges.** Every element touches at least two edges —
504
+ the card's, the column's, or a line made by the thing above it (a row, the
505
+ avatar + name line). Words to READ are aligned start, values end (the
506
+ receipt: items left, prices right; `.card__head`, `.kv`, `.row`); centred
507
+ only what is not read — dots, a lone number, an icon, a mark. Empty room is
508
+ not filled with more content; a new edge is made instead (line the buttons
509
+ up with the avatar).
510
+ 12. **Show, don't explain.** Where a known picture carries it — an avatar for a
511
+ person, an icon, a `Ring`/`Track`/`Band`, a `Chip` for a category — use it
512
+ instead of words. Never add a label, a caption line, a comparison or a
513
+ tooltip to explain what the picture already says (the retired overline was
514
+ that mistake). Long lists are differentiated, not spaced out: group them
515
+ (`Rows heading` per group), put the avatar where a person is named.
516
+ 13. **Emphasis is the difference from the neighbours.** An untouched default
517
+ is quiet (grey, thin border, no fill — `Chip muted`, `Dropdown muted`);
518
+ only what the member changed, or the one thing asked of them, takes the
519
+ accent. To make one thing stand out, calm what surrounds it — never
520
+ bolder, never a second colour.
521
+ 14. **A frame says "tap me".** Inside a card, only a control draws its own
522
+ edge (a chip, a field, a pick tile, a small `Card sm` to pick). What is
523
+ only READ never gets a frame — no card in a card: a stat tile stands on
524
+ the card's ground, a note is a `Banner` (a light band, a rule above and
525
+ below, edge to edge), a count is words (`Text caption muted`), never a
526
+ chip. A chip or tile that looks pressable and is not lies.
527
+
508
528
  ## A project's theme
509
529
 
510
530
  A project retunes cardds from ONE file of its own, and nothing else. Copy
package/css/actions.css CHANGED
@@ -158,6 +158,8 @@
158
158
  letters' own middle, in em so it follows the text size (Lh 2026-09-22: "ตัวหนังสือกับไฟเสมอกัน") */
159
159
  .dot--b { background: var(--dot-b); }
160
160
  .chip--on { background: var(--chip-on-bg, var(--accent)); color: var(--chip-on-ink, var(--on-accent)); border-color: transparent; }
161
+ /* muted: an untouched default — the words wash out, the edge stays; emphasis is the difference from the neighbours (README 13) */
162
+ .chip--muted { color: var(--chip-muted-ink, var(--card-muted)); }
161
163
  /* dark-slot inversion lives in card.css (.card--3 :is(...)) */
162
164
 
163
165
  .chip-row { display: flex; gap: var(--sp-2); flex-wrap: wrap; }
@@ -187,6 +189,8 @@
187
189
  text-align: left;
188
190
  cursor: pointer;
189
191
  }
192
+ /* muted: the value is still the default (README 13) */
193
+ .dropdown--muted .dropdown__btn { color: var(--dropdown-muted-ink, var(--card-muted)); }
190
194
  .dropdown__label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
191
195
  .dropdown__btn > .icon { transition: rotate 0.2s ease; }
192
196
  .dropdown:has(.menu:popover-open) .dropdown__btn > .icon { rotate: 180deg; }
@@ -216,21 +220,15 @@
216
220
  opacity: 1;
217
221
  }
218
222
 
219
- /* ---- bottom CTA pack: fixed action zone under the content ----
220
- --bar-reserve is the room the bar needs — its own height plus a breathing
221
- gap above it — published here so anything else that must stop clear of it
222
- reads this instead of recomputing it. .screen's own reserve below reads
223
- it; so does .step (step.css), the box a sheet slides in — one number, so
224
- the two can't drift apart.
225
- --kb is the on-screen keyboard's height, published on :root by cardds.js
226
- from the visual viewport (0 without one, and on a platform that shrinks
227
- the layout viewport itself): the bar rides above the keyboard on iOS the
228
- way it already does on Android, and .step grows its room by the same. */
223
+ /* ---- bottom CTA pack: the screen's bottom (Screen `bottom`) ----
224
+ A bar is always INSIDE its .screen, its last child (0.7.1: the bar fixed to the viewport from outside, and the
225
+ reserves the screen and the step kept for it, are gone). In a .screen--fill it is a layer over the content's foot
226
+ (step.css); on a scrolling screen a sticky last row (below). --bar-reserve stays published — the bar's nominal
227
+ height — for the one rule that still needs it without measuring: a .card--centre's cap (card.css).
228
+ --kb is the on-screen keyboard's height (cardds.js): the bar rides above it. */
229
229
  :root { --bar-reserve: calc(var(--btn-h) + var(--sp-3) + var(--sp-5) + var(--sp-6)); }
230
230
  .action-bar {
231
- position: fixed;
232
- inset-inline: 0;
233
- bottom: var(--kb, 0);
231
+ position: relative;
234
232
  z-index: 30; /* above .sheet-stack sheets, which paint at --level 1-5 */
235
233
  max-width: var(--screen-w);
236
234
  margin-inline: auto;
@@ -241,9 +239,9 @@
241
239
  /* 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
242
240
  last row — at the foot when the words are short, and sticky to the viewport's foot (above the keyboard) while they scroll;
243
241
  it takes the room it needs in the column, so nothing is added to the content for it */
244
- .screen:not(.screen--fill):has(> .action-bar) { display: flex; flex-direction: column; min-height: 100dvh; }
245
- .screen:not(.screen--fill) > .action-bar {
246
- position: sticky; inset-inline: auto; bottom: var(--kb, 0rem);
242
+ .screen:not(.screen--fill):has(> .action-bar:last-child) { display: flex; flex-direction: column; min-height: 100dvh; }
243
+ .screen:not(.screen--fill) > .action-bar:last-child {
244
+ position: sticky; bottom: var(--kb, 0rem);
247
245
  max-width: none;
248
246
  margin: auto calc(-1 * var(--screen-pad)) 0;
249
247
  }
@@ -313,13 +311,6 @@
313
311
  :is(.action-bar--pager, .action-bar__tier--pager) > :not(.icon-btn):not(:first-child):not(:last-child) { margin-inline: auto; }
314
312
  :is(.action-bar--pager, .action-bar__tier--pager):has(> :nth-child(3)) > .icon-btn:first-child { margin-right: 0; }
315
313
 
316
- /* .screen's default bottom padding is a constant; derive the reserve from the
317
- bar instead, so a taller button (a longer label, a script with more leading)
318
- can never end up underneath it. Any parent that hosts the bar counts —
319
- <body>, or an app shell (#app > .screen + .action-bar) — since the bar is
320
- fixed to the viewport wherever it sits. (.screen--fill reserves nothing:
321
- its .step carries the bar's room itself, step.css.) */
322
- :has(> .action-bar) .screen:not(.screen--fill) { padding-bottom: var(--bar-reserve); }
323
314
 
324
315
  /* ---- action note (Lh 2026-09-24): the main button's place, the main button's size, but ON the ground — no fill, no border, no
325
316
  rounding — one line of muted words, an inline icon allowed; nothing to press. In a CardFoot or an ActionBar row it takes the
package/css/layover.css CHANGED
@@ -31,12 +31,11 @@
31
31
  Reduced motion: the finished state, no beats; closing fires at once. */
32
32
  .modal {
33
33
  --drawer-h: 14rem;
34
- position: absolute; /* at the stage's centre, and kept there as it grows: absolute + margin auto re-centres on every height */
35
- inset: 0;
36
- margin: auto;
37
- height: auto;
38
- align-self: center; /* inset 0 alone would stretch it to the stage: centre self-alignment gives it its own height, the margins
39
- share the rest. NOT `height: fit-content` — WebKit/Safari sizes it wrong (Lh 2026-09-24, iOS 18.6) */
34
+ position: absolute; /* at the stage's centre, and kept there as it grows: its middle is pinned at 50% and */
35
+ top: 50%; /* translate -50% of its OWN height re-centres it on every height. NOT inset 0 + margin auto + */
36
+ inset-inline: 0; /* align-self: center — Safari 18 ignores align-self on an absolute box in a block container, */
37
+ margin-inline: auto; /* so the card stretched to the whole stage (iOS 18.6, Lh 2026-09-24); nor height: fit-content */
38
+ translate: 0 -50%;
40
39
  width: calc(100% - 2 * var(--screen-pad));
41
40
  z-index: 2; /* over the dim stage's scrim (z 1) */
42
41
  max-height: calc(100cqh - 2 * var(--sp-5)); /* cqh: the stage (a size container) */
@@ -80,8 +79,8 @@
80
79
  .modal { animation: modal-drop calc(2 * var(--motion-sheet)) ease both; }
81
80
  .modal__drawer { animation: modal-open calc(2 * var(--motion-sheet)) ease both; }
82
81
  @keyframes modal-drop {
83
- 0% { translate: 0 -100cqh; } /* above the stage */
84
- 50%, 100% { translate: 0 0; } /* 1. at the centre — and it stays: beat 2 is the drawer's */
82
+ 0% { translate: 0 calc(-50% - 100cqh); } /* above the stage */
83
+ 50%, 100% { translate: 0 -50%; } /* 1. at the centre — and it stays: beat 2 is the drawer's */
85
84
  }
86
85
  @keyframes modal-open {
87
86
  0%, 50% { height: 0; padding-block: 0; } /* inside the card */
@@ -166,16 +165,29 @@
166
165
  @starting-style { .menu:popover-open { opacity: 0; translate: 0 calc(-1 * var(--sp-2)); } }
167
166
  }
168
167
 
169
- /* ---- banner: a chip that opens into a tinted card, no JS ----
170
- <details class="banner"><summary class="chip chip--on">Save $30 on orders over $70 <svg class="icon icon--xs"/></summary>
171
- <div class="card card--3 banner__body">…</div></details> */
172
- .banner { display: grid; gap: var(--banner-gap, var(--sp-2)); justify-items: start; }
173
- .banner > summary { list-style: none; cursor: pointer; }
174
- .banner > summary::-webkit-details-marker { display: none; }
175
- .banner > summary .icon { transition: rotate 0.2s ease; }
176
- .banner[open] > summary .icon { rotate: 180deg; }
177
- .banner__body { width: 100%; padding: var(--banner-body-pad-y, var(--sp-4)) var(--banner-body-pad-x, var(--sp-5)); --card-gap: var(--sp-1); }
178
- .banner::details-content { width: 100%; } /* Chromium wraps the content in this pseudo; justify-items: start shrink-wrapped it, so the body never reached the width */
168
+ /* ---- banner: ONE band — the line that opens it and the body share one ground (Lh 2026-09-24: "ปุ่มกับเนื้อหาใช้พื้นที่เดียวกัน") ----
169
+ <details class="banner"><summary class="banner__summary">Save 2 h… <svg class="icon icon--xs"/></summary>
170
+ <div class="banner__body">…</div></details>
171
+ A light tint (not a card tone) with a slightly darker rule above and below, edge to edge of its card or screen (Lh 2026-09-24). */
172
+ .banner {
173
+ display: grid;
174
+ background: var(--banner-bg, var(--ink-tint));
175
+ color: var(--banner-ink, inherit);
176
+ border-block: var(--border-w) solid var(--banner-border, var(--hairline));
177
+ /* a band edge to edge of what holds it — the card's or the screen's inset; no radius, no side border: a band, not a card */
178
+ margin-inline: calc(-1 * var(--card-pad, var(--screen-pad)));
179
+ padding-inline: var(--card-pad, var(--screen-pad));
180
+ }
181
+ .banner__summary {
182
+ list-style: none; cursor: pointer;
183
+ display: flex; align-items: center; justify-content: space-between; gap: var(--sp-2);
184
+ padding-block: var(--banner-summary-pad-y, calc(var(--sp-base) * 1.5)); font: var(--banner-summary-font, var(--type-caption)); font-weight: 400;
185
+ }
186
+ .banner__summary::-webkit-details-marker { display: none; }
187
+ .banner__summary .icon { transition: rotate 0.2s ease; }
188
+ .banner[open] .banner__summary .icon { rotate: 180deg; }
189
+ .banner__body { display: grid; gap: var(--banner-body-gap, var(--sp-1)); padding-bottom: var(--sp-4); }
190
+ .banner::details-content { width: 100%; }
179
191
  @supports (interpolate-size: allow-keywords) {
180
192
  @media (prefers-reduced-motion: no-preference) {
181
193
  .banner { interpolate-size: allow-keywords; }
@@ -289,3 +301,21 @@
289
301
  .deck > .card:nth-last-child(n + 4) { display: none; }
290
302
  .deck__actions { display: flex; justify-content: center; gap: var(--deck-actions-gap, var(--sp-8)); }
291
303
  .deck__actions > .icon-btn { width: var(--bar-h); height: var(--bar-h); }
304
+
305
+ /* ---- splash: the app's mark at the human centre while it boots (Lh 2026-09-24) ----
306
+ <section class="screen screen--fill splash"><div class="splash__group"><div class="splash__mark">{the app's lottie/img}</div>
307
+ <p class="splash__text">…</p></div></section>
308
+ The group's MIDDLE is on --screen-centre (40% down), the same line a centred card uses. The mark is the app's own content —
309
+ a lottie box often carries empty room around its drawing — the app sets the knobs below, cardds does not guess it. */
310
+ .splash { position: relative; background: var(--splash-bg, var(--surface)); transition: opacity var(--motion-sheet) ease; }
311
+ .splash--leaving { opacity: 0; }
312
+ .splash__group {
313
+ position: absolute; inset-inline: var(--screen-pad); top: var(--splash-at, var(--screen-centre)); translate: 0 -50%;
314
+ display: grid; justify-items: center; gap: var(--splash-gap, var(--sp-4)); text-align: center;
315
+ }
316
+ .splash__mark { width: var(--splash-mark-w, 60%); max-width: 100%; translate: var(--splash-mark-x, 0) var(--splash-mark-y, 0); }
317
+ /* each app's lottie is its own size and carries its own empty room: --splash-mark-w sizes the box, --splash-mark-x / -y nudge the
318
+ drawing onto the centre line (the line under it does not move), --splash-at moves the whole group (default the 40% line) */
319
+ .splash__mark > * { display: block; width: 100%; height: auto; }
320
+ .splash__text { margin: 0; font: var(--type-body); color: var(--splash-text-ink, var(--on-surface-muted)); }
321
+ @media (prefers-reduced-motion: reduce) { .splash { transition: none; } }
package/css/lists.css CHANGED
@@ -67,6 +67,9 @@
67
67
  /* rows: a list of rows separated by hairlines */
68
68
  .rows { display: grid; --rows-gap: var(--sp-4); row-gap: var(--rows-gap); }
69
69
  .rows:has(> .row--lg) { --rows-gap: var(--sp-6); }
70
+ /* the group's name: a label on the rows' start edge; two groups one after another read apart by it, not by space */
71
+ .rows__head { font: var(--type-label); color: var(--rows-head-ink, var(--card-muted)); }
72
+ .rows + .rows { margin-top: var(--rows-group-gap, var(--sp-6)); }
70
73
  /* the hairline between rows sits in the middle of the gap — drawn, not spaced:
71
74
  no padding, no spacer element */
72
75
  .rows > .row + .row { position: relative; }
package/css/media.css CHANGED
@@ -90,6 +90,8 @@
90
90
  .tile--pick:has(:checked) { background: var(--accent); color: var(--on-accent); border-color: transparent; }
91
91
  .tile--pick:has(:focus-visible) { outline: var(--focus); outline-offset: var(--sp-1); }
92
92
  .tile--wide { grid-column: 1 / -1; }
93
+ /* stat: READ, not pressed — no frame of its own inside a card (a frame says "tap me"): icon · value · words on the card's ground */
94
+ .tile--stat { border-color: transparent; background: none; padding-inline: 0; cursor: default; }
93
95
  .tile--stat { min-height: var(--tile-stat-min-h, var(--avatar-xl)); gap: var(--tile-stat-gap, var(--sp-3)); grid-template-columns: auto 1fr; align-items: center; }
94
96
  /* upload: dashed, centred plus + label */
95
97
  .tile--upload {
package/css/stack.css CHANGED
@@ -271,7 +271,25 @@
271
271
 
272
272
  /* ---- StackSheet's tabs (Lh 2026-09-23): the head's end is a row of small icon buttons, the sheet's pages — the chosen one
273
273
  filled (.icon-btn--invert); a count is a small circle over the icon's top right corner ---- */
274
- .stack-sheet__tabs { display: flex; gap: var(--stack-sheet-tabs-gap, 0rem); flex: none; } /* no gap: each tab is a 40px target already, and an open head (back · title · tabs) must fit one line — Lh 2026-09-23 */
274
+ .stack-sheet__tabs { display: flex; gap: var(--stack-sheet-tabs-gap, var(--sp-2)); flex: none; } /* room between the tabs: with none, the chosen tab's disc touched its neighbour's (Lh 2026-09-24) */
275
+ /* the head is ONE line, always (Lh 2026-09-24): the tabs keep their size, the TITLE gives — it never wraps, it runs sideways
276
+ (StackSheet measures --_over, the width it overflows by, and sets .is-long), one way: it waits, runs to the end, waits, and starts again from the first letter — like a song title */
277
+ .stack-sheet > .card__head { flex-wrap: nowrap; }
278
+ .stack-sheet__title {
279
+ flex: 1; min-width: 0; overflow-x: clip; overflow-y: visible; white-space: nowrap; overflow-wrap: normal; /* clip SIDEWAYS only:
280
+ hidden clipped the Thai tone marks above a line-height-1 line (เพื่อน lost its ไม้เอก — Lh 2026-09-24) */
281
+ translate: 0 var(--stack-sheet-title-shift, -0.1em); /* FC Pride's line box carries more room above its letters than below: the
282
+ letters sat ~0.1em under the middle of the back disc and the tabs — lifted onto it (Lh 2026-09-24) */
283
+ }
284
+ .stack-sheet__run { display: inline-block; }
285
+ .stack-sheet__title.is-long .stack-sheet__run {
286
+ animation: stack-sheet-run var(--stack-sheet-run-time, var(--_run, 6s)) ease-in-out infinite; /* ONE way: start → end, then back to the start at once (Lh 2026-09-24) */ /* --_run: from the overflow, 30px a second + the pauses */
287
+ }
288
+ @keyframes stack-sheet-run { 0%, 20% { translate: 0; } 85%, 100% { translate: calc(-1 * var(--_over)); } }
289
+ @media (prefers-reduced-motion: reduce) {
290
+ .stack-sheet__title.is-long .stack-sheet__run { animation: none; display: inline; } /* inline: an ellipsis cannot cut an inline-block */
291
+ .stack-sheet__title.is-long { text-overflow: ellipsis; }
292
+ }
275
293
  .stack-sheet__tab { position: relative; }
276
294
  .stack-sheet__tab > .stack-sheet__count { /* outranks .badge (numbers.css loads after this file) */
277
295
  position: absolute;
package/css/step.css CHANGED
@@ -10,7 +10,7 @@
10
10
  <section class="page">…the words…</section> ← first
11
11
  <article class="card sheet sheet--half">…the controls… <div class="card__foot">…</div></article> ← last, optional
12
12
  </section>
13
- <div class="action-bar">…</div> ← only when there is no sheet
13
+ <div class="action-bar">…</div> ← the screen's last child, only when there is no sheet
14
14
 
15
15
  The step is the box the sheet slides in. It fills the screen
16
16
  column between the top bar's reserve (.screen already pads for
@@ -59,11 +59,6 @@
59
59
  more air and a short one less; a screen with a bar keeps the bar's own room */
60
60
  .screen > .step:first-child, .screen--lead > .step { --_lead: var(--screen-lead, 15cqh); } /* top="lead" says it; a Step first says it too */
61
61
 
62
- /* the bar's room — read from actions.css, never recomputed. Margin, not
63
- padding, so the clip cuts at the box edge and nothing shows behind the
64
- bar. Any parent that hosts the bar counts — <body>, an app shell, a demo
65
- frame — the bar is fixed to the viewport wherever it sits. */
66
- :has(> .action-bar) .step { margin-bottom: calc(var(--bar-reserve) + var(--kb, 0rem)); }
67
62
  /* a walk (the dots float on its foot): the reading block runs right to the bar's top edge — the reserve's breathing gap (--sp-6)
68
63
  is given back, the dots keep the words clear of the bar (Lh 2026-09-23) */
69
64
 
@@ -119,7 +114,7 @@
119
114
  TOP + CONTENT in one flex column; BOTTOM is a LAYER over them (Lh 2026-09-23, second call — replaces "the bar is a row"):
120
115
  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
121
116
  under it, on purpose; nothing is added to the content to make room. The keyboard lifts the layer (--kb).
122
- (A bar OUTSIDE a fill screen — an app shell, a scrolling screen — is still fixed to the viewport, actions.css.)
117
+ (On a scrolling screen the bar is a sticky last row instead, actions.css.)
123
118
  ============================================================ */
124
119
  .screen--fill > .action-bar {
125
120
  position: absolute; inset-inline: 0; top: auto; bottom: var(--kb, 0rem);
@@ -127,7 +122,6 @@
127
122
  margin: 0;
128
123
  padding-top: 0; /* no ground of its own, never a solid strip */
129
124
  }
130
- .screen--fill:has(> .action-bar) > .step { margin-bottom: 0; }
131
125
  /* the walk's dots are the BOTTOM layer's too (Lh 2026-09-23): a row of the bar, over its buttons, centred */
132
126
  .action-bar > .pager__at, .action-bar__tier > .pager__at { align-self: center; }
133
127
  /* the TOP is in the column too (Lh 2026-09-23): an .appbar in a fill screen is its first row, not fixed over it — the screen
@@ -2,6 +2,8 @@ import { type ComponentProps, type ReactNode } from 'react';
2
2
  export interface ChipProps extends ComponentProps<'span'> {
3
3
  /** filled accent: the selected / live state */
4
4
  on?: boolean;
5
+ /** quiet: an untouched default value — muted words, the thin edge, nothing to find the eye (README rule 13) */
6
+ muted?: boolean;
5
7
  /** a leading signal dot: `a` (the accent signal) or `b` (the secondary) */
6
8
  dot?: 'a' | 'b';
7
9
  /** the element: span (default), a link, or a button */
@@ -10,7 +12,7 @@ export interface ChipProps extends ComponentProps<'span'> {
10
12
  children?: ReactNode;
11
13
  }
12
14
  /** Chip — a status or attribute tag (with an optional signal Dot), or a small tap target; `on` when it is the live one. For a choice use ChipPick (`sign` for the +/× interest picker). */
13
- export declare function Chip({ on, dot, as, href, className, children, ...rest }: ChipProps): import("react").DetailedReactHTMLElement<{
15
+ export declare function Chip({ on, muted, dot, as, href, className, children, ...rest }: ChipProps): import("react").DetailedReactHTMLElement<{
14
16
  ref?: import("react").Ref<HTMLSpanElement> | undefined;
15
17
  key?: import("react").Key | null | undefined;
16
18
  defaultChecked?: boolean | undefined;
@@ -3,9 +3,9 @@ import { createElement } from 'react';
3
3
  import { cx } from '../cx.js';
4
4
  import { Dot } from './Dot.js';
5
5
  /** Chip — a status or attribute tag (with an optional signal Dot), or a small tap target; `on` when it is the live one. For a choice use ChipPick (`sign` for the +/× interest picker). */
6
- export function Chip({ on, dot, as, href, className, children, ...rest }) {
6
+ export function Chip({ on, muted, dot, as, href, className, children, ...rest }) {
7
7
  const el = as ?? (href ? 'a' : 'span');
8
- return createElement(el, { href, type: el === 'button' ? 'button' : undefined, className: cx('chip', on && 'chip--on', className), ...rest }, dot && _jsx(Dot, { b: dot === 'b' }), children);
8
+ return createElement(el, { href, type: el === 'button' ? 'button' : undefined, className: cx('chip', on && 'chip--on', muted && 'chip--muted', className), ...rest }, dot && _jsx(Dot, { b: dot === 'b' }), children);
9
9
  }
10
10
  /** ChipPick — a chip that is a choice: full-width in a ChipGrid (9:00 · 9:30 · …), or `sign` on a ChipRow (+ off, × on — the
11
11
  * interest picker); the picked one fills with accent. A native radio/checkbox inside the label carries the state. */
@@ -13,6 +13,8 @@ export interface DropdownProps extends Omit<ComponentProps<'div'>, 'onChange' |
13
13
  label?: ReactNode;
14
14
  /** right-aligned trigger: the panel grows leftwards (in TopBar actions) */
15
15
  end?: boolean;
16
+ /** quiet: the value is still the default — muted words; drop it once the member picks (README rule 13) */
17
+ muted?: boolean;
16
18
  /** MenuItems, each with a `value` */
17
19
  children?: ReactNode;
18
20
  }
@@ -22,7 +24,7 @@ export interface DropdownProps extends Omit<ComponentProps<'div'>, 'onChange' |
22
24
  * input: `value` + `onChange`, or `defaultValue`. Children are MenuItems with a `value`; the chosen one is marked and names the pill.
23
25
  * In a FilterRow dropdowns share the width.
24
26
  */
25
- export declare function Dropdown({ id, value, defaultValue, onChange, label, end, className, children, ...rest }: DropdownProps): import("react").JSX.Element;
27
+ export declare function Dropdown({ id, value, defaultValue, onChange, label, end, muted, className, children, ...rest }: DropdownProps): import("react").JSX.Element;
26
28
  export interface MenuProps extends Omit<ComponentProps<'div'>, 'popover' | 'onChange' | 'defaultValue'> {
27
29
  /** render as a popover panel: give it an `id`, and ANY button opens it with the platform's own `popoverTarget={id}` — the Menu does
28
30
  * not know which. Omit for an inline menu card */
@@ -19,10 +19,10 @@ function useValue(value, defaultValue, onChange) {
19
19
  * input: `value` + `onChange`, or `defaultValue`. Children are MenuItems with a `value`; the chosen one is marked and names the pill.
20
20
  * In a FilterRow dropdowns share the width.
21
21
  */
22
- export function Dropdown({ id, value, defaultValue, onChange, label, end, className, children, ...rest }) {
22
+ export function Dropdown({ id, value, defaultValue, onChange, label, end, muted, className, children, ...rest }) {
23
23
  const [current, pick] = useValue(value, defaultValue, onChange);
24
24
  const chosen = Children.toArray(children).find((c) => isValidElement(c) && c.props.value === current);
25
- return (_jsxs("div", { className: cx('dropdown', end && 'dropdown--end', className), "data-value": current, ...rest, children: [_jsxs("button", { type: "button", className: "dropdown__btn", popoverTarget: id, children: [_jsx("span", { className: "dropdown__label", children: label ?? (isValidElement(chosen) ? chosen.props.children : null) }), _jsx(Icon, { name: "chevron-down", size: "xs" })] }), _jsx(Menu, { popover: true, id: id, value: current, onChange: pick, children: children })] }));
25
+ return (_jsxs("div", { className: cx('dropdown', end && 'dropdown--end', muted && 'dropdown--muted', className), "data-value": current, ...rest, children: [_jsxs("button", { type: "button", className: "dropdown__btn", popoverTarget: id, children: [_jsx("span", { className: "dropdown__label", children: label ?? (isValidElement(chosen) ? chosen.props.children : null) }), _jsx(Icon, { name: "chevron-down", size: "xs" })] }), _jsx(Menu, { popover: true, id: id, value: current, onChange: pick, children: children })] }));
26
26
  }
27
27
  /**
28
28
  * Menu — a card of MenuItems: a popover, or inline rows of icon + label. A COMPONENT on its own — what opens a popover Menu is any
package/dist/cardds.css CHANGED
@@ -1114,7 +1114,25 @@ button { font: inherit; cursor: pointer; }
1114
1114
 
1115
1115
  /* ---- StackSheet's tabs (Lh 2026-09-23): the head's end is a row of small icon buttons, the sheet's pages — the chosen one
1116
1116
  filled (.icon-btn--invert); a count is a small circle over the icon's top right corner ---- */
1117
- .stack-sheet__tabs { display: flex; gap: var(--stack-sheet-tabs-gap, 0rem); flex: none; } /* no gap: each tab is a 40px target already, and an open head (back · title · tabs) must fit one line — Lh 2026-09-23 */
1117
+ .stack-sheet__tabs { display: flex; gap: var(--stack-sheet-tabs-gap, var(--sp-2)); flex: none; } /* room between the tabs: with none, the chosen tab's disc touched its neighbour's (Lh 2026-09-24) */
1118
+ /* the head is ONE line, always (Lh 2026-09-24): the tabs keep their size, the TITLE gives — it never wraps, it runs sideways
1119
+ (StackSheet measures --_over, the width it overflows by, and sets .is-long), one way: it waits, runs to the end, waits, and starts again from the first letter — like a song title */
1120
+ .stack-sheet > .card__head { flex-wrap: nowrap; }
1121
+ .stack-sheet__title {
1122
+ flex: 1; min-width: 0; overflow-x: clip; overflow-y: visible; white-space: nowrap; overflow-wrap: normal; /* clip SIDEWAYS only:
1123
+ hidden clipped the Thai tone marks above a line-height-1 line (เพื่อน lost its ไม้เอก — Lh 2026-09-24) */
1124
+ translate: 0 var(--stack-sheet-title-shift, -0.1em); /* FC Pride's line box carries more room above its letters than below: the
1125
+ letters sat ~0.1em under the middle of the back disc and the tabs — lifted onto it (Lh 2026-09-24) */
1126
+ }
1127
+ .stack-sheet__run { display: inline-block; }
1128
+ .stack-sheet__title.is-long .stack-sheet__run {
1129
+ animation: stack-sheet-run var(--stack-sheet-run-time, var(--_run, 6s)) ease-in-out infinite; /* ONE way: start → end, then back to the start at once (Lh 2026-09-24) */ /* --_run: from the overflow, 30px a second + the pauses */
1130
+ }
1131
+ @keyframes stack-sheet-run { 0%, 20% { translate: 0; } 85%, 100% { translate: calc(-1 * var(--_over)); } }
1132
+ @media (prefers-reduced-motion: reduce) {
1133
+ .stack-sheet__title.is-long .stack-sheet__run { animation: none; display: inline; } /* inline: an ellipsis cannot cut an inline-block */
1134
+ .stack-sheet__title.is-long { text-overflow: ellipsis; }
1135
+ }
1118
1136
  .stack-sheet__tab { position: relative; }
1119
1137
  .stack-sheet__tab > .stack-sheet__count { /* outranks .badge (numbers.css loads after this file) */
1120
1138
  position: absolute;
@@ -1543,7 +1561,7 @@ button { font: inherit; cursor: pointer; }
1543
1561
  <section class="page">…the words…</section> ← first
1544
1562
  <article class="card sheet sheet--half">…the controls… <div class="card__foot">…</div></article> ← last, optional
1545
1563
  </section>
1546
- <div class="action-bar">…</div> ← only when there is no sheet
1564
+ <div class="action-bar">…</div> ← the screen's last child, only when there is no sheet
1547
1565
 
1548
1566
  The step is the box the sheet slides in. It fills the screen
1549
1567
  column between the top bar's reserve (.screen already pads for
@@ -1592,11 +1610,6 @@ button { font: inherit; cursor: pointer; }
1592
1610
  more air and a short one less; a screen with a bar keeps the bar's own room */
1593
1611
  .screen > .step:first-child, .screen--lead > .step { --_lead: var(--screen-lead, 15cqh); } /* top="lead" says it; a Step first says it too */
1594
1612
 
1595
- /* the bar's room — read from actions.css, never recomputed. Margin, not
1596
- padding, so the clip cuts at the box edge and nothing shows behind the
1597
- bar. Any parent that hosts the bar counts — <body>, an app shell, a demo
1598
- frame — the bar is fixed to the viewport wherever it sits. */
1599
- :has(> .action-bar) .step { margin-bottom: calc(var(--bar-reserve) + var(--kb, 0rem)); }
1600
1613
  /* a walk (the dots float on its foot): the reading block runs right to the bar's top edge — the reserve's breathing gap (--sp-6)
1601
1614
  is given back, the dots keep the words clear of the bar (Lh 2026-09-23) */
1602
1615
 
@@ -1652,7 +1665,7 @@ button { font: inherit; cursor: pointer; }
1652
1665
  TOP + CONTENT in one flex column; BOTTOM is a LAYER over them (Lh 2026-09-23, second call — replaces "the bar is a row"):
1653
1666
  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
1654
1667
  under it, on purpose; nothing is added to the content to make room. The keyboard lifts the layer (--kb).
1655
- (A bar OUTSIDE a fill screen — an app shell, a scrolling screen — is still fixed to the viewport, actions.css.)
1668
+ (On a scrolling screen the bar is a sticky last row instead, actions.css.)
1656
1669
  ============================================================ */
1657
1670
  .screen--fill > .action-bar {
1658
1671
  position: absolute; inset-inline: 0; top: auto; bottom: var(--kb, 0rem);
@@ -1660,7 +1673,6 @@ button { font: inherit; cursor: pointer; }
1660
1673
  margin: 0;
1661
1674
  padding-top: 0; /* no ground of its own, never a solid strip */
1662
1675
  }
1663
- .screen--fill:has(> .action-bar) > .step { margin-bottom: 0; }
1664
1676
  /* the walk's dots are the BOTTOM layer's too (Lh 2026-09-23): a row of the bar, over its buttons, centred */
1665
1677
  .action-bar > .pager__at, .action-bar__tier > .pager__at { align-self: center; }
1666
1678
  /* the TOP is in the column too (Lh 2026-09-23): an .appbar in a fill screen is its first row, not fixed over it — the screen
@@ -1990,6 +2002,8 @@ button { font: inherit; cursor: pointer; }
1990
2002
  letters' own middle, in em so it follows the text size (Lh 2026-09-22: "ตัวหนังสือกับไฟเสมอกัน") */
1991
2003
  .dot--b { background: var(--dot-b); }
1992
2004
  .chip--on { background: var(--chip-on-bg, var(--accent)); color: var(--chip-on-ink, var(--on-accent)); border-color: transparent; }
2005
+ /* muted: an untouched default — the words wash out, the edge stays; emphasis is the difference from the neighbours (README 13) */
2006
+ .chip--muted { color: var(--chip-muted-ink, var(--card-muted)); }
1993
2007
  /* dark-slot inversion lives in card.css (.card--3 :is(...)) */
1994
2008
 
1995
2009
  .chip-row { display: flex; gap: var(--sp-2); flex-wrap: wrap; }
@@ -2019,6 +2033,8 @@ button { font: inherit; cursor: pointer; }
2019
2033
  text-align: left;
2020
2034
  cursor: pointer;
2021
2035
  }
2036
+ /* muted: the value is still the default (README 13) */
2037
+ .dropdown--muted .dropdown__btn { color: var(--dropdown-muted-ink, var(--card-muted)); }
2022
2038
  .dropdown__label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2023
2039
  .dropdown__btn > .icon { transition: rotate 0.2s ease; }
2024
2040
  .dropdown:has(.menu:popover-open) .dropdown__btn > .icon { rotate: 180deg; }
@@ -2048,21 +2064,15 @@ button { font: inherit; cursor: pointer; }
2048
2064
  opacity: 1;
2049
2065
  }
2050
2066
 
2051
- /* ---- bottom CTA pack: fixed action zone under the content ----
2052
- --bar-reserve is the room the bar needs — its own height plus a breathing
2053
- gap above it — published here so anything else that must stop clear of it
2054
- reads this instead of recomputing it. .screen's own reserve below reads
2055
- it; so does .step (step.css), the box a sheet slides in — one number, so
2056
- the two can't drift apart.
2057
- --kb is the on-screen keyboard's height, published on :root by cardds.js
2058
- from the visual viewport (0 without one, and on a platform that shrinks
2059
- the layout viewport itself): the bar rides above the keyboard on iOS the
2060
- way it already does on Android, and .step grows its room by the same. */
2067
+ /* ---- bottom CTA pack: the screen's bottom (Screen `bottom`) ----
2068
+ A bar is always INSIDE its .screen, its last child (0.7.1: the bar fixed to the viewport from outside, and the
2069
+ reserves the screen and the step kept for it, are gone). In a .screen--fill it is a layer over the content's foot
2070
+ (step.css); on a scrolling screen a sticky last row (below). --bar-reserve stays published — the bar's nominal
2071
+ height — for the one rule that still needs it without measuring: a .card--centre's cap (card.css).
2072
+ --kb is the on-screen keyboard's height (cardds.js): the bar rides above it. */
2061
2073
  :root { --bar-reserve: calc(var(--btn-h) + var(--sp-3) + var(--sp-5) + var(--sp-6)); }
2062
2074
  .action-bar {
2063
- position: fixed;
2064
- inset-inline: 0;
2065
- bottom: var(--kb, 0);
2075
+ position: relative;
2066
2076
  z-index: 30; /* above .sheet-stack sheets, which paint at --level 1-5 */
2067
2077
  max-width: var(--screen-w);
2068
2078
  margin-inline: auto;
@@ -2073,9 +2083,9 @@ button { font: inherit; cursor: pointer; }
2073
2083
  /* 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
2074
2084
  last row — at the foot when the words are short, and sticky to the viewport's foot (above the keyboard) while they scroll;
2075
2085
  it takes the room it needs in the column, so nothing is added to the content for it */
2076
- .screen:not(.screen--fill):has(> .action-bar) { display: flex; flex-direction: column; min-height: 100dvh; }
2077
- .screen:not(.screen--fill) > .action-bar {
2078
- position: sticky; inset-inline: auto; bottom: var(--kb, 0rem);
2086
+ .screen:not(.screen--fill):has(> .action-bar:last-child) { display: flex; flex-direction: column; min-height: 100dvh; }
2087
+ .screen:not(.screen--fill) > .action-bar:last-child {
2088
+ position: sticky; bottom: var(--kb, 0rem);
2079
2089
  max-width: none;
2080
2090
  margin: auto calc(-1 * var(--screen-pad)) 0;
2081
2091
  }
@@ -2145,13 +2155,6 @@ button { font: inherit; cursor: pointer; }
2145
2155
  :is(.action-bar--pager, .action-bar__tier--pager) > :not(.icon-btn):not(:first-child):not(:last-child) { margin-inline: auto; }
2146
2156
  :is(.action-bar--pager, .action-bar__tier--pager):has(> :nth-child(3)) > .icon-btn:first-child { margin-right: 0; }
2147
2157
 
2148
- /* .screen's default bottom padding is a constant; derive the reserve from the
2149
- bar instead, so a taller button (a longer label, a script with more leading)
2150
- can never end up underneath it. Any parent that hosts the bar counts —
2151
- <body>, or an app shell (#app > .screen + .action-bar) — since the bar is
2152
- fixed to the viewport wherever it sits. (.screen--fill reserves nothing:
2153
- its .step carries the bar's room itself, step.css.) */
2154
- :has(> .action-bar) .screen:not(.screen--fill) { padding-bottom: var(--bar-reserve); }
2155
2158
 
2156
2159
  /* ---- action note (Lh 2026-09-24): the main button's place, the main button's size, but ON the ground — no fill, no border, no
2157
2160
  rounding — one line of muted words, an inline icon allowed; nothing to press. In a CardFoot or an ActionBar row it takes the
@@ -2919,6 +2922,9 @@ button { font: inherit; cursor: pointer; }
2919
2922
  /* rows: a list of rows separated by hairlines */
2920
2923
  .rows { display: grid; --rows-gap: var(--sp-4); row-gap: var(--rows-gap); }
2921
2924
  .rows:has(> .row--lg) { --rows-gap: var(--sp-6); }
2925
+ /* the group's name: a label on the rows' start edge; two groups one after another read apart by it, not by space */
2926
+ .rows__head { font: var(--type-label); color: var(--rows-head-ink, var(--card-muted)); }
2927
+ .rows + .rows { margin-top: var(--rows-group-gap, var(--sp-6)); }
2922
2928
  /* the hairline between rows sits in the middle of the gap — drawn, not spaced:
2923
2929
  no padding, no spacer element */
2924
2930
  .rows > .row + .row { position: relative; }
@@ -3150,6 +3156,8 @@ button { font: inherit; cursor: pointer; }
3150
3156
  .tile--pick:has(:checked) { background: var(--accent); color: var(--on-accent); border-color: transparent; }
3151
3157
  .tile--pick:has(:focus-visible) { outline: var(--focus); outline-offset: var(--sp-1); }
3152
3158
  .tile--wide { grid-column: 1 / -1; }
3159
+ /* stat: READ, not pressed — no frame of its own inside a card (a frame says "tap me"): icon · value · words on the card's ground */
3160
+ .tile--stat { border-color: transparent; background: none; padding-inline: 0; cursor: default; }
3153
3161
  .tile--stat { min-height: var(--tile-stat-min-h, var(--avatar-xl)); gap: var(--tile-stat-gap, var(--sp-3)); grid-template-columns: auto 1fr; align-items: center; }
3154
3162
  /* upload: dashed, centred plus + label */
3155
3163
  .tile--upload {
@@ -3378,12 +3386,11 @@ button { font: inherit; cursor: pointer; }
3378
3386
  Reduced motion: the finished state, no beats; closing fires at once. */
3379
3387
  .modal {
3380
3388
  --drawer-h: 14rem;
3381
- position: absolute; /* at the stage's centre, and kept there as it grows: absolute + margin auto re-centres on every height */
3382
- inset: 0;
3383
- margin: auto;
3384
- height: auto;
3385
- align-self: center; /* inset 0 alone would stretch it to the stage: centre self-alignment gives it its own height, the margins
3386
- share the rest. NOT `height: fit-content` — WebKit/Safari sizes it wrong (Lh 2026-09-24, iOS 18.6) */
3389
+ position: absolute; /* at the stage's centre, and kept there as it grows: its middle is pinned at 50% and */
3390
+ top: 50%; /* translate -50% of its OWN height re-centres it on every height. NOT inset 0 + margin auto + */
3391
+ inset-inline: 0; /* align-self: center — Safari 18 ignores align-self on an absolute box in a block container, */
3392
+ margin-inline: auto; /* so the card stretched to the whole stage (iOS 18.6, Lh 2026-09-24); nor height: fit-content */
3393
+ translate: 0 -50%;
3387
3394
  width: calc(100% - 2 * var(--screen-pad));
3388
3395
  z-index: 2; /* over the dim stage's scrim (z 1) */
3389
3396
  max-height: calc(100cqh - 2 * var(--sp-5)); /* cqh: the stage (a size container) */
@@ -3427,8 +3434,8 @@ button { font: inherit; cursor: pointer; }
3427
3434
  .modal { animation: modal-drop calc(2 * var(--motion-sheet)) ease both; }
3428
3435
  .modal__drawer { animation: modal-open calc(2 * var(--motion-sheet)) ease both; }
3429
3436
  @keyframes modal-drop {
3430
- 0% { translate: 0 -100cqh; } /* above the stage */
3431
- 50%, 100% { translate: 0 0; } /* 1. at the centre — and it stays: beat 2 is the drawer's */
3437
+ 0% { translate: 0 calc(-50% - 100cqh); } /* above the stage */
3438
+ 50%, 100% { translate: 0 -50%; } /* 1. at the centre — and it stays: beat 2 is the drawer's */
3432
3439
  }
3433
3440
  @keyframes modal-open {
3434
3441
  0%, 50% { height: 0; padding-block: 0; } /* inside the card */
@@ -3513,16 +3520,29 @@ button { font: inherit; cursor: pointer; }
3513
3520
  @starting-style { .menu:popover-open { opacity: 0; translate: 0 calc(-1 * var(--sp-2)); } }
3514
3521
  }
3515
3522
 
3516
- /* ---- banner: a chip that opens into a tinted card, no JS ----
3517
- <details class="banner"><summary class="chip chip--on">Save $30 on orders over $70 <svg class="icon icon--xs"/></summary>
3518
- <div class="card card--3 banner__body">…</div></details> */
3519
- .banner { display: grid; gap: var(--banner-gap, var(--sp-2)); justify-items: start; }
3520
- .banner > summary { list-style: none; cursor: pointer; }
3521
- .banner > summary::-webkit-details-marker { display: none; }
3522
- .banner > summary .icon { transition: rotate 0.2s ease; }
3523
- .banner[open] > summary .icon { rotate: 180deg; }
3524
- .banner__body { width: 100%; padding: var(--banner-body-pad-y, var(--sp-4)) var(--banner-body-pad-x, var(--sp-5)); --card-gap: var(--sp-1); }
3525
- .banner::details-content { width: 100%; } /* Chromium wraps the content in this pseudo; justify-items: start shrink-wrapped it, so the body never reached the width */
3523
+ /* ---- banner: ONE band — the line that opens it and the body share one ground (Lh 2026-09-24: "ปุ่มกับเนื้อหาใช้พื้นที่เดียวกัน") ----
3524
+ <details class="banner"><summary class="banner__summary">Save 2 h… <svg class="icon icon--xs"/></summary>
3525
+ <div class="banner__body">…</div></details>
3526
+ A light tint (not a card tone) with a slightly darker rule above and below, edge to edge of its card or screen (Lh 2026-09-24). */
3527
+ .banner {
3528
+ display: grid;
3529
+ background: var(--banner-bg, var(--ink-tint));
3530
+ color: var(--banner-ink, inherit);
3531
+ border-block: var(--border-w) solid var(--banner-border, var(--hairline));
3532
+ /* a band edge to edge of what holds it — the card's or the screen's inset; no radius, no side border: a band, not a card */
3533
+ margin-inline: calc(-1 * var(--card-pad, var(--screen-pad)));
3534
+ padding-inline: var(--card-pad, var(--screen-pad));
3535
+ }
3536
+ .banner__summary {
3537
+ list-style: none; cursor: pointer;
3538
+ display: flex; align-items: center; justify-content: space-between; gap: var(--sp-2);
3539
+ padding-block: var(--banner-summary-pad-y, calc(var(--sp-base) * 1.5)); font: var(--banner-summary-font, var(--type-caption)); font-weight: 400;
3540
+ }
3541
+ .banner__summary::-webkit-details-marker { display: none; }
3542
+ .banner__summary .icon { transition: rotate 0.2s ease; }
3543
+ .banner[open] .banner__summary .icon { rotate: 180deg; }
3544
+ .banner__body { display: grid; gap: var(--banner-body-gap, var(--sp-1)); padding-bottom: var(--sp-4); }
3545
+ .banner::details-content { width: 100%; }
3526
3546
  @supports (interpolate-size: allow-keywords) {
3527
3547
  @media (prefers-reduced-motion: no-preference) {
3528
3548
  .banner { interpolate-size: allow-keywords; }
@@ -3637,3 +3657,21 @@ button { font: inherit; cursor: pointer; }
3637
3657
  .deck__actions { display: flex; justify-content: center; gap: var(--deck-actions-gap, var(--sp-8)); }
3638
3658
  .deck__actions > .icon-btn { width: var(--bar-h); height: var(--bar-h); }
3639
3659
 
3660
+ /* ---- splash: the app's mark at the human centre while it boots (Lh 2026-09-24) ----
3661
+ <section class="screen screen--fill splash"><div class="splash__group"><div class="splash__mark">{the app's lottie/img}</div>
3662
+ <p class="splash__text">…</p></div></section>
3663
+ The group's MIDDLE is on --screen-centre (40% down), the same line a centred card uses. The mark is the app's own content —
3664
+ a lottie box often carries empty room around its drawing — the app sets the knobs below, cardds does not guess it. */
3665
+ .splash { position: relative; background: var(--splash-bg, var(--surface)); transition: opacity var(--motion-sheet) ease; }
3666
+ .splash--leaving { opacity: 0; }
3667
+ .splash__group {
3668
+ position: absolute; inset-inline: var(--screen-pad); top: var(--splash-at, var(--screen-centre)); translate: 0 -50%;
3669
+ display: grid; justify-items: center; gap: var(--splash-gap, var(--sp-4)); text-align: center;
3670
+ }
3671
+ .splash__mark { width: var(--splash-mark-w, 60%); max-width: 100%; translate: var(--splash-mark-x, 0) var(--splash-mark-y, 0); }
3672
+ /* each app's lottie is its own size and carries its own empty room: --splash-mark-w sizes the box, --splash-mark-x / -y nudge the
3673
+ drawing onto the centre line (the line under it does not move), --splash-at moves the whole group (default the 40% line) */
3674
+ .splash__mark > * { display: block; width: 100%; height: auto; }
3675
+ .splash__text { margin: 0; font: var(--type-body); color: var(--splash-text-ink, var(--on-surface-muted)); }
3676
+ @media (prefers-reduced-motion: reduce) { .splash { transition: none; } }
3677
+
package/dist/index.d.ts CHANGED
@@ -77,4 +77,5 @@ export * from './templates/Walkthrough.js';
77
77
  export * from './templates/Home.js';
78
78
  export * from './sheets/StackSheet.js';
79
79
  export * from './templates/CallSheet.js';
80
+ export * from './templates/Splash.js';
80
81
  export * from './actions/ActionNote.js';
package/dist/index.js CHANGED
@@ -81,4 +81,5 @@ export * from './templates/Walkthrough.js';
81
81
  export * from './templates/Home.js';
82
82
  export * from './sheets/StackSheet.js';
83
83
  export * from './templates/CallSheet.js';
84
+ export * from './templates/Splash.js';
84
85
  export * from './actions/ActionNote.js';
@@ -1,9 +1,8 @@
1
1
  import type { ComponentProps, ReactNode } from 'react';
2
- export interface BannerProps extends ComponentProps<'details'> {
3
- /** the chip that opens it (a `Chip on` with a chevron Icon xs) */
2
+ export interface BannerProps extends Omit<ComponentProps<'details'>, 'summary'> {
3
+ /** the line that opens it — words (and an inline Icon); the chevron is the Banner's own */
4
4
  summary: ReactNode;
5
- /** the tone of the opened card */
6
- tone?: 1 | 2 | 3;
7
5
  }
8
- /** Banner — a chip that expands into a tinted card, no JS (a native details). */
9
- export declare function Banner({ summary, tone, className, children, ...rest }: BannerProps): import("react").JSX.Element;
6
+ /** Banner — ONE band: the line that opens it and what it opens share the same ground, so they read as one piece (Lh 2026-09-24).
7
+ * A light tint with a darker rule above and below, edge to edge of its card or screen — a band, never a card in a card. A native details, no JS. */
8
+ export declare function Banner({ summary, className, children, ...rest }: BannerProps): import("react").JSX.Element;
@@ -1,6 +1,8 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { cx } from '../cx.js';
3
- /** Banner — a chip that expands into a tinted card, no JS (a native details). */
4
- export function Banner({ summary, tone = 3, className, children, ...rest }) {
5
- return (_jsxs("details", { className: cx('banner', className), ...rest, children: [_jsx("summary", { children: summary }), _jsx("div", { className: cx('card banner__body', tone !== 1 && `card--${tone}`), children: children })] }));
3
+ import { Icon } from '../type/Icon.js';
4
+ /** Banner — ONE band: the line that opens it and what it opens share the same ground, so they read as one piece (Lh 2026-09-24).
5
+ * A light tint with a darker rule above and below, edge to edge of its card or screen — a band, never a card in a card. A native details, no JS. */
6
+ export function Banner({ summary, className, children, ...rest }) {
7
+ return (_jsxs("details", { className: cx('banner', className), ...rest, children: [_jsxs("summary", { className: "banner__summary", children: [summary, _jsx(Icon, { name: "chevron-down", size: "xs" })] }), _jsx("div", { className: "banner__body", children: children })] }));
6
8
  }
@@ -41,6 +41,8 @@ export interface RowProps extends Omit<ComponentProps<'div'>, 'title'> {
41
41
  */
42
42
  export declare function Row({ lead, leadIcon, title, meta, body, end, endValue, endCaption, lg, done, as, href, more, open, defaultOpen, onOpenChange, className, children, ...rest }: RowProps): import("react").JSX.Element;
43
43
  export interface RowsProps extends ComponentProps<'div'> {
44
+ /** the group's name, above its rows (Today · Tomorrow · Later): a long list is GROUPED, not spaced out — several Rows one after another */
45
+ heading?: ReactNode;
44
46
  }
45
- /** Rows — a list of Rows separated by hairlines. */
46
- export declare function Rows({ className, ...rest }: RowsProps): import("react").JSX.Element;
47
+ /** Rows — a list of Rows separated by hairlines; `heading` names the group (a `role="group"` labelled by it). */
48
+ export declare function Rows({ heading, className, children, ...rest }: RowsProps): import("react").JSX.Element;
package/dist/lists/Row.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { createElement } from 'react';
2
+ import { createElement, useId } from 'react';
3
3
  import { cx } from '../cx.js';
4
4
  import { Icon } from '../type/Icon.js';
5
5
  /**
@@ -18,7 +18,9 @@ export function Row({ lead, leadIcon, title, meta, body, end, endValue, endCapti
18
18
  e.currentTarget.open = open; if (is !== (open ?? !is))
19
19
  onOpenChange?.(is); }, children: [row, _jsx("div", { className: "row-expand__more", children: more })] }));
20
20
  }
21
- /** Rows — a list of Rows separated by hairlines. */
22
- export function Rows({ className, ...rest }) {
23
- return _jsx("div", { className: cx('rows', className), ...rest });
21
+ /** Rows — a list of Rows separated by hairlines; `heading` names the group (a `role="group"` labelled by it). */
22
+ export function Rows({ heading, className, children, ...rest }) {
23
+ const id = useId();
24
+ const named = heading != null;
25
+ return (_jsxs("div", { className: cx('rows', className), role: named ? 'group' : undefined, "aria-labelledby": named ? id : undefined, ...rest, children: [named && _jsx("span", { className: "rows__head", id: id, children: heading }), children] }));
24
26
  }
@@ -2,7 +2,7 @@ import type { ComponentProps } from 'react';
2
2
  export interface ActionBarProps extends ComponentProps<'div'> {
3
3
  /** `pager`: previous / next as two equal buttons at the edges (a Link may sit between them; the PagerAt dots never do — they go under the card) · `tiers`: rows stacked — a control tier above the buttons */
4
4
  variant?: 'pager' | 'tiers';
5
- /** render in flow instead of fixed to the viewport bottom (for a preview or a mock frame); the real bar is fixed */
5
+ /** a bare bar for a preview: no position, full width, no side padding (a real bar is a Screen's `bottom`) */
6
6
  inline?: boolean;
7
7
  }
8
8
  /**
@@ -11,7 +11,7 @@ export interface ActionBarProps extends ComponentProps<'div'> {
11
11
  * Plain: one wide primary Btn (the step that commits). `pager`: Back + Next as peers — buttons only; where you are is the
12
12
  * PagerAt under the card in a Centre group, never in the bar (Lh, 2026-09-12).
13
13
  * `tiers`: an ActionBarTier with a control (a Slider) above the button tier.
14
- * In a `Screen fill` the content takes the rest; outside one (an app shell) the bar is still fixed and `--bar-reserve` keeps its room.
14
+ * In a `Screen fill` it is a layer over the content's foot; on a scrolling Screen a sticky last row.
15
15
  */
16
16
  export declare function ActionBar({ variant, inline, className, style, ...rest }: ActionBarProps): import("react").JSX.Element;
17
17
  export interface ActionBarTierProps extends ComponentProps<'div'> {
@@ -6,7 +6,7 @@ import { cx } from '../cx.js';
6
6
  * Plain: one wide primary Btn (the step that commits). `pager`: Back + Next as peers — buttons only; where you are is the
7
7
  * PagerAt under the card in a Centre group, never in the bar (Lh, 2026-09-12).
8
8
  * `tiers`: an ActionBarTier with a control (a Slider) above the button tier.
9
- * In a `Screen fill` the content takes the rest; outside one (an app shell) the bar is still fixed and `--bar-reserve` keeps its room.
9
+ * In a `Screen fill` it is a layer over the content's foot; on a scrolling Screen a sticky last row.
10
10
  */
11
11
  export function ActionBar({ variant, inline, className, style, ...rest }) {
12
12
  return (_jsx("div", { className: cx('action-bar', variant && `action-bar--${variant}`, className), style: inline ? { position: 'static', width: '100%', maxWidth: 'none', paddingInline: 0, ...style } : style, ...rest }));
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useState } from 'react';
2
+ import { useEffect, useRef, useState } from 'react';
3
3
  import { cx } from '../cx.js';
4
4
  import { Card } from '../cards/Card.js';
5
5
  import { CardHead } from '../cards/CardHead.js';
@@ -20,5 +20,24 @@ export function StackSheet({ title, tone, icon, tabs, tab: tabProp, defaultTab,
20
20
  const tab = tabProp ?? own;
21
21
  const pick = (id) => { if (tabProp === undefined)
22
22
  setOwn(id); onTabChange?.(id); };
23
- return (_jsxs(Card, { tone: tone, className: cx('stack-sheet', className), ...rest, children: [_jsxs(CardHead, { children: [_jsx(StackBack, { label: backLabel }), _jsx(Text, { level: "h2", children: title }), tabs?.length ? (_jsx("span", { className: "stack-sheet__tabs", role: "tablist", children: tabs.map((t) => (_jsxs("button", { type: "button", role: "tab", "aria-selected": t.id === tab, "aria-label": t.label, className: cx('icon-btn icon-btn--sm stack-sheet__tab', t.id === tab && 'icon-btn--invert'), onClick: () => pick(t.id), children: [_jsx(Icon, { name: t.icon }), t.count ? _jsx(Badge, { className: "stack-sheet__count", children: t.count > 99 ? '99+' : t.count }) : null] }, t.id))) })) : icon] }), _jsx(SheetBody, { role: tabs?.length ? 'tabpanel' : undefined, children: children }), foot != null && _jsx(CardFoot, { children: foot })] }));
23
+ // a title longer than its room does not wrap: it runs sideways, like a song title in a short slot — the room it overflows by
24
+ // is measured here (the tabs may grow with the text size) and handed to the CSS as --_over
25
+ const titleRef = useRef(null);
26
+ useEffect(() => {
27
+ const h = titleRef.current, run = h?.firstElementChild;
28
+ if (!h || !run || typeof ResizeObserver === 'undefined')
29
+ return;
30
+ const measure = () => {
31
+ const over = Math.max(0, Math.ceil(run.scrollWidth - h.clientWidth));
32
+ h.style.setProperty('--_over', `${over}px`);
33
+ h.style.setProperty('--_run', `${(over / 30 + 3).toFixed(1)}s`);
34
+ h.classList.toggle('is-long', over > 0);
35
+ };
36
+ const ro = new ResizeObserver(measure);
37
+ ro.observe(h);
38
+ ro.observe(run);
39
+ measure();
40
+ return () => ro.disconnect();
41
+ }, [title]);
42
+ return (_jsxs(Card, { tone: tone, className: cx('stack-sheet', className), ...rest, children: [_jsxs(CardHead, { children: [_jsx(StackBack, { label: backLabel }), _jsx(Text, { level: "h2", ref: titleRef, className: "stack-sheet__title", children: _jsx("span", { className: "stack-sheet__run", children: title }) }), tabs?.length ? (_jsx("span", { className: "stack-sheet__tabs", role: "tablist", children: tabs.map((t) => (_jsxs("button", { type: "button", role: "tab", "aria-selected": t.id === tab, "aria-label": t.label, className: cx('icon-btn icon-btn--sm stack-sheet__tab', t.id === tab && 'icon-btn--invert'), onClick: () => pick(t.id), children: [_jsx(Icon, { name: t.icon }), t.count ? _jsx(Badge, { className: "stack-sheet__count", children: t.count > 99 ? '99+' : t.count }) : null] }, t.id))) })) : icon] }), _jsx(SheetBody, { role: tabs?.length ? 'tabpanel' : undefined, children: children }), foot != null && _jsx(CardFoot, { children: foot })] }));
24
43
  }
@@ -0,0 +1,22 @@
1
+ import { type ComponentProps, type ReactNode } from 'react';
2
+ import { Screen } from '../scaffold/Screen.js';
3
+ export interface SplashProps extends Omit<ComponentProps<typeof Screen>, 'children' | 'fill' | 'top' | 'bottom'> {
4
+ /** the app's mark — ITS OWN content: a Lottie player, an <img>, an SVG. cardds places it; it never plays anything itself */
5
+ mark: ReactNode;
6
+ /** one line under the mark (optional) */
7
+ text?: ReactNode;
8
+ /** the app is ready: the splash fades out (after `min`), then calls `onLeft` */
9
+ done?: boolean;
10
+ /** the least time it stays up, in ms, counted from when it appeared (default 0) — so a fast boot does not flash it */
11
+ min?: number;
12
+ /** it has faded out: the app unmounts it now */
13
+ onLeft?: () => void;
14
+ }
15
+ /**
16
+ * Splash — the screen an app shows while it boots: the app's mark (and a line) at the human centre, `--screen-centre` (40%) down,
17
+ * nothing else. A TEMPLATE: cardds does the place, the line, the timing and the fade; the mark is the app's (a Lottie it loads and
18
+ * plays, or a still) — cardds has no animation library. The app says when it is ready (`done`); `min` keeps a fast boot from
19
+ * flashing it. Each lottie is its own size: knobs `--splash-mark-w` (the box's width), `--splash-mark-x` / `--splash-mark-y` (nudge the
20
+ * drawing onto the line), `--splash-at` (the group's line, default --screen-centre) — in the app's theme.css or `style`. `onLeft` fires after the fade (at once under prefers-reduced-motion — the app should give its mark a still frame there too).
21
+ */
22
+ export declare function Splash({ mark, text, done, min, onLeft, className, ...rest }: SplashProps): import("react").JSX.Element;
@@ -0,0 +1,32 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useRef, useState } from 'react';
3
+ import { Screen } from '../scaffold/Screen.js';
4
+ import { cx } from '../cx.js';
5
+ /**
6
+ * Splash — the screen an app shows while it boots: the app's mark (and a line) at the human centre, `--screen-centre` (40%) down,
7
+ * nothing else. A TEMPLATE: cardds does the place, the line, the timing and the fade; the mark is the app's (a Lottie it loads and
8
+ * plays, or a still) — cardds has no animation library. The app says when it is ready (`done`); `min` keeps a fast boot from
9
+ * flashing it. Each lottie is its own size: knobs `--splash-mark-w` (the box's width), `--splash-mark-x` / `--splash-mark-y` (nudge the
10
+ * drawing onto the line), `--splash-at` (the group's line, default --screen-centre) — in the app's theme.css or `style`. `onLeft` fires after the fade (at once under prefers-reduced-motion — the app should give its mark a still frame there too).
11
+ */
12
+ export function Splash({ mark, text, done, min = 0, onLeft, className, ...rest }) {
13
+ const born = useRef(Date.now());
14
+ const [leaving, setLeaving] = useState(false);
15
+ const left = useRef(onLeft);
16
+ left.current = onLeft;
17
+ useEffect(() => {
18
+ if (!done)
19
+ return;
20
+ const t = setTimeout(() => setLeaving(true), Math.max(0, min - (Date.now() - born.current)));
21
+ return () => clearTimeout(t);
22
+ }, [done, min]);
23
+ useEffect(() => {
24
+ if (!leaving)
25
+ return;
26
+ const still = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
27
+ const ms = still ? 0 : parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--motion-sheet')) * 1000 || 350;
28
+ const t = setTimeout(() => left.current?.(), ms);
29
+ return () => clearTimeout(t);
30
+ }, [leaving]);
31
+ return (_jsx(Screen, { fill: true, className: cx('splash', leaving && 'splash--leaving', className), role: "status", "aria-busy": !leaving, ...rest, children: _jsxs("div", { className: "splash__group", children: [_jsx("div", { className: "splash__mark", children: mark }), text != null && _jsx("p", { className: "splash__text", children: text })] }) }));
32
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usableapp/cardds",
3
- "version": "0.7.0",
3
+ "version": "0.7.2",
4
4
  "license": "UNLICENSED",
5
5
  "type": "module",
6
6
  "description": "card-first mobile design system, React-first: the components in src/ are thin wrappers over the CSS contract (css/*.css stays the only truth); gallery/ shows every story live (npm run dev), tests/ measures the geometry.",