@usableapp/cardds 0.7.14 → 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 (59) hide show
  1. package/CHANGELOG.md +43 -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 +9 -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 +6 -5
  10. package/css/numbers.css +4 -3
  11. package/css/palette.css +81 -40
  12. package/css/people.css +3 -2
  13. package/css/sheet.css +4 -6
  14. package/css/stack.css +6 -10
  15. package/css/step.css +3 -5
  16. package/css/theme.template.css +2 -1
  17. package/css/tokens.css +12 -1
  18. package/dist/actions/Dropdown.js +4 -5
  19. package/dist/cardds.css +164 -110
  20. package/dist/cards/Card.d.ts +3 -3
  21. package/dist/choice/Calendar.js +3 -5
  22. package/dist/choice/Mood.d.ts +10 -5
  23. package/dist/choice/Mood.js +4 -2
  24. package/dist/forms/Pin.js +7 -8
  25. package/dist/index.d.ts +1 -1
  26. package/dist/index.js +1 -1
  27. package/dist/lists/Row.d.ts +2 -1
  28. package/dist/lists/Row.js +2 -2
  29. package/dist/media/Postcard.js +3 -5
  30. package/dist/media/Tile.d.ts +1 -1
  31. package/dist/numbers/Band.d.ts +1 -1
  32. package/dist/numbers/Picker.js +4 -5
  33. package/dist/numbers/Track.d.ts +4 -2
  34. package/dist/numbers/Track.js +3 -3
  35. package/dist/people/AvatarPick.d.ts +3 -1
  36. package/dist/people/AvatarPick.js +2 -2
  37. package/dist/scaffold/Centre.d.ts +1 -1
  38. package/dist/scaffold/Centre.js +1 -1
  39. package/dist/scaffold/Screen.d.ts +1 -1
  40. package/dist/scaffold/Screen.js +3 -4
  41. package/dist/scaffold/TopBar.js +3 -5
  42. package/dist/sheets/Sheet.js +5 -7
  43. package/dist/sheets/SheetStack.js +4 -5
  44. package/dist/sheets/SheetSteps.js +5 -6
  45. package/dist/sheets/StackSheet.d.ts +1 -1
  46. package/dist/sheets/StackSheet.js +4 -5
  47. package/dist/state.d.ts +7 -0
  48. package/dist/state.js +17 -0
  49. package/dist/templates/CallSheet.js +3 -4
  50. package/dist/templates/Home.d.ts +1 -1
  51. package/dist/templates/Home.js +3 -4
  52. package/dist/templates/Splash.js +2 -1
  53. package/dist/templates/Walkthrough.js +4 -5
  54. package/dist/type/Icon.js +3 -5
  55. package/dist/type/textScale.d.ts +6 -0
  56. package/dist/type/textScale.js +12 -0
  57. package/package.json +1 -1
  58. package/dist/layover/Deck.d.ts +0 -9
  59. package/dist/layover/Deck.js +0 -10
@@ -1,11 +1,16 @@
1
1
  import type { ComponentProps } from 'react';
