@usableapp/cardds 0.7.36 → 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,16 @@
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
+
5
15
  ## 0.7.36 — 2026-10-01
6
16
 
7
17
  ### 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
 
@@ -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
+ }
@@ -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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usableapp/cardds",
3
- "version": "0.7.36",
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.",