@usableapp/cardds 0.7.15 → 0.7.16

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.
Files changed (53) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +6 -8
  3. package/css/actions.css +24 -1
  4. package/css/base.css +2 -2
  5. package/css/card.css +7 -13
  6. package/css/choice.css +2 -2
  7. package/css/journey.css +3 -3
  8. package/css/layover.css +5 -17
  9. package/css/media.css +4 -5
  10. package/css/numbers.css +4 -3
  11. package/css/people.css +3 -2
  12. package/css/sheet.css +4 -6
  13. package/css/stack.css +6 -10
  14. package/css/step.css +3 -5
  15. package/css/theme.template.css +2 -1
  16. package/css/tokens.css +4 -1
  17. package/dist/actions/Dropdown.js +4 -5
  18. package/dist/cardds.css +71 -70
  19. package/dist/cards/Card.d.ts +1 -1
  20. package/dist/choice/Calendar.js +3 -5
  21. package/dist/choice/Mood.d.ts +10 -5
  22. package/dist/choice/Mood.js +4 -2
  23. package/dist/forms/Pin.js +7 -8
  24. package/dist/index.d.ts +1 -1
  25. package/dist/index.js +1 -1
  26. package/dist/lists/Row.d.ts +2 -1
  27. package/dist/lists/Row.js +2 -2
  28. package/dist/media/Postcard.js +3 -5
  29. package/dist/numbers/Picker.js +4 -5
  30. package/dist/numbers/Track.d.ts +4 -2
  31. package/dist/numbers/Track.js +3 -3
  32. package/dist/people/AvatarPick.d.ts +3 -1
  33. package/dist/people/AvatarPick.js +2 -2
  34. package/dist/scaffold/Centre.d.ts +1 -1
  35. package/dist/scaffold/Centre.js +1 -1
  36. package/dist/scaffold/Screen.js +3 -4
  37. package/dist/scaffold/TopBar.js +3 -5
  38. package/dist/sheets/Sheet.js +5 -7
  39. package/dist/sheets/SheetStack.js +4 -5
  40. package/dist/sheets/SheetSteps.js +5 -6
  41. package/dist/sheets/StackSheet.js +4 -5
  42. package/dist/state.d.ts +7 -0
  43. package/dist/state.js +17 -0
  44. package/dist/templates/CallSheet.js +3 -4
  45. package/dist/templates/Home.js +3 -4
  46. package/dist/templates/Splash.js +2 -1
  47. package/dist/templates/Walkthrough.js +4 -5
  48. package/dist/type/Icon.js +3 -5
  49. package/dist/type/textScale.d.ts +6 -0
  50. package/dist/type/textScale.js +12 -0
  51. package/package.json +1 -1
  52. package/dist/layover/Deck.d.ts +0 -9
  53. package/dist/layover/Deck.js +0 -10
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { createContext, useContext, useEffect, useLayoutEffect, useRef, useState } from 'react';
2
+ import { createContext, useContext, useEffect, useLayoutEffect, useRef } from 'react';
3
+ import { useControlled } from '../state.js';
3
4
  import { cx } from '../cx.js';
4
5
  const PickerContext = createContext(null);
5
6
  /**
@@ -9,16 +10,14 @@ const PickerContext = createContext(null);
9
10
  * settled in the centre. The CSS lights the centred item by scroll position; the app never reads the DOM to know what was picked.
10
11
  */
