@usableapp/cardds 0.7.35 → 0.7.37

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,21 @@
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.37 — 2026-10-01
6
+
7
+ ### Fixed
8
+ - **`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.
9
+
10
+ ### Added
11
+ - **`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.
12
+ - **`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).
13
+ - **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.
14
+
15
+ ## 0.7.36 — 2026-10-01
16
+
17
+ ### Added
18
+ - **`Screen ready` — one outcome, drawn once** (Lh 2026-10-01). A screen that can come out more than one way (signed in or not, a list or its empty state, a redirect) showed one outcome and then turned into another. Now: `<Screen ready={known}>` — `false` while the app is still finding out. Nothing of the screen is drawn (no top, content, bottom; `data-pending`, `aria-busy`), and when it is reached through `slideScreen` or the back button (`installScreenSlide`) the screen BEFORE stays until it is ready — at most 3 s — then the right one slides in. Default `true`: nothing changes until a screen sets it. Every template passes it through (`Home`, `CallSheet`, `Walkthrough`). To do in an app: every screen that decides after its first paint (a check in an effect, a redirect) gets `ready`; on the first load hold `Splash done` until the first screen is known.
19
+
5
20
  ## 0.7.35 — 2026-10-01
6
21
 
7
22
  ### Fixed
package/README.md CHANGED
@@ -355,6 +355,26 @@ between at 40% down the screen (see *Anatomy*, "Where the centre is"):
355
355
  one height, the round pager turns them (`slideTo()`), the words slide like a page. No sheet there.
356
356
  ONE SCREEN TO THE NEXT (a route change) slides the WHOLE screen the same way: `slideScreen(() => navigate(to), 'next' | 'back')`,
357
357
  and `installScreenSlide()` once at app start for the back button (story `Step · WalksOn`). `data-slide="off"` on a Screen = no slide.
358
+ ONE OUTCOME, DRAWN ONCE (Lh 2026-10-01): a screen that can come out more than one way (signed in or not, a list or
359
+ its empty state, a redirect) is NOT drawn until the app knows which — `<Screen ready={known}>`. Not ready = nothing of it
360
+ is drawn; arrived at through `slideScreen` / the back button, the screen before stays until it is ready (at most 3 s), then
361
+ the right one slides in. Never one outcome first and another after (story `Step · ArrivesReady`). On a first load there is
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`.
358
378
  - *Sheets the app calls:* the base offers WAYS IN — sign in · new phone · join — as buttons (`.cta-pack`); each
359
379
  way has a sheet that holds its WHOLE form and its own submit on its foot, starting `.sheet--away` (not on the
360
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
 
@@ -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
@@ -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
 
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';
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';
@@ -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,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
+ }
@@ -11,15 +11,21 @@ export interface ScreenProps extends ComponentProps<'div'> {
11
11
  /** BOTTOM: an `ActionBar` (or a `FloatBar`) — a LAYER over the content's foot, as tall as its rows need.
12
12
  * Never with a `Sheet` in the content: a sheet finishes on its own foot (warns once). */
13
13
  bottom?: ReactNode;
14
+ /** `false` while the app does not yet know WHICH screen this is (signed in or not, a member with or without a request…):
15
+ * nothing of it is drawn — no top, content or bottom — so a screen never shows one outcome and then turns into another.
16
+ * Arrived at through `slideScreen`, the screen before stays until this turns true (at most 3 s). Default true. */
17
+ ready?: boolean;
14
18
  }
15
19
  /**
16
20
  * Screen — the mobile screen in THREE parts: **top** and **content** (the children) in one flex column, the content down to the
17
21
  * screen's foot; **bottom** a LAYER over it there (Lh 2026-09-23). Top = `TopBar` / `AppBar`, or `top="lead"` (no bar, the words start
18
22
  * 15% down). Content = a box of components one after another — a `Step` (`Pages` of `Page`s / a `Page` [+ `Sheet`]), a `Centre`, a
19
- * `SheetStack`… Bottom = an `ActionBar` (the walk's `PagerAt` its first row) or a `FloatBar`, covering the content's foot on purpose. Max 25rem wide, the standard inset, no vertical padding. A plain Screen (no `fill`) scrolls.
23
+ * `SheetStack`… Bottom = an `ActionBar` (the walk's `PagerAt` its first row) or a `FloatBar`, covering the content's foot on purpose. Max 30rem wide, the standard inset, no vertical padding. A plain Screen (no `fill`) scrolls.
24
+ * ONE OUTCOME, DRAWN ONCE (Lh 2026-10-01): a screen that can come out more than one way is not drawn until the app knows which —
25
+ * `ready={false}` until then.
20
26
  * The slots set the order; children laid by hand in the same order still work.
21
27
  */
