@usableapp/cardds 0.7.36 → 0.7.38

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,26 @@
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.38 — 2026-10-01
6
+
7
+ ### Fixed
8
+ - **`Banner`: its words ran past the band when a line held a word with no break in it** (a link; found in mailpool, Lh 2026-10-01) — the body's column grew as wide as that word. The column is now held to the band and such a word breaks where it must. Nothing to change in a screen.
9
+
10
+ - **`Sheet`: the body's words ran under the foot's button at a large text size** (found in timebank, Lh 2026-10-01). The room a sheet kept for its foot was a fixed sum (the button's token height), but a button grows with the member's text size, and a line may stand over it. The Sheet now measures its foot's rows and keeps that much room (`--sheet-foot-h`, its own data — not a knob); the strip's ground covers all of it. Nothing to change in a screen; a `Text` put in a `CardFoot` over the button is counted too.
11
+
12
+ ### Added
13
+ - **`Spinner` — "working on it"** (Lh 2026-10-01, asked by mailpool): a ring that turns, for a wait of unknown length (a known share is a `Progress`). `<Spinner label="…" />` alone (the label is read out), or `<Spinner>words</Spinner>` with a caption-sized line beside it; `size` = an Icon's (`xs` · `md` · `lg`); `role="status"`. In a row's `end`, a card, a sheet's body, an `EmptyState`'s `icon`. Still when motion is reduced. Knob `--spinner-ink`. Before, the only turning ring was inside `FormNote busy`, not exported. A whole screen that is not known yet still shows nothing (`Screen ready`), not a spinner.
14
+
15
+ ## 0.7.37 — 2026-10-01
16
+
17
+ ### Fixed
18
+ - **`slideScreen` held nothing for a `ready={false}` screen under a router that renders a tick after `navigate()`** (react-router 7; found by timebank, 2026-10-01): any mutation in the page — a popover closing — ended the wait while the old screen was still the one there, so the new screen came after the transition, blank, then drawn, with no slide. Now the wait ends only when the SCREEN changed (another element, or the same one with new children), then a pending screen is held as 0.7.36 said. `ready` on a data screen works as described from this version.
19
+
20
+ ### Added
21
+ - **`IconBtnGroup vertical`** (Lh 2026-10-01, asked by mailpool): the buttons one over the other, the panel one button wide (`--tap`), each button at least a tap tall, the rule between them across. For a `Row`'s `end` — a panel across took almost half the row and the title wrapped. `select` and `aria-pressed` as before.
22
+ - **`Rows` slides its rows when the app reorders them** (Lh 2026-10-01): the same keyed rows rendered in another order move to their new places (`--motion-sheet`), the one going up passing over the others; before, they jumped. Nothing to set; reduced motion = no slide. Story `IconBtnGroup · VerticalInARow` (up / down commands at a row's end).
23
+ - **The back stack: `useLayer` · `useLayers` · `LayerHistory`** (Lh 2026-10-01; the formula proven in timebank first). Back takes ONE thing off: a screen, or a LAYER — a whole bottom sheet, a modal. Before, each project wired this by hand (an open sheet in the query made the whole screen slide on back; in `useState`, back left the page). Now: wrap the screens once in `<LayerHistory state push replace back>` (your router's entry state and three moves — README has the react-router lines), then `const way = useLayer('way')`: `value` (null = not up) · `open(value)` (one entry; already up = the same entry, new value) · `close()` (the part's own close) · `arrive(value)` (a link straight to an open sheet). `useLayers()`: `up` · `closeAll(then)`. Nothing inside a layer takes an entry. The state key is `layers` (`{ [name]: { value, pushed } }`), other keys of the entry are kept. Rule added with it: a finished process is not gone back into — move on by `replace`, and a page that cannot be returned to checks on arrival (`Screen ready={false}`) and replaces itself.
24
+
5
25
  ## 0.7.36 — 2026-10-01
6
26
 
7
27
  ### Added
package/README.md CHANGED
@@ -360,6 +360,21 @@ between at 40% down the screen (see *Anatomy*, "Where the centre is"):
360
360
  is drawn; arrived at through `slideScreen` / the back button, the screen before stays until it is ready (at most 3 s), then
361
361
  the right one slides in. Never one outcome first and another after (story `Step · ArrivesReady`). On a first load there is
362
362
  no screen before: hold the `Splash` (`done`) until the first screen is known.
363
+ THE BACK STACK (Lh 2026-10-01): back takes ONE thing off — a screen (a route), or a LAYER: what lies over a screen, a whole
364
+ bottom sheet or a modal. A layer is one history entry, kept in the entry's STATE (the address stays the page's own, so the
365
+ screen behind does not slide): `const way = useLayer('way')` → `way.value` (null = not up) · `way.open(value)` (one entry;
366
+ already up = the same entry shows the new value) · `way.close()` (the part's own close: the handle, the scrim, a button —
367
+ back one entry, or, when it came with the page, the entry without it) · `way.arrive(value)` (a link straight to an open
368
+ sheet: read `?way=` once, no entry added, the query dropped). `useLayers().closeAll(then)` takes every layer off, then moves
369
+ (signing out). NOTHING INSIDE A LAYER TAKES AN ENTRY — a step, typed text, a sheet's rest, a tab: back at any step takes the
370
+ whole sheet down, and opened again it starts anew (a new `key` per opening). The project hands its router over once:
371
+ `<LayerHistory state push replace back>` around its screens — react-router: `state = useLocation().state`,
372
+ `push = (s) => navigate({ pathname }, { state: s })`, `replace = (s) => navigate({ pathname }, { replace: true, state: s })`,
373
+ `back = (n) => navigate(-n)`. cardds never calls the History API itself. Story `LayerHistory · Back`.
374
+ A FINISHED PROCESS IS NOT GONE BACK INTO: history cannot be deleted, so (a) inside a process every move on is a `replace`,
375
+ never a push — when it ends, back skips all of it; (b) a page that can no longer be returned to guards itself: it checks on
376
+ arrival (`Screen ready={false}` meanwhile) and, when its work is already done, replaces itself with the right screen — that
377
+ covers forward, an old link, and entries left ahead after `closeAll`.
363
378
  - *Sheets the app calls:* the base offers WAYS IN — sign in · new phone · join — as buttons (`.cta-pack`); each
364
379
  way has a sheet that holds its WHOLE form and its own submit on its foot, starting `.sheet--away` (not on the
365
380
  screen: slid out, hidden once gone, `inert`). **The wiring is the app's, not cardds's**: a `useState` of which
package/css/actions.css CHANGED
@@ -108,6 +108,10 @@
108
108
  .icon-btn-group > .icon-btn { flex: 1 1 0; min-width: 0; width: auto; height: auto; border: 0; border-radius: 0; background: transparent; color: inherit; box-shadow: none; }
109
109
  .icon-btn-group > .icon-btn + .icon-btn { border-inline-start: var(--border-w) solid var(--icon-btn-group-rule, var(--icon-btn-group-border, var(--outline))); }
110
110
  .card__foot > .icon-btn-group { flex: 1 1 0; }
111
+ /* --vertical: the buttons one over the other, ONE button wide — at a row's end, where a panel across would take the words' room */
112
+ .icon-btn-group--vertical { flex-direction: column; flex: none; width: var(--tap); }
113
+ .icon-btn-group--vertical > .icon-btn { flex: 1 0 auto; min-height: var(--tap); }
114
+ .icon-btn-group--vertical > .icon-btn + .icon-btn { border-inline-start: 0; border-block-start: var(--border-w) solid var(--icon-btn-group-rule, var(--icon-btn-group-border, var(--outline))); }
111
115
  .icon-btn-group > .icon-btn[aria-pressed="true"] { background: var(--icon-btn-group-on-bg, var(--accent)); color: var(--icon-btn-group-on-ink, var(--on-accent)); } /* `select`: a pressed button STAYS filled (Lh 2026-09-29) */
112
116
  .icon-btn-group > .icon-btn:active:not(:disabled) { scale: 1; opacity: var(--press-dim); } /* a part of one frame DIMS, it never shrinks inside it */
113
117
 
package/css/layover.css CHANGED
@@ -176,7 +176,7 @@
176
176
  <div class="banner__body">…</div></details>
177
177
  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). */
