@usableapp/cardds 0.7.1 → 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,34 @@
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
+
5
33
  ## 0.7.1 — 2026-09-24
6
34
 
7
35
  ### The bar fixed from outside is gone
package/README.md CHANGED
@@ -497,6 +497,34 @@ Demo: the *skeleton* block at the top of `elements.html`.
497
497
  `--on-accent` to its own ink and bg, so every accent consumer (buttons,
498
498
  checks, toggles, rings, selected days) inverts without extra rules.
499
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
+
500
528
  ## A project's theme
501
529
 
502
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; }
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;
@@ -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;
@@ -1984,6 +2002,8 @@ button { font: inherit; cursor: pointer; }
1984
2002
  letters' own middle, in em so it follows the text size (Lh 2026-09-22: "ตัวหนังสือกับไฟเสมอกัน") */
1985
2003
  .dot--b { background: var(--dot-b); }
1986
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)); }
1987
2007
  /* dark-slot inversion lives in card.css (.card--3 :is(...)) */
1988
2008
 
1989
2009
  .chip-row { display: flex; gap: var(--sp-2); flex-wrap: wrap; }
@@ -2013,6 +2033,8 @@ button { font: inherit; cursor: pointer; }
2013
2033
  text-align: left;
2014
2034
  cursor: pointer;
2015
2035
  }
2036
+ /* muted: the value is still the default (README 13) */
2037
+ .dropdown--muted .dropdown__btn { color: var(--dropdown-muted-ink, var(--card-muted)); }
2016
2038
  .dropdown__label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2017
2039
  .dropdown__btn > .icon { transition: rotate 0.2s ease; }
2018
2040
  .dropdown:has(.menu:popover-open) .dropdown__btn > .icon { rotate: 180deg; }
@@ -2900,6 +2922,9 @@ button { font: inherit; cursor: pointer; }
2900
2922
  /* rows: a list of rows separated by hairlines */
2901
2923
  .rows { display: grid; --rows-gap: var(--sp-4); row-gap: var(--rows-gap); }
2902
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)); }
2903
2928
  /* the hairline between rows sits in the middle of the gap — drawn, not spaced:
2904
2929
  no padding, no spacer element */
2905
2930
  .rows > .row + .row { position: relative; }
@@ -3131,6 +3156,8 @@ button { font: inherit; cursor: pointer; }
3131
3156
  .tile--pick:has(:checked) { background: var(--accent); color: var(--on-accent); border-color: transparent; }
3132
3157
  .tile--pick:has(:focus-visible) { outline: var(--focus); outline-offset: var(--sp-1); }
3133
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; }
3134
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; }
3135
3162
  /* upload: dashed, centred plus + label */
3136
3163
  .tile--upload {
@@ -3359,12 +3386,11 @@ button { font: inherit; cursor: pointer; }
3359
3386
  Reduced motion: the finished state, no beats; closing fires at once. */
3360
3387
  .modal {
3361
3388
  --drawer-h: 14rem;
3362
- position: absolute; /* at the stage's centre, and kept there as it grows: absolute + margin auto re-centres on every height */
3363
- inset: 0;
3364
- margin: auto;
3365
- height: auto;
3366
- align-self: center; /* inset 0 alone would stretch it to the stage: centre self-alignment gives it its own height, the margins
3367
- 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%;
3368
3394
  width: calc(100% - 2 * var(--screen-pad));
3369
3395
  z-index: 2; /* over the dim stage's scrim (z 1) */
3370
3396
  max-height: calc(100cqh - 2 * var(--sp-5)); /* cqh: the stage (a size container) */
@@ -3408,8 +3434,8 @@ button { font: inherit; cursor: pointer; }
3408
3434
  .modal { animation: modal-drop calc(2 * var(--motion-sheet)) ease both; }
3409
3435
  .modal__drawer { animation: modal-open calc(2 * var(--motion-sheet)) ease both; }
3410
3436
  @keyframes modal-drop {
3411
- 0% { translate: 0 -100cqh; } /* above the stage */
3412
- 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 */
3413
3439
  }
3414
3440
  @keyframes modal-open {
3415
3441
  0%, 50% { height: 0; padding-block: 0; } /* inside the card */
@@ -3494,16 +3520,29 @@ button { font: inherit; cursor: pointer; }
3494
3520
  @starting-style { .menu:popover-open { opacity: 0; translate: 0 calc(-1 * var(--sp-2)); } }
3495
3521
  }
3496
3522
 
3497
- /* ---- banner: a chip that opens into a tinted card, no JS ----
3498
- <details class="banner"><summary class="chip chip--on">Save $30 on orders over $70 <svg class="icon icon--xs"/></summary>
3499
- <div class="card card--3 banner__body">…</div></details> */
3500
- .banner { display: grid; gap: var(--banner-gap, var(--sp-2)); justify-items: start; }
3501
- .banner > summary { list-style: none; cursor: pointer; }
3502
- .banner > summary::-webkit-details-marker { display: none; }
3503
- .banner > summary .icon { transition: rotate 0.2s ease; }
3504
- .banner[open] > summary .icon { rotate: 180deg; }
3505
- .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); }
3506
- .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%; }
3507
3546
  @supports (interpolate-size: allow-keywords) {
3508
3547
  @media (prefers-reduced-motion: no-preference) {
3509
3548
  .banner { interpolate-size: allow-keywords; }
@@ -3618,3 +3657,21 @@ button { font: inherit; cursor: pointer; }
3618
3657
  .deck__actions { display: flex; justify-content: center; gap: var(--deck-actions-gap, var(--sp-8)); }
3619
3658
  .deck__actions > .icon-btn { width: var(--bar-h); height: var(--bar-h); }
3620
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
  }
@@ -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.1",
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.",