22
- export declare function Screen({ fill, palette, as, top, bottom, className, children, ref, ...rest }: ScreenProps): import("react").DetailedReactHTMLElement<{
28
+ export declare function Screen({ fill, palette, as, top, bottom, ready, className, children, ref, ...rest }: ScreenProps): import("react").DetailedReactHTMLElement<{
23
29
  key?: import("react").Key | null | undefined;
24
30
  defaultChecked?: boolean | undefined;
25
31
  defaultValue?: string | number | readonly string[] | undefined;
@@ -80,7 +86,7 @@ export declare function Screen({ fill, palette, as, top, bottom, className, chil
80
86
  "aria-autocomplete"?: "none" | "inline" | "list" | "both" | undefined;
81
87
  "aria-braillelabel"?: string | undefined;
82
88
  "aria-brailleroledescription"?: string | undefined;
83
- "aria-busy"?: (boolean | "true" | "false") | undefined;
89
+ "aria-busy": (boolean | "true" | "false") | undefined;
84
90
  "aria-checked"?: boolean | "false" | "mixed" | "true" | undefined;
85
91
  "aria-colcount"?: number | undefined;
86
92
  "aria-colindex"?: number | undefined;
@@ -301,5 +307,6 @@ export declare function Screen({ fill, palette, as, top, bottom, className, chil
301
307
  onTransitionStartCapture?: import("react").TransitionEventHandler<HTMLDivElement> | undefined;
302
308
  className: string;
303
309
  'data-palette': Palette | undefined;
310
+ 'data-pending': string | undefined;
304
311
  ref: Ref<HTMLDivElement>;
305
312
  }, HTMLDivElement>;
@@ -5,10 +5,12 @@ import { cx } from '../cx.js';
5
5
  * Screen — the mobile screen in THREE parts: **top** and **content** (the children) in one flex column, the content down to the
6
6
  * screen's foot; **bottom** a LAYER over it there (Lh 2026-09-23). Top = `TopBar` / `AppBar`, or `top="lead"` (no bar, the words start
7
7
  * 15% down). Content = a box of components one after another — a `Step` (`Pages` of `Page`s / a `Page` [+ `Sheet`]), a `Centre`, a
8
- * `SheetStack`… Bottom = an `ActionBar` (the walk's `PagerAt` its first row) or a `FloatBar`, covering the content's foot on purpose. Max 25rem wide, the standard inset, no vertical padding. A plain Screen (no `fill`) scrolls.
8
+ * `SheetStack`… Bottom = an `ActionBar` (the walk's `PagerAt` its first row) or a `FloatBar`, covering the content's foot on purpose. Max 30rem wide, the standard inset, no vertical padding. A plain Screen (no `fill`) scrolls.
9
+ * ONE OUTCOME, DRAWN ONCE (Lh 2026-10-01): a screen that can come out more than one way is not drawn until the app knows which —
10
+ * `ready={false}` until then.
9
11
  * The slots set the order; children laid by hand in the same order still work.
10
12
  */
11
- export function Screen({ fill, palette, as = 'div', top, bottom, className, children, ref, ...rest }) {
13
+ export function Screen({ fill, palette, as = 'div', top, bottom, ready = true, className, children, ref, ...rest }) {
12
14
  const own = useRef(null);
13
15
  useEffect(() => {
14
16
  const el = own.current;
@@ -25,7 +27,7 @@ export function Screen({ fill, palette, as = 'div', top, bottom, className, chil
25
27
  return;
26
28
  }
27
29
  return watchSize([bar], () => el.style.setProperty('--bottom-h', `${bar.offsetHeight}px`));
28
- }, [bottom]);
30
+ }, [bottom, ready]);
29
31
  const setRef = (node) => {
30
32
  own.current = node;
31
33
  if (typeof ref === 'function')
@@ -33,5 +35,5 @@ export function Screen({ fill, palette, as = 'div', top, bottom, className, chil
33
35
  else if (ref)
34
36
  ref.current = node;
35
37
  };
36
- return createElement(as, { className: cx('screen', fill && 'screen--fill', top === 'lead' && 'screen--lead', className), 'data-palette': palette, ref: setRef, ...rest }, top !== 'lead' ? top : null, children, bottom);
38
+ return createElement(as, { className: cx('screen', fill && 'screen--fill', top === 'lead' && 'screen--lead', className), 'data-palette': palette, 'data-pending': ready ? undefined : '', 'aria-busy': ready ? undefined : true, ref: setRef, ...rest }, ready && top !== 'lead' ? top : null, ready ? children : null, ready ? bottom : null);
37
39
  }
@@ -3,6 +3,7 @@ import type { SlideDirection } from './Centre.js';
3
3
  * slideScreen — go to the NEXT SCREEN (a route change) with the whole screen sliding, as a walk's pages do. Wrap the app's
4
4
  * navigate in it: `slideScreen(() => navigate('/download'))` — `back` for a way back: `slideScreen(() => navigate(-1), 'back')`.
5
5
  * The update may render at once or later (a route's chunk loading): the slide waits for the new Screen (at most 0.8 s).
6
+ * A new Screen that is not `ready` is waited for (at most 3 s) — the old screen stays until then.
6
7
  * A Screen with `data-slide="off"` (or none at all) is not slid. `scope`: where the one Screen is (default the document — an
7
8
  * app has one). Without view transitions (the browser floor) it just runs the update.
8
9
  */
@@ -2,17 +2,34 @@
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` */
7
- function newScreen(scope, old, ms = 800) {
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. */
10
+ function newScreen(scope, old, ms = 800, hold = 3000) {
8
11
  return new Promise((done) => {
9
- const now = screenIn(scope);
10
- if (now !== old)
11
- return done(now);
12
12
  const root = scope instanceof Document ? scope.documentElement : scope;
13
- const mo = new MutationObserver(() => { mo.disconnect(); clearTimeout(t); done(screenIn(scope)); });
14
- mo.observe(root, { childList: true, subtree: true });
15
- const t = setTimeout(() => { mo.disconnect(); done(screenIn(scope)); }, ms);
13
+ let changed = false, held = false;
14
+ const end = () => { mo.disconnect(); clearTimeout(t); done(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'))
22
+ return end();
23
+ if (!held) {
24
+ held = true;
25
+ clearTimeout(t);
26
+ t = setTimeout(end, hold);
27
+ }
28
+ };
29
+ const mo = new MutationObserver(look);
30
+ mo.observe(root, { childList: true, subtree: true, attributes: true, attributeFilter: ['data-pending'] });
31
+ let t = setTimeout(end, ms);
32
+ look();
16
33
  });
17
34
  }
18
35
  let running = false;
@@ -20,6 +37,7 @@ let running = false;
20
37
  * slideScreen — go to the NEXT SCREEN (a route change) with the whole screen sliding, as a walk's pages do. Wrap the app's
21
38
  * navigate in it: `slideScreen(() => navigate('/download'))` — `back` for a way back: `slideScreen(() => navigate(-1), 'back')`.
22
39
  * The update may render at once or later (a route's chunk loading): the slide waits for the new Screen (at most 0.8 s).
40
+ * A new Screen that is not `ready` is waited for (at most 3 s) — the old screen stays until then.
23
41
  * A Screen with `data-slide="off"` (or none at all) is not slid. `scope`: where the one Screen is (default the document — an
24
42
  * app has one). Without view transitions (the browser floor) it just runs the update.
25
43
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usableapp/cardds",
3
- "version": "0.7.35",
3
+ "version": "0.7.37",
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.",