2
- export interface MoodProps extends Omit<ComponentProps<'fieldset'>, 'onChange'> {
2
+ type MoodValue = 1 | 2 | 3 | 4 | 5;
3
+ export interface MoodProps extends Omit<ComponentProps<'fieldset'>, 'onChange' | 'defaultValue'> {
3
4
  /** the radio group name */
4
5
  name?: string;
5
- /** the picked dot, 1 (worst) to 5 (best) */
6
- value?: 1 | 2 | 3 | 4 | 5;
6
+ /** CONTROLLED: the picked dot, 1 (worst) to 5 (best) — with `onChange`; or `defaultValue` */
7
+ value?: MoodValue;
8
+ /** UNCONTROLLED: the dot picked on arrival; the Mood then keeps its own */
9
+ defaultValue?: MoodValue;
7
10
  label?: string;
8
- onChange?: React.ChangeEventHandler<HTMLInputElement>;
11
+ /** the dot the member tapped */
12
+ onChange?: (value: MoodValue) => void;
9
13
  }
10
14
  /** Mood — five dots in a pill from bad to good; the picked one grows. */
11
- export declare function Mood({ name, value, label, onChange, className, ...rest }: MoodProps): import("react").JSX.Element;
15
+ export declare function Mood({ name, value, defaultValue, label, onChange, className, ...rest }: MoodProps): import("react").JSX.Element;
16
+ export {};
@@ -1,6 +1,8 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { cx } from '../cx.js';
3
+ import { useControlled } from '../state.js';
3
4
  /** Mood — five dots in a pill from bad to good; the picked one grows. */
4
- export function Mood({ name = 'mood', value, label = 'Mood', onChange, className, ...rest }) {
5
- return (_jsx("fieldset", { className: cx('mood', className), "aria-label": label, ...rest, children: [1, 2, 3, 4, 5].map((n) => (_jsxs("label", { className: "mood__opt", children: [_jsx("input", { type: "radio", name: name, value: n, defaultChecked: n === value, onChange: onChange }), _jsx("span", { className: "mood__dot" })] }, n))) }));
5
+ export function Mood({ name = 'mood', value, defaultValue, label = 'Mood', onChange, className, ...rest }) {
6
+ const [picked, setOwn] = useControlled(value, defaultValue);
7
+ return (_jsx("fieldset", { className: cx('mood', className), "aria-label": label, ...rest, children: [1, 2, 3, 4, 5].map((n) => (_jsxs("label", { className: "mood__opt", children: [_jsx("input", { type: "radio", name: name, value: n, checked: n === picked, onChange: () => { setOwn(n); onChange?.(n); } }), _jsx("span", { className: "mood__dot" })] }, n))) }));
6
8
  }
package/dist/forms/Pin.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { useEffect, useRef, useState } from 'react';
3
+ import { useControlled } from '../state.js';
3
4
  import { cx } from '../cx.js';
4
5
  import { useCheck } from './check.js';
5
6
  const RESET_MS = 900; // a code that does not match: red + shake this long, then the cells empty for a new try
@@ -11,12 +12,12 @@ const RESET_MS = 900; // a code that does not match: red + shake this long, then
11
12
  */
12
13
  export function Pin({ length = 6, value, defaultValue = '', label = 'Digit', onChange, autoFocus, error, match, formatError, secret, className, onAnimationEnd, ...rest }) {
13
14
  const ref = useRef(null);
14
- const [inner, setInner] = useState(defaultValue);
15
- const code = (value ?? inner).replace(/\D/g, '').slice(0, length);
15
+ const [typed, setOwn] = useControlled(value, defaultValue);
16
+ const code = typed.replace(/\D/g, '').slice(0, length);
16
17
  const cell = (i) => ref.current?.querySelectorAll('.pin__cell')[i];
17
18
  /* a miss — a match that failed, or the app's error: shake, red, then the cells empty for a new try on the same step */
18
- const latest = useRef({ value, onChange, error });
19
- latest.current = { value, onChange, error };
19
+ const latest = useRef({ setOwn, onChange, error });
20
+ latest.current = { setOwn, onChange, error };
20
21
  const miss = () => {
21
22
  restart.current = true;
22
23
  setShake(true);
@@ -25,8 +26,7 @@ export function Pin({ length = 6, value, defaultValue = '', label = 'Digit', onC
25
26
  setBad(false);
26
27
  setCleared(latest.current.error);
27
28
  restart.current = false;
28
- if (latest.current.value === undefined)
29
- setInner('');
29
+ latest.current.setOwn('');
30
30
  latest.current.onChange?.('', false);
31
31
  cell(0)?.focus();
32
32
  }, RESET_MS);
@@ -46,8 +46,7 @@ export function Pin({ length = 6, value, defaultValue = '', label = 'Digit', onC
46
46
  said.current = true;
47
47
  miss();
48
48
  }
49
- if (value === undefined)
50
- setInner(next);
49
+ setOwn(next);
51
50
  if (next !== code)
52
51
  onChange?.(next, next.length === length);
53
52
  };
package/dist/index.d.ts CHANGED
@@ -4,6 +4,7 @@ export { cx } from './cx.js';
4
4
  export * from './type/Icon.js';
5
5
  export { ICON_NAMES } from './type/icons.js';
6
6
  export * from './type/Text.js';
7
+ export { setTextScale } from './type/textScale.js';
7
8
  export * from './scaffold/Screen.js';
8
9
  export * from './scaffold/AppBar.js';
9
10
  export * from './scaffold/TopBar.js';
@@ -72,7 +73,6 @@ export * from './media/Tile.js';
72
73
  export * from './media/Mosaic.js';
73
74
  export * from './layover/Banner.js';
74
75
  export * from './layover/Callout.js';
75
- export * from './layover/Deck.js';
76
76
  export * from './journey/Route.js';
77
77
  export * from './journey/TileBadge.js';
78
78
  export * from './journey/NoteRow.js';
package/dist/index.js CHANGED
@@ -8,6 +8,7 @@ export { cx } from './cx.js';
8
8
  export * from './type/Icon.js';
9
9
  export { ICON_NAMES } from './type/icons.js';
10
10
  export * from './type/Text.js';
11
+ export { setTextScale } from './type/textScale.js';
11
12
  export * from './scaffold/Screen.js';
12
13
  export * from './scaffold/AppBar.js';
13
14
  export * from './scaffold/TopBar.js';
@@ -76,7 +77,6 @@ export * from './media/Tile.js';
76
77
  export * from './media/Mosaic.js';
77
78
  export * from './layover/Banner.js';
78
79
  export * from './layover/Callout.js';
79
- export * from './layover/Deck.js';
80
80
  export * from './journey/Route.js';
81
81
  export * from './journey/TileBadge.js';
82
82
  export * from './journey/NoteRow.js';
@@ -41,7 +41,8 @@ export interface RowProps extends Omit<ComponentProps<'div'>, 'title'> {
41
41
  */
42
42
  export declare function Row({ lead, leadIcon, title, meta, body, end, endValue, endCaption, lg, done, as, href, more, open, defaultOpen, onOpenChange, className, children, ...rest }: RowProps): import("react").JSX.Element;
43
43
  export interface RowsProps extends ComponentProps<'div'> {
44
- /** the group's name, above its rows (Today · Tomorrow · Later): a long list is GROUPED, not spaced out — several Rows one after another */
44
+ /** the group's name, above its rows (Today · Tomorrow · Later): a long list is GROUPED, not spaced out — several Rows one after another.
45
+ * Words = the small label; an ELEMENT (`<Text level="title" as="h3">`) is used as the heading itself — the group is labelled by it. */
45
46
  heading?: ReactNode;
46
47
  }
47
48
  /** Rows — a list of Rows separated by hairlines; `heading` names the group (a `role="group"` labelled by it). */
package/dist/lists/Row.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { createElement, useId } from 'react';
2
+ import { cloneElement, createElement, isValidElement, useId } from 'react';
3
3
  import { cx } from '../cx.js';
4
4
  import { Icon } from '../type/Icon.js';
5
5
  /**
@@ -22,5 +22,5 @@ export function Row({ lead, leadIcon, title, meta, body, end, endValue, endCapti
22
22
  export function Rows({ heading, className, children, ...rest }) {
23
23
  const id = useId();
24
24
  const named = heading != null;
25
- return (_jsxs("div", { className: cx('rows', className), role: named ? 'group' : undefined, "aria-labelledby": named ? id : undefined, ...rest, children: [named && _jsx("span", { className: "rows__head", id: id, children: heading }), children] }));
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] }));
26
26
  }
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useState } from 'react';
2
+ import { useControlled } from '../state.js';
3
3
  import { cx } from '../cx.js';
4
4
  import { IconBtn } from '../actions/IconBtn.js';
5
5
  /**
@@ -9,10 +9,8 @@ import { IconBtn } from '../actions/IconBtn.js';
9
9
  * Who else liked it, a voice note, a reply go OUTSIDE it — a Row under it (`Row more` expands the list).
10
10
  */
11
11
  export function Postcard({ photo, alt = '', name, line, caption, voice, flipped, defaultFlipped = false, onFlip, liked, onLike, likeLabel = 'ถูกใจ', frontLabel = 'พลิกดูด้านหลัง', backLabel = 'พลิกดูด้านหน้า', className, ...rest }) {
12
- const [inner, setInner] = useState(defaultFlipped);
13
- const back = flipped ?? inner;
14
- const turn = () => { if (flipped === undefined)
15
- setInner(!back); onFlip?.(!back); };
12
+ const [back, setOwn] = useControlled(flipped, defaultFlipped);
13
+ const turn = () => { setOwn(!back); onFlip?.(!back); };
16
14
  const words = (_jsxs("div", { className: "postcard__words", children: [_jsx("span", { className: "t-h1", children: name }), line != null && _jsx("span", { className: "t-body", children: line })] }));
17
15
  return (_jsxs("div", { className: cx('postcard', back && 'is-flipped', className), ...rest, children: [_jsxs("div", { className: "postcard__face postcard__front", children: [_jsx("button", { className: "postcard__turn", type: "button", "aria-label": frontLabel, onClick: turn, children: photo ? _jsx("img", { src: photo, alt: alt }) : alt ? _jsx("div", { className: "ph", role: "img", "aria-label": alt }) : _jsx("div", { className: "ph" }) }), _jsxs("div", { className: "postcard__band", children: [words, (liked !== undefined || onLike) && _jsx(IconBtn, { invert: true, icon: "heart", label: likeLabel, "aria-pressed": !!liked, onClick: () => onLike?.(!liked) })] })] }), _jsxs("div", { className: "postcard__face postcard__back", children: [_jsxs("button", { className: "postcard__turn", type: "button", "aria-label": backLabel, onClick: turn, children: [words, caption != null && _jsx("p", { className: "postcard__caption", children: caption })] }), voice != null && _jsx("div", { className: "postcard__voice", children: voice })] })] }));
18
16
  }
@@ -32,7 +32,7 @@ export interface TileProps extends Omit<ComponentProps<'div'>, 'onChange'> {
32
32
  /** spans the whole grid */
33
33
  wide?: boolean;
34
34
  /** a slot tint */
35
- tone?: 2 | 3;
35
+ tone?: 2 | 3 | 4 | 5;
36
36
  /** div (default), button for a tap tile — pick tiles render as a label */
37
37
  as?: 'div' | 'button';
38
38
  }
@@ -6,7 +6,7 @@ export interface BandProps extends ComponentProps<'article'> {
6
6
  num: ReactNode;
7
7
  /** the footnote under the label */
8
8
  foot?: ReactNode;
9
- tone?: 1 | 2 | 3;
9
+ tone?: 1 | 2 | 3 | 4 | 5;
10
10
  }
11
11
  /** Band — a data card: 2-line label · giant number · footnote. Stack several in a BandStack for one boundary with hairlines. */
12
12
  export declare function Band({ label, num, foot, tone, className, ...rest }: BandProps): import("react").JSX.Element;
@@ -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,5 +1,5 @@
1
1
  import { type ComponentProps, type ReactNode, type Ref } from 'react';
2
- export type Palette = 'clay' | 'shine' | 'field' | 'roast' | 'hydro';
2
+ export type Palette = 'clay' | 'roast' | 'hydro' | 'orchard' | 'vers';
3
3
  export interface ScreenProps extends ComponentProps<'div'> {
4
4
  /** exactly one phone height (100dvh), clipped: the box a Step or a SheetStack fills — an app screen, not a scrolling page */
5
5
  fill?: boolean;
@@ -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';
@@ -12,7 +12,7 @@ export interface StackSheetProps extends Omit<ComponentProps<'article'>, 'title'
12
12
  /** the SHEET HEAD's title — an h2 (the page's h1 is its own) */
13
13
  title: ReactNode;
14
14
  /** the paper: 1 light · 2 tinted · 3 dark */
15
- tone?: 1 | 2 | 3;
15
+ tone?: 1 | 2 | 3 | 4 | 5;
16
16
  /** the head's end when the sheet has no tabs: an Icon, an IconRow */
17
17
  icon?: ReactNode;
18
18
  /** TABS in the head: icon buttons at the end — the sheet's pages. A tap on one in a peeked head opens the sheet on it */
@@ -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
@@ -7,7 +7,7 @@ export interface HomeSheet {
7
7
  /** the sheet head's end when it has no tabs: an Icon, an IconRow */
8
8
  icon?: ReactNode;
9
9
  /** the paper: 1 light · 2 tinted · 3 dark (default: 2, 3, 1, 2, 3 by place) */
10
- tone?: 1 | 2 | 3;
10
+ tone?: 1 | 2 | 3 | 4 | 5;
11
11
  /** the sheet's pages as icon tabs in its head (a count shows as a small circle over the icon) — see `StackSheet` */
12
12
  tabs?: StackSheetTab[];
13
13
  /** the tab shown first (default: the first) */
@@ -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 */