11
12
  export function Picker({ value, defaultValue, onChange, unit, label, className, children, ...rest }) {
12
- const [inner, setInner] = useState(defaultValue);
13
- const current = value ?? inner;
13
+ const [current, setOwn] = useControlled(value, defaultValue);
14
14
  const rail = useRef(null);
15
15
  const mounted = useRef(false);
16
16
  const settle = useRef(null);
17
17
  const pick = (next) => {
18
18
  if (next === current)
19
19
  return;
20
- if (value === undefined)
21
- setInner(next);
20
+ setOwn(next);
22
21
  onChange?.(next);
23
22
  };
24
23
  const items = () => [...(rail.current?.querySelectorAll(':scope > .picker__item') ?? [])];
@@ -7,6 +7,8 @@ export declare function Track({ label, className, ...rest }: TrackProps): import
7
7
  export interface TrackStepProps extends ComponentProps<'li'> {
8
8
  /** this step (and the connector into it) is done */
9
9
  done?: boolean;
10
+ /** the step's NUMBER in the round instead of an Icon (1, 2, 3…) — a walk whose steps are counted, not pictured */
11
+ num?: number;
10
12
  }
11
- /** TrackStep — one step of a Track: an Icon in a round; lit when done. */
12
- export declare function TrackStep({ done, className, ...rest }: TrackStepProps): import("react").JSX.Element;
13
+ /** TrackStep — one step of a Track: an Icon (children) or its number (`num`) in a round; lit when done. */
14
+ export declare function TrackStep({ done, num, className, children, ...rest }: TrackStepProps): import("react").JSX.Element;
@@ -4,7 +4,7 @@ import { cx } from '../cx.js';
4
4
  export function Track({ label = 'Progress', className, ...rest }) {
5
5
  return _jsx("ol", { className: cx('track', className), "aria-label": label, ...rest });
6
6
  }
7
- /** TrackStep — one step of a Track: an Icon in a round; lit when done. */
8
- export function TrackStep({ done, className, ...rest }) {
9
- return _jsx("li", { className: cx('track__step', done && 'is-done', className), ...rest });
7
+ /** TrackStep — one step of a Track: an Icon (children) or its number (`num`) in a round; lit when done. */
8
+ export function TrackStep({ done, num, className, children, ...rest }) {
9
+ return _jsx("li", { className: cx('track__step', done && 'is-done', className), ...rest, children: num != null ? _jsx("span", { className: "track__dot", children: num }) : children });
10
10
  }
@@ -13,6 +13,8 @@ export interface AvatarPickItemProps extends Omit<ComponentProps<'button'>, 'nam
13
13
  role?: ReactNode;
14
14
  /** the Avatar (lg) */
15
15
  children?: ReactNode;
16
+ /** this person is the one picked (aria-pressed; the app keeps which) */
17
+ selected?: boolean;
16
18
  }
17
19
  /** AvatarPickItem — one person in an AvatarPick: an `Avatar size="lg"`, a name, a role. */
18
- export declare function AvatarPickItem({ name, role, className, children, ...rest }: AvatarPickItemProps): import("react").JSX.Element;
20
+ export declare function AvatarPickItem({ name, role, selected, className, children, ...rest }: AvatarPickItemProps): import("react").JSX.Element;
@@ -5,6 +5,6 @@ export function AvatarPick({ bleed, label, className, ...rest }) {
5
5
  return _jsx("div", { className: cx('avatar-pick', bleed && 'bleed', className), "aria-label": label, ...rest });
6
6
  }
7
7
  /** AvatarPickItem — one person in an AvatarPick: an `Avatar size="lg"`, a name, a role. */
8
- export function AvatarPickItem({ name, role, className, children, ...rest }) {
9
- return (_jsxs("button", { type: "button", className: cx('avatar-pick__item', className), ...rest, children: [children, _jsx("span", { className: "avatar-pick__name", children: name }), role != null && _jsx("span", { className: "avatar-pick__role", children: role })] }));
8
+ export function AvatarPickItem({ name, role, selected, className, children, ...rest }) {
9
+ return (_jsxs("button", { type: "button", className: cx('avatar-pick__item', className), "aria-pressed": selected, ...rest, children: [children, _jsx("span", { className: "avatar-pick__name", children: name }), role != null && _jsx("span", { className: "avatar-pick__role", children: role })] }));
10
10
  }
@@ -3,7 +3,7 @@ export interface CentreProps extends ComponentProps<'div'> {
3
3
  }
4
4
  /**
5
5
  * Centre — the centred group: ONE thing to read at the human centre of the screen, when that thing is not a lone
6
- * card: an EmptyState; a Card with its quiet second Btn and the PagerAt under it; a Deck with its DeckActions.
6
+ * card: an EmptyState; a Card with its quiet second Btn and the PagerAt under it.
7
7
  * On a plain `Screen` (the usual case, Lh 2026-09-12) it is in flow: the column is one phone tall, the group takes
8
8
  * the room after the TopBar and centres a short card; a long card makes the page longer and scrolls up under the
9
9
  * bar — nothing is capped, nothing shrinks. On a `Screen fill` it floats at 40% down (`--screen-centre`), capped
@@ -3,7 +3,7 @@ import { flushSync } from 'react-dom';
3
3
  import { cx } from '../cx.js';
4
4
  /**
5
5
  * Centre — the centred group: ONE thing to read at the human centre of the screen, when that thing is not a lone
6
- * card: an EmptyState; a Card with its quiet second Btn and the PagerAt under it; a Deck with its DeckActions.
6
+ * card: an EmptyState; a Card with its quiet second Btn and the PagerAt under it.
7
7
  * On a plain `Screen` (the usual case, Lh 2026-09-12) it is in flow: the column is one phone tall, the group takes
8
8
  * the room after the TopBar and centres a short card; a long card makes the page longer and scrolls up under the
9
9
  * bar — nothing is capped, nothing shrinks. On a `Screen fill` it floats at 40% down (`--screen-centre`), capped
@@ -1,6 +1,6 @@
1
1
  import { createElement, useEffect, useRef } from 'react';
2
+ import { warnOnce } from '../state.js';
2
3
  import { cx } from '../cx.js';
3
- let warnedSheetBar = false;
4
4
  /**
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
@@ -12,10 +12,9 @@ export function Screen({ fill, palette, as = 'div', top, bottom, className, chil
12
12
  const own = useRef(null);
13
13
  useEffect(() => {
14
14
  const el = own.current;
15
- if (warnedSheetBar || !el || !el.querySelector(':scope > .action-bar') || !el.querySelector(':scope > .step > .sheet'))
15
+ if (!el || !el.querySelector(':scope > .action-bar') || !el.querySelector(':scope > .step > .sheet'))
16
16
  return;
17
- warnedSheetBar = true;
18
- console.warn('cardds Screen: a bottom bar on a screen with a Sheet — the sheet finishes on its own foot (CardFoot); drop the bar.');
17
+ warnOnce('screen-sheet-bar', 'cardds Screen: a bottom bar on a screen with a Sheet — the sheet finishes on its own foot (CardFoot); drop the bar.');
19
18
  });
20
19
  /* the BOTTOM layer's height, published on the screen as --bottom-h (Lh 2026-09-23): the walk's pages end with that much room
21
20
  more, so every last word scrolls up clear of the layer. Measured, because the layer is as tall as its rows and the text size. */
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useEffect, useRef } from 'react';
3
+ import { warnOnce } from '../state.js';
3
4
  import { cx } from '../cx.js';
4
5
  /**
5
6
  * TopBar — the screen's title bar: back · TITLE · actions. Same 60px floor as the AppBar.
@@ -9,12 +10,9 @@ export function TopBar({ title, back, actions, className, children, ...rest }) {
9
10
  const acts = useRef(null);
10
11
  useEffect(() => {
11
12
  const n = acts.current?.childElementCount ?? 0;
12
- if (n > MAX_ACTIONS && !warned) {
13
- warned = true;
14
- console.warn(`cardds TopBar: ${n} actions — at most ${MAX_ACTIONS} are drawn; put the rest in a Menu behind one button`);
15
- }
13
+ if (n > MAX_ACTIONS)
14
+ warnOnce('topbar', `cardds TopBar: ${n} actions — at most ${MAX_ACTIONS} are drawn; put the rest in a Menu behind one button`);
16
15
  });
17
16
  return (_jsxs("header", { className: cx('topbar', className), ...rest, children: [back, _jsx("h1", { className: "t-h1", children: title }), children, actions != null && _jsx("div", { className: "topbar__actions", ref: acts, children: actions })] }));
18
17
  }
19
18
  const MAX_ACTIONS = 2;
20
- let warned = false;
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useCallback, useEffect, useRef, useState } from 'react';
2
+ import { useCallback, useEffect, useRef } from 'react';
3
+ import { useControlled } from '../state.js';
3
4
  import { cx } from '../cx.js';
4
5
  const ORDER = ['peek', 'half', '3q', 'full']; // low → high; `away` is below them all and outside them
5
6
  const TAP_REM = 0.5; // = --sp-2: a move shorter than this is a tap, not a drag
@@ -16,9 +17,7 @@ const TAP_REM = 0.5; // = --sp-2: a move shorter than this is a tap, not a drag
16
17
  * climbs until the focused control has room above the foot.
17
18
  */
18
19
  export function Sheet({ state, defaultState = 'half', onStateChange, states, handle = true, className, children, onFocus, ...rest }) {
19
- const [inner, setInner] = useState(defaultState);
20
- const current = state ?? inner;
21
- const controlled = state !== undefined;
20
+ const [current, setOwn] = useControlled(state, defaultState);
22
21
  const ref = useRef(null);
23
22
  const handleRef = useRef(null);
24
23
  const home = useRef(current !== 'peek' && current !== 'away' ? current : 'half'); // where a tap from peek returns to
@@ -32,10 +31,9 @@ export function Sheet({ state, defaultState = 'half', onStateChange, states, han
32
31
  const request = useCallback((next, reason) => {
33
32
  if (next === current)
34
33
  return;
35
- if (!controlled)
36
- setInner(next);
34
+ setOwn(next);
37
35
  onStateChange?.(next, reason);
38
- }, [current, controlled, onStateChange]);
36
+ }, [current, setOwn, onStateChange]);
39
37
  useEffect(() => {
40
38
  if (current !== 'peek' && current !== 'away')
41
39
  home.current = current;
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useLayoutEffect, useRef, useState } from 'react';
2
+ import { useLayoutEffect, useRef } from 'react';
3
+ import { useControlled } from '../state.js';
3
4
  import { cx } from '../cx.js';
4
5
  const PHASE = 380; // ms before the before-pack un-tucks on close, so the after-pack is clear first
5
6
  /* the stack's sheets, in slot order: its direct children — or, where a host wraps each child in one box of its own, the first .card in
@@ -23,16 +24,14 @@ const sheetsOf = (stack) => [...stack.children]
23
24
  * before tuck under nearest-first; closing reverses in two beats. Only `translate` (stack.css); it sets classes, never a size.
24
25
  */
25
26
  export function SheetStack({ mode = 'tap', open, defaultOpen = null, onOpenChange, fanned, closed, base, className, children, onClick, ...rest }) {
26
- const [inner, setInner] = useState(defaultOpen);
27
- const current = open !== undefined ? open : inner;
27
+ const [current, setOwn] = useControlled(open, defaultOpen);
28
28
  const ref = useRef(null);
29
29
  const was = useRef(null);
30
30
  const untuck = useRef(null);
31
31
  const request = (next) => {
32
32
  if (next === current)
33
33
  return;
34
- if (open === undefined)
35
- setInner(next);
34
+ setOwn(next);
36
35
  onOpenChange?.(next);
37
36
  };
38
37
  /* paint the state onto the sheets — after every render, so a re-rendered Card never loses its place */
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Children, cloneElement, createContext, isValidElement, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
3
+ import { useControlled, reducedMotion } from '../state.js';
3
4
  import { cx } from '../cx.js';
4
5
  import { Icon } from '../type/Icon.js';
5
6
  import { Link } from '../actions/Link.js';
@@ -17,8 +18,7 @@ const StepCtx = createContext(null);
17
18
  * The sheet may be dragged down to peek and back — the body keeps where it was scrolled.
18
19
  */
19
20
  export function SheetSteps({ at, reached, defaultAt = 0, onAtChange, reversible = false, numbered = false, finish, className, style, children, onFocus, onBlur, onPointerDown, onPointerUp, onPointerCancel, ...rest }) {
20
- const [inner, setInner] = useState(defaultAt);
21
- const current = at ?? inner;
21
+ const [current, setOwn] = useControlled(at, defaultAt);
22
22
  const steps = Children.toArray(children).filter(isValidElement);
23
23
  const count = steps.length;
24
24
  /* how far the member has come: a step before it was DONE (its work, or skipped) — it stays done when they go back to look */
@@ -41,8 +41,7 @@ export function SheetSteps({ at, reached, defaultAt = 0, onAtChange, reversible
41
41
  const go = (i, reason) => {
42
42
  if (i === current || i < 0 || i > count)
43
43
  return;
44
- if (at === undefined)
45
- setInner(i);
44
+ setOwn(i);
46
45
  onAtChange?.(i, reason);
47
46
  };
48
47
  /* how far the body is scrolled when a step opens or a field in it takes the keyboard: the TWO latest done rows always show
@@ -72,7 +71,7 @@ export function SheetSteps({ at, reached, defaultAt = 0, onAtChange, reversible
72
71
  if (Math.abs(to - body.scrollTop) <= 1)
73
72
  return;
74
73
  // the pile SCROLLS there, animated (Lh 2026-09-24) — the browser's own smooth scroll; its frames are not replayed as jumps
75
- const smooth = !matchMedia('(prefers-reduced-motion: reduce)').matches;
74
+ const smooth = !reducedMotion();
76
75
  own.current = performance.now() + (smooth ? 700 : 0);
77
76
  body.scrollTo({ top: to, behavior: smooth ? 'smooth' : 'auto' });
78
77
  };
@@ -127,7 +126,7 @@ export function SheetSteps({ at, reached, defaultAt = 0, onAtChange, reversible
127
126
  const onScroll = () => {
128
127
  const d = body.scrollTop - last.current;
129
128
  last.current = body.scrollTop;
130
- if (!d || performance.now() > glide.current || performance.now() < own.current || matchMedia('(prefers-reduced-motion: reduce)').matches)
129
+ if (!d || performance.now() > glide.current || performance.now() < own.current || reducedMotion())
131
130
  return;
132
131
  const from = parseFloat(el.style.translate.split(' ')[1] || '0') || 0;
133
132
  el.style.transition = 'none';
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useEffect, useRef, useState } from 'react';
2
+ import { useEffect, useRef } from 'react';
3
+ import { useControlled } from '../state.js';
3
4
  import { cx } from '../cx.js';
4
5
  import { Card } from '../cards/Card.js';
5
6
  import { CardHead } from '../cards/CardHead.js';
@@ -16,10 +17,8 @@ import { Badge } from '../numbers/Badge.js';
16
17
  * is closed — see `Home`). A direct child of `SheetStack`.
17
18
  */
18
19
  export function StackSheet({ title, tone, icon, tabs, tab: tabProp, defaultTab, onTabChange, foot, backLabel = 'Back', className, children, ...rest }) {
19
- const [own, setOwn] = useState(defaultTab ?? tabs?.[0]?.id);
20
- const tab = tabProp ?? own;
21
- const pick = (id) => { if (tabProp === undefined)
22
- setOwn(id); onTabChange?.(id); };
20
+ const [tab, setOwn] = useControlled(tabProp, defaultTab ?? tabs?.[0]?.id);
21
+ const pick = (id) => { setOwn(id); onTabChange?.(id); };
23
22
  // a title longer than its room does not wrap: it runs sideways, like a song title in a short slot — the room it overflows by
24
23
  // is measured here (the tabs may grow with the text size) and handed to the CSS as --_over
25
24
  const titleRef = useRef(null);
@@ -0,0 +1,7 @@
1
+ /** controlled like an input: `value` given = the app owns it; undefined = the component keeps its own, starting at `initial`.
2
+ * Returns the value shown and a setter that only moves the component's own copy — the caller still ASKS the app (onXChange). */
3
+ export declare function useControlled<T>(value: T | undefined, initial: T): [T, (next: T) => void];
4
+ /** a dev hint said once per key, never again */
5
+ export declare const warnOnce: (key: string, text: string) => void;
6
+ /** the member asked for less motion (false where there is no window, e.g. a server render) */
7
+ export declare const reducedMotion: () => boolean;
package/dist/state.js ADDED
@@ -0,0 +1,17 @@
1
+ import { useState } from 'react';
2
+ /** controlled like an input: `value` given = the app owns it; undefined = the component keeps its own, starting at `initial`.
3
+ * Returns the value shown and a setter that only moves the component's own copy — the caller still ASKS the app (onXChange). */
4
+ export function useControlled(value, initial) {
5
+ const [own, setOwn] = useState(initial);
6
+ const controlled = value !== undefined;
7
+ return [controlled ? value : own, (next) => { if (!controlled)
8
+ setOwn(next); }];
9
+ }
10
+ const warned = new Set();
11
+ /** a dev hint said once per key, never again */
12
+ export const warnOnce = (key, text) => { if (!warned.has(key)) {
13
+ warned.add(key);
14
+ console.warn(text);
15
+ } };
16
+ /** the member asked for less motion (false where there is no window, e.g. a server render) */
17
+ export const reducedMotion = () => typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useState } from 'react';
3
+ import { useControlled } from '../state.js';
3
4
  import { Screen } from '../scaffold/Screen.js';
4
5
  import { Step } from '../sheets/Step.js';
5
6
  import { Page, CtaPack } from '../scaffold/Page.js';
@@ -18,11 +19,9 @@ const index = (o) => (o === true ? 0 : typeof o === 'number' ? o : null);
18
19
  */
19
20
  export function CallSheet({ media, heading, body, action, sheet, ways, open: openProp, defaultOpen = null, onOpenChange, states = 'peek full', children, ...rest }) {
20
21
  const list = ways ?? (sheet ? [{ action, sheet }] : []);
21
- const [own, setOwn] = useState(index(defaultOpen));
22
- const on = openProp === undefined ? own : index(openProp);
22
+ const [on, setOwn] = useControlled(openProp === undefined ? undefined : index(openProp), index(defaultOpen));
23
23
  const [at, setAt] = useState('full');
24
- const set = (next) => { if (openProp === undefined)
25
- setOwn(next); onOpenChange?.(next !== null, next); };
24
+ const set = (next) => { setOwn(next); onOpenChange?.(next !== null, next); };
26
25
  return (_jsxs(Screen, { fill: true, ...rest, children: [_jsxs(Step, { children: [_jsxs(Page, { children: [media, _jsx(CardHead, { level: "h1", heading: heading }), typeof body === 'string' ? _jsx(Text, { level: "body", children: body }) : body, _jsx(CtaPack, { children: list.map((w, i) => (_jsx(Btn, { primary: i === 0, quiet: i > 0, "aria-expanded": on === i, onClick: () => { setAt('full'); set(i); }, children: w.action }, i))) })] }), list.map(({ sheet: sh }, i) => (_jsxs(Sheet, { state: on === i ? at : 'away', onStateChange: (s) => { if (s === 'away')
27
26
  set(null);
28
27
  else
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useEffect, useState } from 'react';
3
+ import { useControlled } from '../state.js';
3
4
  import { Screen } from '../scaffold/Screen.js';
4
5
  import { SheetStack } from '../sheets/SheetStack.js';
5
6
  import { StackSheet } from '../sheets/StackSheet.js';
@@ -14,8 +15,7 @@ const LEAVE = 800; // ms: a closing sheet keeps its body until it has slid home
14
15
  * sections on one home never cost three pages (`keep` holds a body once opened).
15
16
  */
16
17
  export function Home({ top, title, sheets, open: openProp, defaultOpen = null, onOpenChange, base, keep, backLabel = 'Back', children, ...rest }) {
17
- const [own, setOwn] = useState(defaultOpen);
18
- const open = openProp !== undefined ? openProp : own;
18
+ const [open, setOwn] = useControlled(openProp, defaultOpen);
19
19
  const [leaving, setLeaving] = useState(null);
20
20
  const [seen, setSeen] = useState(() => new Set(open != null ? [open] : []));
21
21
  const [was, setWas] = useState(open);
@@ -32,8 +32,7 @@ export function Home({ top, title, sheets, open: openProp, defaultOpen = null, o
32
32
  const t = setTimeout(() => setLeaving(null), LEAVE);
33
33
  return () => clearTimeout(t);
34
34
  }, [leaving]);
35
- const change = (next) => { if (openProp === undefined)
36
- setOwn(next); onOpenChange?.(next); };
35
+ const change = (next) => { setOwn(next); onOpenChange?.(next); };
37
36
  const [tabs, setTabs] = useState(() => Object.fromEntries(sheets.map((x, i) => [i, x.defaultTab ?? x.tabs?.[0]?.id])));
38
37
  const shows = (i) => i === open || i === leaving || (keep && seen.has(i));
39
38
  return (_jsxs(Screen, { fill: true, top: top, ...rest, children: [_jsx(Text, { level: "h1", as: "h1", sr: true, children: title }), _jsx(SheetStack, { closed: true, open: open, onOpenChange: change, base: base, children: sheets.map((s, i) => (_jsx(StackSheet, { tone: s.tone ?? TONES[i], title: s.title, icon: s.icon, tabs: s.tabs, tab: tabs[i], onTabChange: (t) => setTabs((v) => ({ ...v, [i]: t })), backLabel: backLabel, foot: typeof s.foot === 'function' ? s.foot(tabs[i]) : s.foot, children: shows(i) ? (typeof s.body === 'function' ? s.body(tabs[i]) : s.body) : null }, i))) }), children] }));
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useEffect, useRef, useState } from 'react';
3
+ import { reducedMotion } from '../state.js';
3
4
  import { Screen } from '../scaffold/Screen.js';
4
5
  import { cx } from '../cx.js';
5
6
  /**
@@ -23,7 +24,7 @@ export function Splash({ mark, text, done, min = 0, onLeft, className, ...rest }
23
24
  useEffect(() => {
24
25
  if (!leaving)
25
26
  return;
26
- const still = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
27
+ const still = reducedMotion();
27
28
  const ms = still ? 0 : parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--motion-sheet')) * 1000 || 350;
28
29
  const t = setTimeout(() => left.current?.(), ms);
29
30
  return () => clearTimeout(t);
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useLayoutEffect, useRef, useState } from 'react';
2
+ import { useLayoutEffect, useRef } from 'react';
3
+ import { useControlled } from '../state.js';
3
4
  import { Screen } from '../scaffold/Screen.js';
4
5
  import { Step } from '../sheets/Step.js';
5
6
  import { Page } from '../scaffold/Page.js';
@@ -16,8 +17,7 @@ import { IconBtn } from '../actions/IconBtn.js';
16
17
  * first and fixed while the words scroll, one CTA row. Yours: the pages, the row, the state.
17
18
  */
18
19
  export function Walkthrough({ pages, at: atProp, defaultAt = 0, onAtChange, cta, controls = [], ctaLink, children, backLabel = 'Back', nextLabel = 'Next', ...rest }) {
19
- const [own, setOwn] = useState(defaultAt);
20
- const at = atProp ?? own;
20
+ const [at, setOwn] = useControlled(atProp, defaultAt);
21
21
  const step = useRef(null);
22
22
  // a new page starts where a page starts — at its lead, not where the last one was scrolled to (Lh 2026-09-23). A LAYOUT effect:
23
23
  // it runs inside the page turn's commit, before the view transition takes its picture — only the sideways slide shows, no scroll
@@ -27,8 +27,7 @@ export function Walkthrough({ pages, at: atProp, defaultAt = 0, onAtChange, cta,
27
27
  const go = (to) => {
28
28
  if (to < 0 || to >= count || to === at)
29
29
  return;
30
- slideTo(to > at ? 'next' : 'back', () => { if (atProp === undefined)
31
- setOwn(to); onAtChange?.(to); }, step.current ?? document);
30
+ slideTo(to > at ? 'next' : 'back', () => { setOwn(to); onAtChange?.(to); }, step.current ?? document);
32
31
  };
33
32
  /* a sideways SWIPE turns the page (Lh 2026-09-23): left = next, right = back. The walk still scrolls up and down itself
34
33
  (touch-action: pan-y); a swipe must be mostly sideways and long enough, so a scroll never turns a page */
package/dist/type/Icon.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { warnOnce } from '../state.js';
2
3
  import { cx } from '../cx.js';
3
4
  import { ICONS } from './icons.js';
4
5
  /* the stroke style every stock symbol shares (Lucide): a project's drawing takes it unless it brings its own attrs */
@@ -14,7 +15,6 @@ export function registerIcons(icons) {
14
15
  for (const [name, d] of Object.entries(icons))
15
16
  OWN.set(name, typeof d === 'string' ? { attrs: STROKE, body: d } : { attrs: { ...STROKE, ...d.attrs }, body: d.body });
16
17
  }
17
- const warned = new Set();
18
18
  /**
19
19
  * Icon — a stroke icon, inlined (no sprite file at runtime): one of the stock Lucide symbols, one of the project's OWN (`registerIcons`), or a
20
20
  * one-off drawn with `children`. The set is open — a project never comes to cardds for an icon.
@@ -23,10 +23,8 @@ const warned = new Set();
23
23
  */
24
24
  export function Icon({ name, children, size = 'md', label, className, ...rest }) {
25
25
  const icon = name === undefined ? undefined : OWN.get(name) ?? ICONS[name];
26
- if (name !== undefined && !icon && !warned.has(name)) {
27
- warned.add(name);
28
- console.warn(`cardds Icon: no icon named "${name}" — a stock name is misspelt, or the project has not registered it (registerIcons)`);
29
- }
26
+ if (name !== undefined && !icon)
27
+ warnOnce(`icon:${name}`, `cardds Icon: no icon named "${name}" — a stock name is misspelt, or the project has not registered it (registerIcons)`);
30
28
  const shared = { className: cx('icon', size !== 'md' && `icon--${size}`, className), role: label ? 'img' : undefined, 'aria-label': label, 'aria-hidden': label ? undefined : true };
31
29
  // an unknown name draws nothing but keeps its box — a missing icon never takes a screen down
32
30
  if (!icon)
@@ -0,0 +1,6 @@
1
+ /**
2
+ * The member's text size: `--fs-base` on <html> as a share of the theme's own base (100 = as the theme set it).
3
+ * Words grow, things keep their size. The base is read from the CSS (cardds's default or the project's theme.css) — never
4
+ * copied into app code. Keeping the choice (localStorage, an account) is the app's.
5
+ */
6
+ export declare function setTextScale(percent: number, root?: HTMLElement): void;
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The member's text size: `--fs-base` on <html> as a share of the theme's own base (100 = as the theme set it).
3
+ * Words grow, things keep their size. The base is read from the CSS (cardds's default or the project's theme.css) — never
4
+ * copied into app code. Keeping the choice (localStorage, an account) is the app's.
5
+ */
6
+ export function setTextScale(percent, root = document.documentElement) {
7
+ root.style.removeProperty('--fs-base');
8
+ if (percent === 100)
9
+ return;
10
+ const base = parseFloat(getComputedStyle(root).getPropertyValue('--fs-base')) || 1.375; // rem
11
+ root.style.setProperty('--fs-base', `${(base * percent) / 100}rem`);
12
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usableapp/cardds",
3
- "version": "0.7.15",
3
+ "version": "0.7.16",
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.",
@@ -1,9 +0,0 @@
1
- import type { ComponentProps } from 'react';
2
- export interface DeckProps extends ComponentProps<'div'> {
3
- }
4
- /** Deck — swipe cards fanned behind each other; the LAST child is on top. Children are Cards (the front one may `fold`). */
5
- export declare function Deck({ className, ...rest }: DeckProps): import("react").JSX.Element;
6
- export interface DeckActionsProps extends ComponentProps<'div'> {
7
- }
8
- /** DeckActions — the two big round buttons under a Deck (skip · keep). */
9
- export declare function DeckActions({ className, ...rest }: DeckActionsProps): import("react").JSX.Element;
@@ -1,10 +0,0 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
2
- import { cx } from '../cx.js';
3
- /** Deck — swipe cards fanned behind each other; the LAST child is on top. Children are Cards (the front one may `fold`). */
4
- export function Deck({ className, ...rest }) {
5
- return _jsx("div", { className: cx('deck', className), ...rest });
6
- }
7
- /** DeckActions — the two big round buttons under a Deck (skip · keep). */
8
- export function DeckActions({ className, ...rest }) {
9
- return _jsx("div", { className: cx('deck__actions', className), ...rest });
10
- }