178
178
  .banner {
179
- display: grid;
179
+ display: grid; grid-template-columns: minmax(0, 1fr);
180
180
  background: var(--banner-bg, var(--ink-tint));
181
181
  color: var(--banner-ink, inherit);
182
182
  border-block: var(--border-w) solid var(--banner-border, var(--hairline));
@@ -192,7 +192,9 @@
192
192
  .banner__summary::-webkit-details-marker { display: none; }
193
193
  .banner__summary .icon { transition: rotate var(--motion-quick) ease; }
194
194
  .banner[open] .banner__summary .icon { rotate: 180deg; }
195
- .banner__body { display: grid; gap: var(--banner-body-gap, var(--sp-1)); padding-bottom: var(--sp-4); }
195
+ /* the band's words never run past it: one column that cannot be wider than the band (a grid track is as wide as its longest
196
+ word otherwise), and a word with no break in it — a link — breaks where it must (mailpool, Lh 2026-10-01) */
197
+ .banner__body { display: grid; grid-template-columns: minmax(0, 1fr); overflow-wrap: anywhere; gap: var(--banner-body-gap, var(--sp-1)); padding-bottom: var(--sp-4); }
196
198
  .banner::details-content { width: 100%; }
197
199
  @supports (interpolate-size: allow-keywords) {
198
200
  @media (prefers-reduced-motion: no-preference) {
package/css/numbers.css CHANGED
@@ -242,6 +242,14 @@
242
242
  .bar--on::after { background: var(--accent); }
243
243
  .bar--on > span { background: var(--accent); color: var(--on-accent); padding: var(--sp-0) var(--sp-2); border-radius: var(--r-chip); }
244
244
 
245
+ /* ---- spinner: "working on it", a wait of unknown length (Lh 2026-10-01) ----
246
+ <span class="spinner" role="status"><svg class="icon">…loader-circle…</svg>Loading…</span>
247
+ The ring turns; words beside it are optional and take the caption's look. Still when motion is reduced. */
248
+ .spinner { display: inline-flex; align-items: center; justify-content: center; gap: var(--sp-2); vertical-align: middle; font: var(--type-caption); color: var(--spinner-ink, currentColor); }
249
+ .spinner > .icon { flex: none; }
250
+ @media (prefers-reduced-motion: no-preference) { .spinner > .icon { animation: spinner-turn 0.9s linear infinite; } }
251
+ @keyframes spinner-turn { to { rotate: 1turn; } }
252
+
245
253
  /* ---- progress: one straight line, the done share solid, the rest faint (Lh 2026-09-22) — a voice note's
246
254
  timeline, a download, a step count as a line. <div class="progress" role="progressbar" style="--progress-at: 0.4"></div>
247
255
  --progress-at is DATA the component writes (0–1), not a knob (scripts/knobs.mjs lists it). The done part is a
package/css/sheet.css CHANGED
@@ -81,7 +81,7 @@
81
81
  border-radius: var(--r-card) var(--r-card) 0 0;
82
82
  border-bottom: 0;
83
83
  box-shadow: var(--sheet-shadow, var(--shadow-sheet));
84
- --_foot: calc(var(--card-gap) + var(--btn-h) + var(--card-pad) + var(--_safe-b-open)); /* the foot strip's height (below) — merged here, one bare .sheet rule */
84
+ --_foot: calc(var(--card-gap) + var(--sheet-foot-h, var(--btn-h)) + var(--card-pad) + var(--_safe-b-open)); /* the foot strip's height (below) — merged here, one bare .sheet rule */
85
85
  }
86
86
  .sheet--full { --_y: 0%; --_cover: 0rem; }
87
87
  .sheet--half { --_y: calc(var(--sheet-half) * 100%); --_cover: calc(var(--sheet-half) * var(--_box)); }
@@ -148,7 +148,7 @@
148
148
  stops that far above the sheet's edge. At peek there is only the handle and the title: the foot is gone.
149
149
  One row, ≤ 2 buttons. <div class="card__foot"><button class="btn btn--primary btn--block">…</button></div> */
150
150
  /* …and the link row under the button, when there is one (its line + the gap over it) */
151
- .sheet:has(> .card__foot--link) { --_foot: calc(var(--card-gap) + var(--btn-h) + var(--sp-1) + var(--link-sm-min-h, calc(var(--tap-base) * 7 / 12)) + var(--card-pad) + var(--_safe-b-open)); }
151
+ .sheet:has(> .card__foot--link) { --_foot: calc(var(--card-gap) + var(--sheet-foot-h, calc(var(--btn-h) + var(--sp-1) + var(--link-sm-min-h, calc(var(--tap-base) * 7 / 12)))) + var(--card-pad) + var(--_safe-b-open)); } /* --sheet-foot-h: the foot's rows as the Sheet measured them (a button grows with the text size) */
152
152
  .sheet > .card__foot {
153
153
  position: absolute; inset: calc(-1 * var(--border-w)) 0 0; z-index: 1; /* the sheet's border box: the same height the sheet's own % reads */
154
154
  align-items: end;
@@ -302,12 +302,12 @@ export declare function Btn({ primary, quiet, block, form, xl, href, className,
302
302
  is?: string | undefined | undefined;
303
303
  exportparts?: string | undefined | undefined;
304
304
  disabled?: boolean | undefined | undefined;
305
+ value?: string | number | readonly string[] | undefined;
305
306
  formAction?: string | ((formData: FormData) => void | Promise<void>) | undefined;
306
307
  formEncType?: string | undefined | undefined;
307
308
  formMethod?: string | undefined | undefined;
308
309
  formNoValidate?: boolean | undefined | undefined;
309
310
  formTarget?: string | undefined | undefined;
310
- value?: string | number | readonly string[] | undefined;
311
311
  href: string | undefined;
312
312
  type: "reset" | "submit" | "button" | undefined;
313
313
  className: string;
@@ -11,9 +11,12 @@ export interface IconBtnGroupProps extends Omit<ComponentProps<'div'>, 'onChange
11
11
  defaultValue?: Picked;
12
12
  /** asks the app: `one` → the pressed value · `many` → the new list */
13
13
  onChange?: (value: Picked) => void;
14
+ /** the buttons one OVER the other, the panel one button wide — at a `Row`'s `end`, where a panel across would take the words' room */
15
+ vertical?: boolean;
14
16
  }
15
17
  /** IconBtnGroup — a panel of up to FOUR icon-only buttons in ONE frame, sharing its width, a rule between them in the frame's
16
18
  * colour (Lh 2026-09-29). Each is an `IconBtn` (its `label` is its spoken name). Plain: each does its own thing on its onClick.
17
- * With `select`, a pressed button stays filled (aria-pressed): `one` or `many`. At home in a card's or a sheet's foot. */
18
- export declare function IconBtnGroup({ label, select, value, defaultValue, onChange, className, children, ...rest }: IconBtnGroupProps): import("react").JSX.Element;
19
+ * With `select`, a pressed button stays filled (aria-pressed): `one` or `many`. At home in a card's or a sheet's foot;
20
+ * `vertical` at a row's end. */
21
+ export declare function IconBtnGroup({ label, select, value, defaultValue, onChange, vertical, className, children, ...rest }: IconBtnGroupProps): import("react").JSX.Element;
19
22
  export {};
@@ -4,8 +4,9 @@ import { cx } from '../cx.js';
4
4
  import { useControlled, warnOnce } from '../state.js';
5
5
  /** IconBtnGroup — a panel of up to FOUR icon-only buttons in ONE frame, sharing its width, a rule between them in the frame's
6
6
  * colour (Lh 2026-09-29). Each is an `IconBtn` (its `label` is its spoken name). Plain: each does its own thing on its onClick.
7
- * With `select`, a pressed button stays filled (aria-pressed): `one` or `many`. At home in a card's or a sheet's foot. */
8
- export function IconBtnGroup({ label, select, value, defaultValue, onChange, className, children, ...rest }) {
7
+ * With `select`, a pressed button stays filled (aria-pressed): `one` or `many`. At home in a card's or a sheet's foot;
8
+ * `vertical` at a row's end. */
9
+ export function IconBtnGroup({ label, select, value, defaultValue, onChange, vertical, className, children, ...rest }) {
9
10
  if (Children.count(children) > 4)
10
11
  warnOnce('icon-btn-group', 'cardds IconBtnGroup: more than 4 buttons — keep 4, put the rest in a Menu behind one of them');
11
12
  const [shown, request] = useControlled(value, defaultValue ?? (select === 'many' ? [] : null), onChange);
@@ -30,5 +31,5 @@ export function IconBtnGroup({ label, select, value, defaultValue, onChange, cla
30
31
  },
31
32
  });
32
33
  });
33
- return _jsx("div", { role: "group", "aria-label": label, className: cx('icon-btn-group', className), ...rest, children: items });
34
+ return _jsx("div", { role: "group", "aria-label": label, className: cx('icon-btn-group', vertical && 'icon-btn-group--vertical', className), ...rest, children: items });
34
35
  }
package/dist/cardds.css CHANGED
@@ -1374,7 +1374,7 @@ button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
1374
1374
  border-radius: var(--r-card) var(--r-card) 0 0;
1375
1375
  border-bottom: 0;
1376
1376
  box-shadow: var(--sheet-shadow, var(--shadow-sheet));
1377
- --_foot: calc(var(--card-gap) + var(--btn-h) + var(--card-pad) + var(--_safe-b-open)); /* the foot strip's height (below) — merged here, one bare .sheet rule */
1377
+ --_foot: calc(var(--card-gap) + var(--sheet-foot-h, var(--btn-h)) + var(--card-pad) + var(--_safe-b-open)); /* the foot strip's height (below) — merged here, one bare .sheet rule */
1378
1378
  }
1379
1379
  .sheet--full { --_y: 0%; --_cover: 0rem; }
1380
1380
  .sheet--half { --_y: calc(var(--sheet-half) * 100%); --_cover: calc(var(--sheet-half) * var(--_box)); }
@@ -1441,7 +1441,7 @@ button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
1441
1441
  stops that far above the sheet's edge. At peek there is only the handle and the title: the foot is gone.
1442
1442
  One row, ≤ 2 buttons. <div class="card__foot"><button class="btn btn--primary btn--block">…</button></div> */
1443
1443
  /* …and the link row under the button, when there is one (its line + the gap over it) */
1444
- .sheet:has(> .card__foot--link) { --_foot: calc(var(--card-gap) + var(--btn-h) + var(--sp-1) + var(--link-sm-min-h, calc(var(--tap-base) * 7 / 12)) + var(--card-pad) + var(--_safe-b-open)); }
1444
+ .sheet:has(> .card__foot--link) { --_foot: calc(var(--card-gap) + var(--sheet-foot-h, calc(var(--btn-h) + var(--sp-1) + var(--link-sm-min-h, calc(var(--tap-base) * 7 / 12)))) + var(--card-pad) + var(--_safe-b-open)); } /* --sheet-foot-h: the foot's rows as the Sheet measured them (a button grows with the text size) */
1445
1445
  .sheet > .card__foot {
1446
1446
  position: absolute; inset: calc(-1 * var(--border-w)) 0 0; z-index: 1; /* the sheet's border box: the same height the sheet's own % reads */
1447
1447
  align-items: end;
@@ -2140,6 +2140,10 @@ button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
2140
2140
  .icon-btn-group > .icon-btn { flex: 1 1 0; min-width: 0; width: auto; height: auto; border: 0; border-radius: 0; background: transparent; color: inherit; box-shadow: none; }
2141
2141
  .icon-btn-group > .icon-btn + .icon-btn { border-inline-start: var(--border-w) solid var(--icon-btn-group-rule, var(--icon-btn-group-border, var(--outline))); }
2142
2142
  .card__foot > .icon-btn-group { flex: 1 1 0; }
2143
+ /* --vertical: the buttons one over the other, ONE button wide — at a row's end, where a panel across would take the words' room */
2144
+ .icon-btn-group--vertical { flex-direction: column; flex: none; width: var(--tap); }
2145
+ .icon-btn-group--vertical > .icon-btn { flex: 1 0 auto; min-height: var(--tap); }
2146
+ .icon-btn-group--vertical > .icon-btn + .icon-btn { border-inline-start: 0; border-block-start: var(--border-w) solid var(--icon-btn-group-rule, var(--icon-btn-group-border, var(--outline))); }
2143
2147
  .icon-btn-group > .icon-btn[aria-pressed="true"] { background: var(--icon-btn-group-on-bg, var(--accent)); color: var(--icon-btn-group-on-ink, var(--on-accent)); } /* `select`: a pressed button STAYS filled (Lh 2026-09-29) */
2144
2148
  .icon-btn-group > .icon-btn:active:not(:disabled) { scale: 1; opacity: var(--press-dim); } /* a part of one frame DIMS, it never shrinks inside it */
2145
2149
 
@@ -2674,6 +2678,14 @@ button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
2674
2678
  .bar--on::after { background: var(--accent); }
2675
2679
  .bar--on > span { background: var(--accent); color: var(--on-accent); padding: var(--sp-0) var(--sp-2); border-radius: var(--r-chip); }
2676
2680
 
2681
+ /* ---- spinner: "working on it", a wait of unknown length (Lh 2026-10-01) ----
2682
+ <span class="spinner" role="status"><svg class="icon">…loader-circle…</svg>Loading…</span>
2683
+ The ring turns; words beside it are optional and take the caption's look. Still when motion is reduced. */
2684
+ .spinner { display: inline-flex; align-items: center; justify-content: center; gap: var(--sp-2); vertical-align: middle; font: var(--type-caption); color: var(--spinner-ink, currentColor); }
2685
+ .spinner > .icon { flex: none; }
2686
+ @media (prefers-reduced-motion: no-preference) { .spinner > .icon { animation: spinner-turn 0.9s linear infinite; } }
2687
+ @keyframes spinner-turn { to { rotate: 1turn; } }
2688
+
2677
2689
  /* ---- progress: one straight line, the done share solid, the rest faint (Lh 2026-09-22) — a voice note's
2678
2690
  timeline, a download, a step count as a line. <div class="progress" role="progressbar" style="--progress-at: 0.4"></div>
2679
2691
  --progress-at is DATA the component writes (0–1), not a knob (scripts/knobs.mjs lists it). The done part is a
@@ -3766,7 +3778,7 @@ button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
3766
3778
  <div class="banner__body">…</div></details>
3767
3779
  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). */
3768
3780
  .banner {
3769
- display: grid;
3781
+ display: grid; grid-template-columns: minmax(0, 1fr);
3770
3782
  background: var(--banner-bg, var(--ink-tint));
3771
3783
  color: var(--banner-ink, inherit);
3772
3784
  border-block: var(--border-w) solid var(--banner-border, var(--hairline));
@@ -3782,7 +3794,9 @@ button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
3782
3794
  .banner__summary::-webkit-details-marker { display: none; }
3783
3795
  .banner__summary .icon { transition: rotate var(--motion-quick) ease; }
3784
3796
  .banner[open] .banner__summary .icon { rotate: 180deg; }
3785
- .banner__body { display: grid; gap: var(--banner-body-gap, var(--sp-1)); padding-bottom: var(--sp-4); }
3797
+ /* the band's words never run past it: one column that cannot be wider than the band (a grid track is as wide as its longest
3798
+ word otherwise), and a word with no break in it — a link — breaks where it must (mailpool, Lh 2026-10-01) */
3799
+ .banner__body { display: grid; grid-template-columns: minmax(0, 1fr); overflow-wrap: anywhere; gap: var(--banner-body-gap, var(--sp-1)); padding-bottom: var(--sp-4); }
3786
3800
  .banner::details-content { width: 100%; }
3787
3801
  @supports (interpolate-size: allow-keywords) {
3788
3802
  @media (prefers-reduced-motion: no-preference) {
package/dist/index.d.ts CHANGED
@@ -17,6 +17,7 @@ export * from './scaffold/Page.js';
17
17
  export * from './scaffold/EmptyState.js';
18
18
  export * from './scaffold/Centre.js';
19
19
  export * from './scaffold/slideScreen.js';
20
+ export * from './scaffold/LayerHistory.js';
20
21
  export * from './cards/Card.js';
21
22
  export * from './cards/CardHead.js';
22
23
  export * from './cards/CardFoot.js';
@@ -55,6 +56,7 @@ export * from './choice/Mood.js';
55
56
  export * from './choice/Slider.js';
56
57
  export * from './numbers/Ring.js';
57
58
  export * from './numbers/Progress.js';
59
+ export * from './numbers/Spinner.js';
58
60
  export * from './numbers/Track.js';
59
61
  export * from './numbers/Band.js';
60
62
  export * from './numbers/DotGrid.js';
package/dist/index.js CHANGED
@@ -21,6 +21,7 @@ export * from './scaffold/Page.js';
21
21
  export * from './scaffold/EmptyState.js';
22
22
  export * from './scaffold/Centre.js';
23
23
  export * from './scaffold/slideScreen.js';
24
+ export * from './scaffold/LayerHistory.js';
24
25
  export * from './cards/Card.js';
25
26
  export * from './cards/CardHead.js';
26
27
  export * from './cards/CardFoot.js';
@@ -59,6 +60,7 @@ export * from './choice/Mood.js';
59
60
  export * from './choice/Slider.js';
60
61
  export * from './numbers/Ring.js';
61
62
  export * from './numbers/Progress.js';
63
+ export * from './numbers/Spinner.js';
62
64
  export * from './numbers/Track.js';
63
65
  export * from './numbers/Band.js';
64
66
  export * from './numbers/DotGrid.js';
@@ -45,5 +45,7 @@ export interface RowsProps extends ComponentProps<'div'> {
45
45
  * Words = the small label; an ELEMENT (`<Text level="title" as="h3">`) is used as the heading itself — the group is labelled by it. */
46
46
  heading?: ReactNode;
47
47
  }
48
- /** Rows — a list of Rows separated by hairlines; `heading` names the group (a `role="group"` labelled by it). */
49
- export declare function Rows({ heading, className, children, ...rest }: RowsProps): import("react").JSX.Element;
48
+ /** Rows — a list of Rows separated by hairlines; `heading` names the group (a `role="group"` labelled by it).
49
+ * REORDERED (the app renders the same keyed rows in another order): each row SLIDES to its new place — the one going up passes
50
+ * over the others — instead of jumping (Lh 2026-10-01). The order is the app's; the move is the list's. */
51
+ export declare function Rows({ heading, className, children, ref, ...rest }: RowsProps): import("react").JSX.Element;
package/dist/lists/Row.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { cloneElement, createElement, isValidElement, useId } from 'react';
2
+ import { cloneElement, createElement, isValidElement, useId, useLayoutEffect, useRef } from 'react';
3
3
  import { cx } from '../cx.js';
4
4
  import { Icon } from '../type/Icon.js';
5
- import { useDisclosure } from '../state.js';
5
+ import { reducedMotion, useDisclosure } from '../state.js';
6
6
  /**
7
7
  * Row — a frameless row inside a card: lead · title + meta · end. 48px+ tall, a tap target.
8
8
  * Several go in Rows (hairlines between). A row that IS a card is `Card row`.
@@ -18,9 +18,50 @@ export function Row({ lead, leadIcon, title, meta, body, end, endValue, endCapti
18
18
  return row;
19
19
  return (_jsxs("details", { className: "row-expand", ...rest, ...disclosure, children: [row, _jsx("div", { className: "row-expand__more", children: more })] }));
20
20
  }
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 }) {
21
+ /** Rows — a list of Rows separated by hairlines; `heading` names the group (a `role="group"` labelled by it).
22
+ * REORDERED (the app renders the same keyed rows in another order): each row SLIDES to its new place — the one going up passes
23
+ * over the others — instead of jumping (Lh 2026-10-01). The order is the app's; the move is the list's. */
24
+ export function Rows({ heading, className, children, ref, ...rest }) {
23
25
  const id = useId();
24
26
  const named = heading != null;
25
- return (_jsxs("div", { className: cx('rows', className), role: named ? 'group' : undefined, "aria-labelledby": named ? id : undefined, ...rest, children: [named && (isValidElement(heading) ? cloneElement(heading, { id }) : _jsx("span", { className: "rows__head", id: id, children: heading })), children] }));
27
+ const own = useRef(null);
28
+ const was = useRef(new Map());
29
+ const order = useRef([]);
30
+ useLayoutEffect(() => {
31
+ const box = own.current;
32
+ if (!box)
33
+ return;
34
+ const rows = [...box.children].filter((el) => el.id !== id);
35
+ rows.forEach((el) => el.getAnimations().forEach((a) => { if (a.id === 'rows-move')
36
+ a.cancel(); })); // read the resting places
37
+ const at = new Map(rows.map((el) => [el, el.offsetTop]));
38
+ const before = order.current, moved = before.length === rows.length && rows.some((el, i) => el !== before[i]) && rows.every((el) => was.current.has(el));
39
+ if (moved && !reducedMotion()) {
40
+ const ms = (parseFloat(getComputedStyle(box).getPropertyValue('--motion-sheet')) || 0.35) * 1000;
41
+ for (const el of rows) {
42
+ const dy = (was.current.get(el) ?? 0) - (at.get(el) ?? 0);
43
+ if (!dy)
44
+ continue;
45
+ const up = dy > 0; // it goes UP: over the others, on the card's ground so they pass under it
46
+ const a = el.animate([{ translate: `0 ${dy}px` }, { translate: '0 0' }], { duration: ms, easing: 'cubic-bezier(0.2, 0.8, 0.2, 1)' });
47
+ a.id = 'rows-move';
48
+ if (up) {
49
+ el.style.position = 'relative';
50
+ el.style.zIndex = '1';
51
+ const clear = () => { el.style.position = ''; el.style.zIndex = ''; };
52
+ a.finished.then(clear, clear);
53
+ }
54
+ }
55
+ }
56
+ was.current = at;
57
+ order.current = rows;
58
+ });
59
+ const setRef = (node) => {
60
+ own.current = node;
61
+ if (typeof ref === 'function')
62
+ ref(node);
63
+ else if (ref)
64
+ ref.current = node;
65
+ };
66
+ return (_jsxs("div", { ref: setRef, className: cx('rows', className), role: named ? 'group' : undefined, "aria-labelledby": named ? id : undefined, ...rest, children: [named && (isValidElement(heading) ? cloneElement(heading, { id }) : _jsx("span", { className: "rows__head", id: id, children: heading })), children] }));
26
67
  }
@@ -0,0 +1,13 @@
1
+ import type { ComponentProps, ReactNode } from 'react';
2
+ export interface SpinnerProps extends Omit<ComponentProps<'span'>, 'children'> {
3
+ /** the ring's size, an Icon's: xs = in a caption or a row's meta · md (default) · lg = an EmptyState's `icon` */
4
+ size?: 'xs' | 'sm' | 'md' | 'lg';
5
+ /** what is being waited for, READ OUT (not shown) — give it when there are no words beside the ring */
6
+ label?: string;
7
+ /** words shown beside the ring ("กำลังโหลด…"): the line says it, the ring only turns */
8
+ children?: ReactNode;
9
+ }
10
+ /** Spinner — "working on it": a ring that turns, alone or with a line of words beside it. For a wait of unknown length — a known
11
+ * share is a `Progress`. `role="status"`: the words (or `label`) are read out when it appears. In a row's `end`, a card, a
12
+ * sheet's body, an `EmptyState`'s `icon`; a whole SCREEN that is not known yet shows nothing instead (`Screen ready`). */
13
+ export declare function Spinner({ size, label, className, children, ...rest }: SpinnerProps): import("react").JSX.Element;
@@ -0,0 +1,9 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { cx } from '../cx.js';
3
+ import { Icon } from '../type/Icon.js';
4
+ /** Spinner — "working on it": a ring that turns, alone or with a line of words beside it. For a wait of unknown length — a known
5
+ * share is a `Progress`. `role="status"`: the words (or `label`) are read out when it appears. In a row's `end`, a card, a
6
+ * sheet's body, an `EmptyState`'s `icon`; a whole SCREEN that is not known yet shows nothing instead (`Screen ready`). */
7
+ export function Spinner({ size = 'md', label, className, children, ...rest }) {
8
+ return (_jsxs("span", { className: cx('spinner', className), role: "status", "aria-label": children == null ? label : undefined, ...rest, children: [_jsx(Icon, { name: "loader-circle", size: size }), children] }));
9
+ }
@@ -0,0 +1,53 @@
1
+ import { type ReactNode } from 'react';
2
+ export type LayerValue = string | number;
3
+ type EntryState = Record<string, unknown>;
4
+ export interface LayerHistoryProps {
5
+ /** the CURRENT history entry's state, as a value of this render — the hook re-reads it on every back / forward / push
6
+ * (react-router: `useLocation().state`) */
7
+ state: unknown;
8
+ /** a NEW entry for the same page with this state: the path kept, the query dropped (react-router:
9
+ * `navigate({ pathname }, { state })`) */
10
+ push: (state: EntryState) => void;
11
+ /** the SAME entry, its state replaced: the path kept, the query dropped (`navigate({ pathname }, { replace: true, state })`) */
12
+ replace: (state: EntryState) => void;
13
+ /** back by this many entries (`navigate(-entries)`) */
14
+ back: (entries: number) => void;
15
+ children?: ReactNode;
16
+ }
17
+ /**
18
+ * LayerHistory — the project's router, handed to cardds ONCE, around the app's screens (inside the router): the entry's `state`
19
+ * and the three moves `push` · `replace` · `back`. `useLayer` / `useLayers` stand on it. cardds never touches the History API
20
+ * itself — a router would not see it.
21
+ */
22
+ export declare function LayerHistory({ state, push, replace, back, children }: LayerHistoryProps): import("react").JSX.Element;
23
+ export interface LayerApi {
24
+ /** what the layer shows while it is up (which way's sheet, which question); `null` = not up */
25
+ value: LayerValue | null;
26
+ /** UP: one entry of its own. Already up → the new value on the entry it has, never a second entry (one layer, one back) —
27
+ * so `open` called from INSIDE the layer (a step that turns this sheet into another way's) is a replace, not a push. */
28
+ open: (value: LayerValue) => void;
29
+ /** the PART asked to close (a handle to away, `onDismiss`, a button): back one entry when the app put one there — else (it
30
+ * came with the page) the entry it is on, without it. Nothing when it is not up: the back button already took it. */
31
+ close: () => void;
32
+ /** it CAME WITH THE PAGE (a link straight to an open sheet, `?way=`): read the address once, call this — no entry is added,
33
+ * and the query is dropped. Closing it then stays on the page. */
34
+ arrive: (value: LayerValue) => void;
35
+ }
36
+ /**
37
+ * useLayer — ONE layer of this screen, by its name: a whole bottom sheet, a modal. Back takes it off; the screen stays.
38
+ * `const way = useLayer('way')` → `<CallSheet open={way.value} onOpenChange={(up, i) => (up ? way.open(i!) : way.close())}>`.
39
+ * A Modal: `open={ask.value !== null}` + the app's own `mounted` flag cleared in `onClosed`. Opened again a layer starts anew —
40
+ * give what is inside a new `key` per opening.
41
+ */
42
+ export declare function useLayer(name: string): LayerApi;
43
+ /**
44
+ * useLayers — every layer over this screen: `up` (how many), and `closeAll(then)` — take them ALL off, then run `then`: before a
45
+ * move that must leave nothing of this page's layers under it (signed out → replace with the sign-in page). The entries are
46
+ * counted when the move is MADE, not when it was asked (the member may press back while a call runs), and `then` runs once the
47
+ * history shows them gone.
48
+ */
49
+ export declare function useLayers(): {
50
+ up: number;
51
+ closeAll: (then: () => void) => void;
52
+ };
53
+ export {};
@@ -0,0 +1,94 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { createContext, useContext, useEffect, useMemo, useRef } from 'react';
3
+ import { warnOnce } from '../state.js';
4
+ const Ctx = createContext(null);
5
+ /**
6
+ * LayerHistory — the project's router, handed to cardds ONCE, around the app's screens (inside the router): the entry's `state`
7
+ * and the three moves `push` · `replace` · `back`. `useLayer` / `useLayers` stand on it. cardds never touches the History API
8
+ * itself — a router would not see it.
9
+ */
10
+ export function LayerHistory({ state, push, replace, back, children }) {
11
+ const value = useMemo(() => ({ state, push, replace, back }), [state, push, replace, back]);
12
+ return _jsx(Ctx.Provider, { value: value, children: children });
13
+ }
14
+ /* the state is untyped — an old entry, another library's keys: read only what has the shape, keep the rest as it is */
15
+ const read = (state) => {
16
+ if (typeof state !== 'object' || state === null || Array.isArray(state))
17
+ return { rest: {}, layers: {} };
18
+ const { layers: raw, ...rest } = state;
19
+ const layers = {};
20
+ if (typeof raw === 'object' && raw !== null) {
21
+ for (const [name, l] of Object.entries(raw)) {
22
+ const v = l?.value;
23
+ if (typeof v === 'string' || typeof v === 'number')
24
+ layers[name] = { value: v, pushed: l.pushed === true };
25
+ }
26
+ }
27
+ return { rest, layers };
28
+ };
29
+ const NONE = { state: null, push: () => undefined, replace: () => undefined, back: () => undefined };
30
+ const useHistory = () => {
31
+ const h = useContext(Ctx);
32
+ if (!h)
33
+ warnOnce('layer-history', 'cardds useLayer: no <LayerHistory> above — hand the router over once, around the screens; until then a layer never comes up.');
34
+ return h ?? NONE;
35
+ };
36
+ /**
37
+ * useLayer — ONE layer of this screen, by its name: a whole bottom sheet, a modal. Back takes it off; the screen stays.
38
+ * `const way = useLayer('way')` → `<CallSheet open={way.value} onOpenChange={(up, i) => (up ? way.open(i!) : way.close())}>`.
39
+ * A Modal: `open={ask.value !== null}` + the app's own `mounted` flag cleared in `onClosed`. Opened again a layer starts anew —
40
+ * give what is inside a new `key` per opening.
41
+ */
42
+ export function useLayer(name) {
43
+ const { state, push, replace, back } = useHistory();
44
+ return useMemo(() => {
45
+ const { rest, layers } = read(state);
46
+ const layer = layers[name] ?? null;
47
+ const others = () => Object.fromEntries(Object.entries(layers).filter(([key]) => key !== name));
48
+ return {
49
+ value: layer?.value ?? null,
50
+ open: (to) => (layer ? replace({ ...rest, layers: { ...layers, [name]: { value: to, pushed: layer.pushed } } }) : push({ ...rest, layers: { ...layers, [name]: { value: to, pushed: true } } })),
51
+ // already up on an entry the app pushed (the link opened where the layer was): it stays a pushed one, or the close would leave that entry behind
52
+ arrive: (as) => replace({ ...rest, layers: { ...layers, [name]: { value: as, pushed: layer?.pushed ?? false } } }),
53
+ close: () => {
54
+ if (!layer)
55
+ return;
56
+ if (layer.pushed)
57
+ back(1);
58
+ else
59
+ replace({ ...rest, layers: others() });
60
+ },
61
+ };
62
+ }, [state, push, replace, back, name]);
63
+ }
64
+ /**
65
+ * useLayers — every layer over this screen: `up` (how many), and `closeAll(then)` — take them ALL off, then run `then`: before a
66
+ * move that must leave nothing of this page's layers under it (signed out → replace with the sign-in page). The entries are
67
+ * counted when the move is MADE, not when it was asked (the member may press back while a call runs), and `then` runs once the
68
+ * history shows them gone.
69
+ */
70
+ export function useLayers() {
71
+ const { state, back } = useHistory();
72
+ const { layers } = read(state);
73
+ const up = Object.keys(layers).length;
74
+ const pushed = Object.values(layers).filter((l) => l.pushed).length;
75
+ const now = useRef(pushed);
76
+ const after = useRef(null);
77
+ const goBack = useRef(back);
78
+ goBack.current = back;
79
+ useEffect(() => {
80
+ now.current = pushed;
81
+ if (pushed > 0 || !after.current)
82
+ return;
83
+ const then = after.current;
84
+ after.current = null;
85
+ then();
86
+ }, [pushed, up]);
87
+ const closeAll = useRef((then) => {
88
+ if (now.current === 0)
89
+ return then();
90
+ after.current = then;
91
+ goBack.current(now.current);
92
+ }).current;
93
+ return { up, closeAll };
94
+ }
@@ -2,20 +2,23 @@
2
2
  old one out to the left, the new one in from the right; `back`: the reverse; `--motion-slide`, card.css). It is a view
3
3
  transition named for the move only: the leaving `.screen` is `screen-out`, the arriving one `screen-in`. */
4
4
  const screenIn = (scope) => scope.querySelector('.screen:not(.screen .screen)');
5
- /** wait until the screen in `scope` CHANGED — another element, or new content in the same one (React may keep the element when
6
- both routes render a Screen in the same place) — a route rendered, sync or not; at most `ms`. A new Screen that is not
7
- `ready` yet (`data-pending`) is waited for too, at most `hold`: the old screen stays until the new one knows what it is. */
5
+ /** wait until the screen in `scope` CHANGED — another element, or the same one with new children (React may keep the element
6
+ when both routes render a Screen in the same place) — a route rendered, sync or not; at most `ms`. Any OTHER mutation is not
7
+ the route (a router renders a tick later: a popover closing meanwhile must not end the wait — timebank, 2026-10-01). A new
8
+ Screen that is not `ready` yet (`data-pending`) is waited for too, at most `hold`: the old screen stays until the new one
9
+ knows what it is. */
8
10
  function newScreen(scope, old, ms = 800, hold = 3000) {
9
11
  return new Promise((done) => {
10
- const pending = (el) => !!el?.hasAttribute('data-pending');
11
- const now = screenIn(scope);
12
- if (now !== old && !pending(now))
13
- return done(now);
14
12
  const root = scope instanceof Document ? scope.documentElement : scope;
13
+ let changed = false, held = false;
15
14
  const end = () => { mo.disconnect(); clearTimeout(t); done(screenIn(scope)); };
16
- let held = false;
17
- const look = () => {
18
- if (!pending(screenIn(scope)))
15
+ const look = (records = []) => {
16
+ const now = screenIn(scope);
17
+ if (now !== old || records.some((r) => r.target === old))
18
+ changed = true; // its own children / its own data-pending
19
+ if (!changed)
20
+ return;
21
+ if (!now?.hasAttribute('data-pending'))
19
22
  return end();
20
23
  if (!held) {
21
24
  held = true;
@@ -26,8 +29,7 @@ function newScreen(scope, old, ms = 800, hold = 3000) {
26
29
  const mo = new MutationObserver(look);
27
30
  mo.observe(root, { childList: true, subtree: true, attributes: true, attributeFilter: ['data-pending'] });
28
31
  let t = setTimeout(end, ms);
29
- if (pending(now))
30
- look();
32
+ look();
31
33
  });
32
34
  }
33
35
  let running = false;
@@ -173,6 +173,26 @@ export function Sheet({ state, defaultState = 'half', onStateChange, onSettled,
173
173
  const stop = watchSize([head], publish);
174
174
  return () => { stop(); box.style.removeProperty('--sheet-peek'); };
175
175
  }, []);
176
+ /* the FOOT's room follows what the foot really holds (timebank, Lh 2026-10-01): a button grows with the member's text size, a
177
+ line may stand over it (an error) — the CSS strip was a fixed sum (--btn-h), so the body's words ran under the button. The
178
+ sheet measures its foot's rows and publishes --sheet-foot-h; the strip, its ground and the body's stop all read it. */
179
+ useEffect(() => {
180
+ const el = ref.current, foot = el?.querySelector(':scope > .card__foot');
181
+ if (!el || !foot) {
182
+ el?.style.removeProperty('--sheet-foot-h');
183
+ return;
184
+ }
185
+ const publish = () => {
186
+ const rows = [...foot.children].map((c) => c.getBoundingClientRect()).filter((r) => r.height > 0);
187
+ if (!rows.length)
188
+ return el.style.removeProperty('--sheet-foot-h');
189
+ el.style.setProperty('--sheet-foot-h', `${Math.max(...rows.map((r) => r.bottom)) - Math.min(...rows.map((r) => r.top))}px`);
190
+ };
191
+ const stop = watchSize([...foot.children], publish);
192
+ const mo = new MutationObserver(publish);
193
+ mo.observe(foot, { childList: true });
194
+ return () => { stop(); mo.disconnect(); };
195
+ }); // every render: the foot's rows are the app's, they come and go
176
196
  const focus = (e) => {
177
197
  onFocus?.(e);
178
198
  if (current !== 'peek' || e.target.closest('.sheet__handle'))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usableapp/cardds",
3
- "version": "0.7.36",
3
+ "version": "0.7.38",
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.",