@usableapp/cardds 0.7.16 → 0.7.18

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 (63) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +23 -20
  3. package/cardds.css +3 -3
  4. package/css/actions.css +60 -38
  5. package/css/base.css +43 -17
  6. package/css/card.css +18 -13
  7. package/css/choice.css +21 -33
  8. package/css/forms.css +0 -3
  9. package/css/journey.css +0 -2
  10. package/css/layover.css +14 -24
  11. package/css/lists.css +1 -6
  12. package/css/media.css +7 -15
  13. package/css/numbers.css +5 -21
  14. package/css/people.css +1 -8
  15. package/css/sheet.css +12 -14
  16. package/css/stack.css +17 -21
  17. package/css/step.css +7 -8
  18. package/css/tokens.css +19 -4
  19. package/dist/actions/Btn.d.ts +1 -1
  20. package/dist/actions/Dropdown.js +1 -6
  21. package/dist/actions/Fab.d.ts +1 -1
  22. package/dist/actions/Fab.js +3 -2
  23. package/dist/cardds.css +230 -232
  24. package/dist/cards/Card.d.ts +23 -7
  25. package/dist/cards/Card.js +23 -8
  26. package/dist/cards/CardHead.d.ts +0 -1
  27. package/dist/cards/CardHead.js +1 -1
  28. package/dist/choice/Calendar.js +1 -2
  29. package/dist/choice/Mood.js +2 -2
  30. package/dist/forms/Field.js +2 -3
  31. package/dist/forms/FileBtn.js +1 -1
  32. package/dist/forms/Pin.js +6 -10
  33. package/dist/index.js +2 -2
  34. package/dist/layover/Banner.d.ts +10 -3
  35. package/dist/layover/Banner.js +6 -3
  36. package/dist/lists/Row.js +3 -3
  37. package/dist/media/Postcard.js +2 -2
  38. package/dist/numbers/Band.d.ts +3 -1
  39. package/dist/numbers/Picker.js +1 -7
  40. package/dist/scaffold/ActionBar.d.ts +3 -3
  41. package/dist/scaffold/ActionBar.js +2 -2
  42. package/dist/scaffold/Centre.d.ts +1 -1
  43. package/dist/scaffold/Centre.js +1 -1
  44. package/dist/scaffold/PagerAt.d.ts +1 -1
  45. package/dist/scaffold/PagerAt.js +1 -1
  46. package/dist/scaffold/Screen.js +2 -4
  47. package/dist/sheets/Drawer.js +3 -4
  48. package/dist/sheets/Sheet.js +8 -19
  49. package/dist/sheets/SheetStack.d.ts +2 -2
  50. package/dist/sheets/SheetStack.js +18 -10
  51. package/dist/sheets/SheetSteps.js +5 -7
  52. package/dist/sheets/StackBack.js +2 -1
  53. package/dist/sheets/StackSheet.d.ts +3 -2
  54. package/dist/sheets/StackSheet.js +6 -9
  55. package/dist/state.d.ts +19 -2
  56. package/dist/state.js +47 -7
  57. package/dist/templates/CallSheet.js +1 -2
  58. package/dist/templates/Home.d.ts +3 -2
  59. package/dist/templates/Home.js +1 -2
  60. package/dist/templates/Splash.js +2 -3
  61. package/dist/templates/Walkthrough.js +2 -2
  62. package/dist/type/Text.d.ts +1 -1
  63. package/package.json +7 -4
@@ -1,7 +1,10 @@
1
- import { type ComponentProps } from 'react';
1
+ import { type ComponentProps, type ReactNode } from 'react';
2
+ import { type CardHeadProps } from './CardHead.js';
3
+ /** a card's paper: 1 (default, light) · 2 (tinted) · 3 (dark / accent — controls invert) · 4 · 5 (more colours, from the palette) */
4
+ export type Tone = 1 | 2 | 3 | 4 | 5;
2
5
  export interface CardProps extends ComponentProps<'article'> {
3
- /** the tone slot: 1 (default, light) · 2 (tinted) · 3 (dark / accent — controls invert automatically) · 4 · 5 (more colours, from the palette) */
4
- tone?: 1 | 2 | 3 | 4 | 5;
6
+ /** the tone slot — see `Tone` */
7
+ tone?: Tone;
5
8
  /** compact: a small card inside a card or a sheet body (a note, a promo) */
6
9
  sm?: boolean;
7
10
  /** a list row that is its own card: thumb · body · end, in a CardList */
@@ -17,14 +20,28 @@ export interface CardProps extends ComponentProps<'article'> {
17
20
  /** the element: article (default), a when it links, label when it wraps an input, div/section */
18
21
  as?: 'article' | 'div' | 'a' | 'label' | 'section' | 'li';
19
22
  href?: string;
23
+ /** THE HEAD, filled in place: the heading (wraps freely) — the card writes its CardHead first. For a head of your own shape
24
+ * (a chip before the words, a tile badge) write `<CardHead>` as the first child instead, and leave these out */
25
+ heading?: ReactNode;
26
+ /** the muted line under the heading */
27
+ caption?: ReactNode;
28
+ /** the head's far-right thing: a Chip, an IconBtn, a Badge, a small Ring */
29
+ end?: ReactNode;
30
+ /** the heading's level (h2 default; h1 for the one-feature page) */
31
+ level?: CardHeadProps['level'];
32
+ /** THE FOOT, filled in place: its Btns (≤4, one line) — the card writes its CardFoot last */
33
+ foot?: ReactNode;
34
+ /** the small Link under the foot's buttons (CardFoot `link`) */
35
+ footLink?: ReactNode;
20
36
  }
21
37
  /**
22
38
  * Card — ONE boundary: head · content · foot. Cards are content the system offers (the words, the thing to read).
23
- * Children: a CardHead first (heading left, contextual info far right), the content (frameless rows, kv, a ring,
24
- * a paragraph — never a second card boundary), an optional CardFoot (≤4 buttons on one line).
39
+ * The head and the foot are SLOTS: `heading` · `caption` · `end` (the card writes its CardHead first) and `foot` · `footLink`
40
+ * (its CardFoot last); the children are the content between them (frameless rows, kv, a ring, a paragraph — never a second card
41
+ * boundary). The order is the card's, never the writer's. A head of its own shape: write `<CardHead>` as the first child instead.
25
42
  * A page with one feature and one CTA may be a single card. Keep everything big.
26
43
  */
27
- export declare function Card({ tone, sm, row, cover, fold, centre, band, as, className, ...rest }: CardProps): import("react").DetailedReactHTMLElement<{
44
+ export declare function Card({ tone, sm, row, cover, fold, centre, band, as, heading, caption, end, level, foot, footLink, className, children, ...rest }: CardProps): import("react").DetailedReactHTMLElement<{
28
45
  href?: string;
29
46
  ref?: import("react").Ref<HTMLElement> | undefined;
30
47
  key?: import("react").Key | null | undefined;
@@ -135,7 +152,6 @@ export declare function Card({ tone, sm, row, cover, fold, centre, band, as, cla
135
152
  "aria-valuemin"?: number | undefined;
136
153
  "aria-valuenow"?: number | undefined;
137
154
  "aria-valuetext"?: string | undefined;
138
- children?: import("react").ReactNode | undefined;
139
155
  dangerouslySetInnerHTML?: {
140
156
  __html: string | TrustedHTML;
141
157
  } | undefined;
@@ -1,14 +1,29 @@
1
- import { createElement } from 'react';
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Children, createElement, isValidElement } from 'react';
2
3
  import { cx } from '../cx.js';
4
+ import { warnOnce } from '../state.js';
5
+ import { CardHead } from './CardHead.js';
6
+ import { CardFoot } from './CardFoot.js';
3
7
  /**
4
8
  * Card — ONE boundary: head · content · foot. Cards are content the system offers (the words, the thing to read).
5
- * Children: a CardHead first (heading left, contextual info far right), the content (frameless rows, kv, a ring,
6
- * a paragraph — never a second card boundary), an optional CardFoot (≤4 buttons on one line).
9
+ * The head and the foot are SLOTS: `heading` · `caption` · `end` (the card writes its CardHead first) and `foot` · `footLink`
10
+ * (its CardFoot last); the children are the content between them (frameless rows, kv, a ring, a paragraph — never a second card
11
+ * boundary). The order is the card's, never the writer's. A head of its own shape: write `<CardHead>` as the first child instead.
7
12
  * A page with one feature and one CTA may be a single card. Keep everything big.
8
13
  */
9
- export function Card({ tone, sm, row, cover, fold, centre, band, as = 'article', className, ...rest }) {
10
- return createElement(as, {
11
- className: cx('card', tone && tone !== 1 && `card--${tone}`, sm && 'card--sm', row && 'card--row', cover && 'card--cover', fold && 'card--fold', centre && 'card--centre', band && 'card--band', className),
12
- ...rest,
13
- });
14
+ export function Card({ tone, sm, row, cover, fold, centre, band, as = 'article', heading, caption, end, level, foot, footLink, className, children, ...rest }) {
15
+ const head = heading != null || caption != null || end != null;
16
+ const tail = foot != null || footLink != null;
17
+ if (head || tail) {
18
+ const parts = Children.toArray(children).filter(isValidElement); // dev hint only
19
+ if (head && parts.some((c) => c.type === CardHead))
20
+ warnOnce('card-head-twice', 'cardds Card: `heading`/`caption`/`end` AND a <CardHead> child — one head per card; use one way');
21
+ if (tail && parts.some((c) => c.type === CardFoot))
22
+ warnOnce('card-foot-twice', 'cardds Card: `foot`/`footLink` AND a <CardFoot> child — one foot per card; use one way');
23
+ }
24
+ const props = { className: cx('card', tone && tone !== 1 && `card--${tone}`, sm && 'card--sm', row && 'card--row', cover && 'card--cover', fold && 'card--fold', centre && 'card--centre', band && 'card--band', className), ...rest };
25
+ if (!head && !tail)
26
+ return createElement(as, props, children);
27
+ return createElement(as, props, head && _jsx(CardHead, { heading: heading, caption: caption, end: end, level: level }, "head"), ...Children.toArray(children), // keyed: the slots sit around them
28
+ tail && _jsx(CardFoot, { link: footLink, children: foot }, "foot"));
14
29
  }
@@ -2,7 +2,6 @@ import type { ComponentProps, ReactNode } from 'react';
2
2
  export interface CardHeadProps extends ComponentProps<'div'> {
3
3
  /** the heading (a string becomes an h2 at level `t-h2`) — wraps freely, as many lines as the words need */
4
4
  heading?: ReactNode;
5
- /** small caps above the heading ("Step 1 of 4", "Time bank") */
6
5
  /** the muted line under the heading (a date, a subtitle) */
7
6
  caption?: ReactNode;
8
7
  /** the contextual thing at the far right: a chip, an IconBtn (more), a Link, a small Ring, an Avatar, a Badge (a count in a stack head) */
@@ -7,5 +7,5 @@ import { cx } from '../cx.js';
7
7
  */
8
8
  export function CardHead({ heading, caption, end, top, level = 'h2', className, children, ...rest }) {
9
9
  const H = level === 'h1' ? 'h1' : 'h2';
10
- return (_jsxs("div", { className: cx('card__head', top && 'card__head--top', className), ...rest, children: [heading != null && _jsx(H, { className: level === 'display' ? 't-display' : `t-${level}`, children: heading }), caption != null && _jsx("span", { className: "t-caption t-muted", children: caption }), children, end] }));
10
+ return (_jsxs("div", { className: cx('card__head', top && 'card__head--top', className), ...rest, children: [heading != null && _jsx(H, { className: `t-${level}`, children: heading }), caption != null && _jsx("span", { className: "t-caption t-muted", children: caption }), children, end] }));
11
11
  }
@@ -5,7 +5,6 @@ import { IconBtn } from '../actions/IconBtn.js';
5
5
  /** Calendar — a month grid of 44px round cells: one accent day, optional marks; prev/next in the head. It shows ONE month and knows no dates:
6
6
  * the app owns which month (`title`, `days`, `start` + `onPrev` / `onNext`) and which day (`selected` + `onSelect`, or `defaultSelected`). */
7
7
  export function Calendar({ title, days = 30, start = 1, selected, defaultSelected, onSelect, onPrev, onNext, prevLabel = 'Previous', nextLabel = 'Next', marks = [], marks2 = [], disabled = [], weekdays = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'], nav, className, ...rest }) {
8
- const [day, setOwn] = useControlled(selected, defaultSelected);
9
- const pick = (d) => { setOwn(d); onSelect?.(d); };
8
+ const [day, pick] = useControlled(selected, defaultSelected, onSelect);
10
9
  return (_jsxs("div", { className: cx('calendar', className), ...rest, children: [_jsxs("div", { className: "calendar__head", children: [_jsx("span", { className: "t-title", children: title }), nav ?? (_jsxs("span", { className: "icon-row", children: [_jsx(IconBtn, { sm: true, icon: "chevron-left", label: prevLabel, onClick: onPrev }), _jsx(IconBtn, { sm: true, icon: "chevron-right", label: nextLabel, onClick: onNext })] }))] }), _jsxs("div", { className: "calendar__grid", children: [weekdays.map((d) => _jsx("span", { className: "calendar__wd", children: d }, d)), Array.from({ length: days }, (_, i) => i + 1).map((d) => (_jsx("button", { type: "button", className: cx('calendar__day', marks.includes(d) && 'calendar__day--mark', marks2.includes(d) && 'calendar__day--mark2'), "aria-selected": d === day ? 'true' : undefined, onClick: () => pick(d), disabled: disabled.includes(d), style: d === 1 ? { '--start': start } : undefined, children: d }, d)))] })] }));
11
10
  }
@@ -3,6 +3,6 @@ import { cx } from '../cx.js';
3
3
  import { useControlled } from '../state.js';
4
4
  /** Mood — five dots in a pill from bad to good; the picked one grows. */
5
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
+ const [picked, pick] = useControlled(value, defaultValue, onChange);
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: () => pick(n) }), _jsx("span", { className: "mood__dot" })] }, n))) }));
8
8
  }
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useEffect, useLayoutEffect, useRef, useState } from 'react';
3
+ import { watchSize } from '../state.js';
3
4
  import { formatOk, useCheck } from './check.js';
4
5
  import { cx } from '../cx.js';
5
6
  import { Icon } from '../type/Icon.js';
@@ -59,10 +60,8 @@ export function Field({ icon, action, label, error, format, digits, formatError,
59
60
  el.style.removeProperty('--_ph-fit');
60
61
  };
61
62
  fit();
62
- const ro = new ResizeObserver(fit);
63
- ro.observe(el);
64
63
  document.fonts?.ready.then(fit);
65
- return () => ro.disconnect();
64
+ return watchSize([el], fit);
66
65
  }, [input.placeholder]);
67
66
  useEffect(() => {
68
67
  if (wrong) {
@@ -6,5 +6,5 @@ import { cx } from '../cx.js';
6
6
  * opens the camera; without `capture` the gallery. The picked file arrives in `onChange` like any input.
7
7
  */
8
8
  export function FileBtn({ primary, quiet, block, className, children, ...input }) {
9
- return (_jsxs("label", { className: cx('btn', primary && 'btn--primary', quiet && 'btn--quiet', block && 'btn--block', className), children: [_jsx("input", { type: "file", hidden: true, ...input }), children] }));
9
+ return (_jsxs("label", { className: cx('btn', primary && 'btn--primary', quiet && 'btn--quiet', block && 'btn--block', className), children: [_jsx("input", { type: "file", className: "t-sr", ...input }), children] }));
10
10
  }
package/dist/forms/Pin.js CHANGED
@@ -1,6 +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
+ import { useControlled, useLatest } from '../state.js';
4
4
  import { cx } from '../cx.js';
5
5
  import { useCheck } from './check.js';
6
6
  const RESET_MS = 900; // a code that does not match: red + shake this long, then the cells empty for a new try
@@ -12,22 +12,20 @@ const RESET_MS = 900; // a code that does not match: red + shake this long, then
12
12
  */
13
13
  export function Pin({ length = 6, value, defaultValue = '', label = 'Digit', onChange, autoFocus, error, match, formatError, secret, className, onAnimationEnd, ...rest }) {
14
14
  const ref = useRef(null);
15
- const [typed, setOwn] = useControlled(value, defaultValue);
15
+ const [typed, request] = useControlled(value, defaultValue, onChange);
16
16
  const code = typed.replace(/\D/g, '').slice(0, length);
17
17
  const cell = (i) => ref.current?.querySelectorAll('.pin__cell')[i];
18
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 */
19
- const latest = useRef({ setOwn, onChange, error });
20
- latest.current = { setOwn, onChange, error };
19
+ const latest = useLatest(error);
21
20
  const miss = () => {
22
21
  restart.current = true;
23
22
  setShake(true);
24
23
  clearTimeout(reset.current);
25
24
  reset.current = setTimeout(() => {
26
25
  setBad(false);
27
- setCleared(latest.current.error);
26
+ setCleared(latest.current);
28
27
  restart.current = false;
29
- latest.current.setOwn('');
30
- latest.current.onChange?.('', false);
28
+ request('', false);
31
29
  cell(0)?.focus();
32
30
  }, RESET_MS);
33
31
  };
@@ -46,9 +44,7 @@ export function Pin({ length = 6, value, defaultValue = '', label = 'Digit', onC
46
44
  said.current = true;
47
45
  miss();
48
46
  }
49
- setOwn(next);
50
- if (next !== code)
51
- onChange?.(next, next.length === length);
47
+ request(next, next.length === length);
52
48
  };
53
49
  useEffect(() => {
54
50
  if (autoFocus)
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // cardds — the React adapter over the CSS. Thin: every component emits the
2
- // markup contract the CSS documents, nothing more. cardds.js rides along for
3
- // the behaviours (sheet handle, stack open/close, dropdown value sync).
2
+ // markup contract the CSS documents; a component's own behaviour is its own React code.
3
+ // cardds.js rides along for the environment only: the keyboard's height (--kb) and .bleed's scrollbar.
4
4
  import '../cardds.js';
5
5
  // Popover for Safari/iOS 16.4–16.x (native from 17): applies itself only where the platform lacks it.
6
6
  import '@oddbird/popover-polyfill';
@@ -1,8 +1,15 @@
1
1
  import type { ComponentProps, ReactNode } from 'react';
2
- export interface BannerProps extends Omit<ComponentProps<'details'>, 'summary'> {
2
+ export interface BannerProps extends Omit<ComponentProps<'details'>, 'summary' | 'open' | 'onToggle'> {
3
3
  /** the line that opens it — words (and an inline Icon); the chevron is the Banner's own */
4
4
  summary: ReactNode;
5
+ /** CONTROLLED: open — with `onOpenChange`; or `defaultOpen` */
6
+ open?: boolean;
7
+ /** UNCONTROLLED: start open */
8
+ defaultOpen?: boolean;
9
+ /** the summary was tapped: the state it asks for */
10
+ onOpenChange?: (open: boolean) => void;
5
11
  }
6
12
  /** Banner — ONE band: the line that opens it and what it opens share the same ground, so they read as one piece (Lh 2026-09-24).
7
- * A light tint with a darker rule above and below, edge to edge of its card or screen — a band, never a card in a card. A native details, no JS. */
8
- export declare function Banner({ summary, className, children, ...rest }: BannerProps): import("react").JSX.Element;
13
+ * A light tint with a darker rule above and below, edge to edge of its card or screen — a band, never a card in a card.
14
+ * A native details, controlled like an input: `open` + `onOpenChange`, or `defaultOpen`. */
15
+ export declare function Banner({ summary, open, defaultOpen, onOpenChange, className, children, ...rest }: BannerProps): import("react").JSX.Element;
@@ -1,8 +1,11 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { cx } from '../cx.js';
3
3
  import { Icon } from '../type/Icon.js';
4
+ import { useDisclosure } from '../state.js';
4
5
  /** Banner — ONE band: the line that opens it and what it opens share the same ground, so they read as one piece (Lh 2026-09-24).
5
- * A light tint with a darker rule above and below, edge to edge of its card or screen — a band, never a card in a card. A native details, no JS. */
6
- export function Banner({ summary, className, children, ...rest }) {
7
- return (_jsxs("details", { className: cx('banner', className), ...rest, children: [_jsxs("summary", { className: "banner__summary", children: [summary, _jsx(Icon, { name: "chevron-down", size: "xs" })] }), _jsx("div", { className: "banner__body", children: children })] }));
6
+ * A light tint with a darker rule above and below, edge to edge of its card or screen — a band, never a card in a card.
7
+ * A native details, controlled like an input: `open` + `onOpenChange`, or `defaultOpen`. */
8
+ export function Banner({ summary, open, defaultOpen, onOpenChange, className, children, ...rest }) {
9
+ const disclosure = useDisclosure(open, defaultOpen, onOpenChange);
10
+ return (_jsxs("details", { className: cx('banner', className), ...rest, ...disclosure, children: [_jsxs("summary", { className: "banner__summary", children: [summary, _jsx(Icon, { name: "chevron-down", size: "xs" })] }), _jsx("div", { className: "banner__body", children: children })] }));
8
11
  }
package/dist/lists/Row.js CHANGED
@@ -2,6 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { cloneElement, createElement, isValidElement, useId } from 'react';
3
3
  import { cx } from '../cx.js';
4
4
  import { Icon } from '../type/Icon.js';
5
+ import { useDisclosure } from '../state.js';
5
6
  /**
6
7
  * Row — a frameless row inside a card: lead · title + meta · end. 48px+ tall, a tap target.
7
8
  * Several go in Rows (hairlines between). A row that IS a card is `Card row`.
@@ -10,13 +11,12 @@ import { Icon } from '../type/Icon.js';
10
11
  * An `end` chevron turns when it is open — the app fetches whatever `more` lists; the row only shows it.
11
12
  */
12
13
  export function Row({ lead, leadIcon, title, meta, body, end, endValue, endCaption, lg, done, as, href, more, open, defaultOpen, onOpenChange, className, children, ...rest }) {
14
+ const disclosure = useDisclosure(open, defaultOpen, onOpenChange);
13
15
  const el = more !== undefined ? 'summary' : as ?? (href ? 'a' : 'div');
14
16
  const row = createElement(el, { href, className: cx('row', lg && 'row--lg', done && 'row--done', className), 'aria-disabled': done || undefined, ...(more === undefined ? rest : {}) }, leadIcon ? _jsx("span", { className: "row__lead", children: _jsx(Icon, { name: leadIcon }) }) : lead, (title != null || meta != null || body != null) && (_jsxs("div", { className: "row__body", children: [title != null && _jsx("span", { className: "row__title", children: title }), meta != null && _jsx("span", { className: "row__meta", children: meta }), body] })), children, (endValue != null || endCaption != null) && (_jsxs("span", { className: "row__end", children: [endValue != null && _jsx("span", { className: "t-title", children: endValue }), endCaption != null && _jsx("span", { className: "t-caption t-muted", children: endCaption })] })), end, done && _jsx("span", { className: "row__done", children: _jsx(Icon, { name: "check", size: "sm" }) }));
15
17
  if (more === undefined)
16
18
  return row;
17
- return (_jsxs("details", { className: "row-expand", open: open ?? defaultOpen, ...rest, onToggle: (e) => { const is = e.currentTarget.open; if (open !== undefined && is !== open)
18
- e.currentTarget.open = open; if (is !== (open ?? !is))
19
- onOpenChange?.(is); }, children: [row, _jsx("div", { className: "row-expand__more", children: more })] }));
19
+ return (_jsxs("details", { className: "row-expand", ...rest, ...disclosure, children: [row, _jsx("div", { className: "row-expand__more", children: more })] }));
20
20
  }
21
21
  /** Rows — a list of Rows separated by hairlines; `heading` names the group (a `role="group"` labelled by it). */
22
22
  export function Rows({ heading, className, children, ...rest }) {
@@ -9,8 +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 [back, setOwn] = useControlled(flipped, defaultFlipped);
13
- const turn = () => { setOwn(!back); onFlip?.(!back); };
12
+ const [back, flip] = useControlled(flipped, defaultFlipped, onFlip);
13
+ const turn = () => flip(!back);
14
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 })] }));
15
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 })] })] }));
16
16
  }
@@ -1,4 +1,5 @@
1
1
  import type { ComponentProps, ReactNode } from 'react';
2
+ import type { Tone } from '../cards/Card.js';
2
3
  export interface BandProps extends ComponentProps<'article'> {
3
4
  /** the two-line label (use a `<br />` to break it) */
4
5
  label: ReactNode;
@@ -6,7 +7,8 @@ export interface BandProps extends ComponentProps<'article'> {
6
7
  num: ReactNode;
7
8
  /** the footnote under the label */
8
9
  foot?: ReactNode;
9
- tone?: 1 | 2 | 3 | 4 | 5;
10
+ /** the paper — see `Tone` */
11
+ tone?: Tone;
10
12
  }
11
13
  /** Band — a data card: 2-line label · giant number · footnote. Stack several in a BandStack for one boundary with hairlines. */
12
14
  export declare function Band({ label, num, foot, tone, className, ...rest }: BandProps): import("react").JSX.Element;
@@ -10,16 +10,10 @@ const PickerContext = createContext(null);
10
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.
11
11
  */
12
12
  export function Picker({ value, defaultValue, onChange, unit, label, className, children, ...rest }) {
13
- const [current, setOwn] = useControlled(value, defaultValue);
13
+ const [current, pick] = useControlled(value, defaultValue, onChange);
14
14
  const rail = useRef(null);
15
15
  const mounted = useRef(false);
16
16
  const settle = useRef(null);
17
- const pick = (next) => {
18
- if (next === current)
19
- return;
20
- setOwn(next);
21
- onChange?.(next);
22
- };
23
17
  const items = () => [...(rail.current?.querySelectorAll(':scope > .picker__item') ?? [])];
24
18
  const column = () => (rail.current ? rail.current.clientWidth / 3 : 0); // the rail is three columns wide; item i stands in the centre at i × one column
25
19
  /* the chosen value stands in the centre — at once on arrival, sliding after */
@@ -1,6 +1,6 @@
1
1
  import type { ComponentProps } from 'react';
2
2
  export interface ActionBarProps extends ComponentProps<'div'> {
3
- /** `pager`: previous / next as two equal buttons at the edges (a Link may sit between them; the PagerAt dots never do — they go under the card) · `tiers`: rows stacked — a control tier above the buttons */
3
+ /** `pager`: previous / next as two equal buttons at the edges (a Link may sit between them) · `tiers`: rows stacked — a control tier above the buttons */
4
4
  variant?: 'pager' | 'tiers';
5
5
  /** a bare bar for a preview: no position, full width, no side padding (a real bar is a Screen's `bottom`) */
6
6
  inline?: boolean;
@@ -8,8 +8,8 @@ export interface ActionBarProps extends ComponentProps<'div'> {
8
8
  /**
9
9
  * ActionBar — the screen's ONE move: the Screen's `bottom` (`<Screen fill bottom={<ActionBar…>}>`), the last part of its flex
10
10
  * column, as tall as its rows. Never after `</Screen>`, never positioned by hand.
11
- * Plain: one wide primary Btn (the step that commits). `pager`: Back + Next as peers — buttons only; where you are is the
12
- * PagerAt under the card in a Centre group, never in the bar (Lh, 2026-09-12).
11
+ * Plain: one wide primary Btn (the step that commits).
12
+ * `pager`: Back + Next as peers — buttons only. The PagerAt dots may be the bar's first row (a Walkthrough).
13
13
  * `tiers`: an ActionBarTier with a control (a Slider) above the button tier.
14
14
  * In a `Screen fill` it is a layer over the content's foot; on a scrolling Screen a sticky last row.
15
15
  */
@@ -3,8 +3,8 @@ import { cx } from '../cx.js';
3
3
  /**
4
4
  * ActionBar — the screen's ONE move: the Screen's `bottom` (`<Screen fill bottom={<ActionBar…>}>`), the last part of its flex
5
5
  * column, as tall as its rows. Never after `</Screen>`, never positioned by hand.
6
- * Plain: one wide primary Btn (the step that commits). `pager`: Back + Next as peers — buttons only; where you are is the
7
- * PagerAt under the card in a Centre group, never in the bar (Lh, 2026-09-12).
6
+ * Plain: one wide primary Btn (the step that commits).
7
+ * `pager`: Back + Next as peers — buttons only. The PagerAt dots may be the bar's first row (a Walkthrough).
8
8
  * `tiers`: an ActionBarTier with a control (a Slider) above the button tier.
9
9
  * In a `Screen fill` it is a layer over the content's foot; on a scrolling Screen a sticky last row.
10
10
  */
@@ -8,7 +8,7 @@ export interface CentreProps extends ComponentProps<'div'> {
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
10
10
  * and scrolling inside, for a screen that must never scroll. Its children stack at the screen's rhythm.
11
- * Put it after the TopBar; the ActionBar, if any, stays outside the Screen.
11
+ * Put it after the TopBar; the ActionBar, if any, is the Screen's `bottom`.
12
12
  */
13
13
  export declare function Centre({ className, ...rest }: CentreProps): import("react").JSX.Element;
14
14
  export interface PagesProps extends ComponentProps<'div'> {
@@ -8,7 +8,7 @@ import { cx } from '../cx.js';
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
10
10
  * and scrolling inside, for a screen that must never scroll. Its children stack at the screen's rhythm.
11
- * Put it after the TopBar; the ActionBar, if any, stays outside the Screen.
11
+ * Put it after the TopBar; the ActionBar, if any, is the Screen's `bottom`.
12
12
  */
13
13
  export function Centre({ className, ...rest }) {
14
14
  return _jsx("div", { className: cx('screen__centre', className), ...rest });
@@ -7,5 +7,5 @@ export interface PagerAtProps extends Omit<ComponentProps<'span'>, 'children'> {
7
7
  /** accessible name; defaults to "Page {at} of {of}" */
8
8
  label?: string;
9
9
  }
10
- /** PagerAt — the "where am I" dots: one accent dot at the current page. Under the card that changes (a child of Centre), or a Step's child after its Pages — pinned to the base's foot. One per walk, never inside an ActionBar, never inside a page. */
10
+ /** PagerAt — the "where am I" dots: one accent dot at the current page. Under the card that changes (a child of Centre), or a Step's child after its Pages — pinned to the base's foot. One per walk, never inside a page; in a Walkthrough it is the bottom layer's first row (an ActionBar child, centred). */
11
11
  export declare function PagerAt({ at, of, label, className, ...rest }: PagerAtProps): import("react").JSX.Element;
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { cx } from '../cx.js';
3
- /** PagerAt — the "where am I" dots: one accent dot at the current page. Under the card that changes (a child of Centre), or a Step's child after its Pages — pinned to the base's foot. One per walk, never inside an ActionBar, never inside a page. */
3
+ /** PagerAt — the "where am I" dots: one accent dot at the current page. Under the card that changes (a child of Centre), or a Step's child after its Pages — pinned to the base's foot. One per walk, never inside a page; in a Walkthrough it is the bottom layer's first row (an ActionBar child, centred). */
4
4
  export function PagerAt({ at, of, label, className, ...rest }) {
5
5
  return (_jsx("span", { className: cx('pager__at', className), role: "img", "aria-label": label ?? `Page ${at} of ${of}`, ...rest, children: Array.from({ length: of }, (_, i) => _jsx("i", { className: i + 1 === at ? 'is-at' : undefined }, i)) }));
6
6
  }
@@ -1,5 +1,5 @@
1
1
  import { createElement, useEffect, useRef } from 'react';
2
- import { warnOnce } from '../state.js';
2
+ import { warnOnce, watchSize } from '../state.js';
3
3
  import { cx } from '../cx.js';
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
@@ -24,9 +24,7 @@ export function Screen({ fill, palette, as = 'div', top, bottom, className, chil
24
24
  el?.style.removeProperty('--bottom-h');
25
25
  return;
26
26
  }
27
- const ro = new ResizeObserver(() => el.style.setProperty('--bottom-h', `${bar.offsetHeight}px`));
28
- ro.observe(bar);
29
- return () => ro.disconnect();
27
+ return watchSize([bar], () => el.style.setProperty('--bottom-h', `${bar.offsetHeight}px`));
30
28
  }, [bottom]);
31
29
  const setRef = (node) => {
32
30
  own.current = node;
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Children, Fragment, useEffect, useRef } from 'react';
3
+ import { useLatest } from '../state.js';
3
4
  import { cx } from '../cx.js';
4
5
  /**
5
6
  * Modal — the ask: ONE card at the centre of a `SheetStage dim`, two parts — a `Lift` (the thing being asked about, light, scrolls
@@ -11,8 +12,7 @@ import { cx } from '../cx.js';
11
12
  */
12
13
  export function Modal({ open = true, onClosed, onDismiss, className, ...rest }) {
13
14
  const ref = useRef(null);
14
- const closed = useRef(onClosed);
15
- closed.current = onClosed;
15
+ const closed = useLatest(onClosed);
16
16
  const shown = useRef(true); // the animations stand played forwards
17
17
  useEffect(() => {
18
18
  const el = ref.current;
@@ -28,8 +28,7 @@ export function Modal({ open = true, onClosed, onDismiss, className, ...rest })
28
28
  closed.current?.(); });
29
29
  return () => { live = false; };
30
30
  }, [open]);
31
- const dismiss = useRef(onDismiss);
32
- dismiss.current = onDismiss;
31
+ const dismiss = useLatest(onDismiss);
33
32
  const has = onDismiss !== undefined;
34
33
  useEffect(() => {
35
34
  const el = ref.current, stage = el?.parentElement;
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useCallback, useEffect, useRef } from 'react';
3
- import { useControlled } from '../state.js';
3
+ import { useControlled, watchSize } from '../state.js';
4
4
  import { cx } from '../cx.js';
5
5
  const ORDER = ['peek', 'half', '3q', 'full']; // low → high; `away` is below them all and outside them
6
6
  const TAP_REM = 0.5; // = --sp-2: a move shorter than this is a tap, not a drag
@@ -17,7 +17,7 @@ const TAP_REM = 0.5; // = --sp-2: a move shorter than this is a tap, not a drag
17
17
  * climbs until the focused control has room above the foot.
18
18
  */
19
19
  export function Sheet({ state, defaultState = 'half', onStateChange, states, handle = true, className, children, onFocus, ...rest }) {
20
- const [current, setOwn] = useControlled(state, defaultState);
20
+ const [current, request] = useControlled(state, defaultState, onStateChange);
21
21
  const ref = useRef(null);
22
22
  const handleRef = useRef(null);
23
23
  const home = useRef(current !== 'peek' && current !== 'away' ? current : 'half'); // where a tap from peek returns to
@@ -28,12 +28,6 @@ export function Sheet({ state, defaultState = 'half', onStateChange, states, han
28
28
  const list = (typeof states === 'string' ? states.split(/\s+/) : [...(states ?? [])]).filter((s) => ORDER.includes(s));
29
29
  return list.length ? ORDER.filter((s) => list.includes(s)) : [...ORDER];
30
30
  }, [states]);
31
- const request = useCallback((next, reason) => {
32
- if (next === current)
33
- return;
34
- setOwn(next);
35
- onStateChange?.(next, reason);
36
- }, [current, setOwn, onStateChange]);
37
31
  useEffect(() => {
38
32
  if (current !== 'peek' && current !== 'away')
39
33
  home.current = current;
@@ -103,10 +97,10 @@ export function Sheet({ state, defaultState = 'half', onStateChange, states, han
103
97
  in this sheet has the focus, make sure it still shows */
104
98
  useEffect(() => {
105
99
  const el = ref.current;
106
- if (!el || typeof ResizeObserver === 'undefined')
100
+ if (!el)
107
101
  return;
108
- let first = true;
109
- const ro = new ResizeObserver(() => {
102
+ let first = true; // the browser's report at the start is not a resize
103
+ return watchSize([el, el.parentElement], () => {
110
104
  if (first) {
111
105
  first = false;
112
106
  return;
@@ -115,10 +109,6 @@ export function Sheet({ state, defaultState = 'half', onStateChange, states, han
115
109
  if (at && el.contains(at))
116
110
  reveal(at, 'keyboard');
117
111
  });
118
- ro.observe(el);
119
- if (el.parentElement)
120
- ro.observe(el.parentElement);
121
- return () => ro.disconnect();
122
112
  }, [reveal]);
123
113
  /* the peek strip holds the handle and the WHOLE title row. CSS gives the box the strip for a one-line head (--sheet-peek in
124
114
  sheet.css); when the head is taller than that floor — a title wrapped on a narrow phone at a large text size — the sheet
@@ -126,7 +116,7 @@ export function Sheet({ state, defaultState = 'half', onStateChange, states, han
126
116
  22-base type scale). Removed when the head fits the floor again, so the CSS number is the one in force. */
127
117
  useEffect(() => {
128
118
  const el = ref.current, box = el?.parentElement, head = el?.querySelector(':scope > .card__head, :scope > .sheet__head');
129
- if (!el || !box || !head || typeof ResizeObserver === 'undefined')
119
+ if (!el || !box || !head)
130
120
  return;
131
121
  const publish = () => {
132
122
  box.style.removeProperty('--sheet-peek'); // read the CSS strip, not our own
@@ -136,9 +126,8 @@ export function Sheet({ state, defaultState = 'half', onStateChange, states, han
136
126
  if (over > 0.5)
137
127
  box.style.setProperty('--sheet-peek', `${strip + over}px`);
138
128
  };
139
- const ro = new ResizeObserver(publish);
140
- ro.observe(head);
141
- return () => { ro.disconnect(); box.style.removeProperty('--sheet-peek'); };
129
+ const stop = watchSize([head], publish);
130
+ return () => { stop(); box.style.removeProperty('--sheet-peek'); };
142
131
  }, []);
143
132
  const focus = (e) => {
144
133
  onFocus?.(e);
@@ -21,7 +21,7 @@ export interface SheetStackProps extends ComponentProps<'div'> {
21
21
  * Children are Cards, direct children only, at most five: first = the back of the stack, last = the front (open) sheet.
22
22
  * Every sheet is the stack's full height; a peek is the strip the next sheet doesn't cover. A stack head is a CardHead
23
23
  * with CHILDREN, in this order: `<StackBack />` (tap mode), `<Text level="h2" caps>Shopping</Text>`, an `<IconRow>` of Icons —
24
- * never the `heading` prop (it wraps the h2 in a div; the stack's one-line peek needs the bare h2). Put it last in a `Screen fill`, it bleeds
24
+ * never the `heading` prop (a stack head is back · h2 · tabs on ONE line — StackSheet builds it). Put it last in a `Screen fill`, it bleeds
25
25
  * off the bottom. `closed`: nothing open on arrival — every head peeks at the bottom and the base above shows `base` (a home screen
26
26
  * with a picture over the menu); the `base` slot is rendered after the sheets, so it never takes a sheet's slot. This is the sheet STACK; a single bottom sheet is `Sheet`.
27
27
  *
@@ -30,4 +30,4 @@ export interface SheetStackProps extends ComponentProps<'div'> {
30
30
  * sheet's StackBack or its title (folds it). How the sheets MOVE is its own too: sheets are rigid — the tapped one slides up, the ones after it leave below, the ones
31
31
  * before tuck under nearest-first; closing reverses in two beats. Only `translate` (stack.css); it sets classes, never a size.
32
32
  */
33
- export declare function SheetStack({ mode, open, defaultOpen, onOpenChange, fanned, closed, base, className, children, onClick, ...rest }: SheetStackProps): import("react").JSX.Element;
33
+ export declare function SheetStack({ mode, open, defaultOpen, onOpenChange, fanned, closed, base, className, children, onClick, onKeyDown, ...rest }: SheetStackProps): import("react").JSX.Element;
@@ -14,7 +14,7 @@ const sheetsOf = (stack) => [...stack.children]
14
14
  * Children are Cards, direct children only, at most five: first = the back of the stack, last = the front (open) sheet.
15
15
  * Every sheet is the stack's full height; a peek is the strip the next sheet doesn't cover. A stack head is a CardHead
16
16
  * with CHILDREN, in this order: `<StackBack />` (tap mode), `<Text level="h2" caps>Shopping</Text>`, an `<IconRow>` of Icons —
17
- * never the `heading` prop (it wraps the h2 in a div; the stack's one-line peek needs the bare h2). Put it last in a `Screen fill`, it bleeds
17
+ * never the `heading` prop (a stack head is back · h2 · tabs on ONE line — StackSheet builds it). Put it last in a `Screen fill`, it bleeds
18
18
  * off the bottom. `closed`: nothing open on arrival — every head peeks at the bottom and the base above shows `base` (a home screen
19
19
  * with a picture over the menu); the `base` slot is rendered after the sheets, so it never takes a sheet's slot. This is the sheet STACK; a single bottom sheet is `Sheet`.
20
20
  *
@@ -23,23 +23,24 @@ const sheetsOf = (stack) => [...stack.children]
23
23
  * sheet's StackBack or its title (folds it). How the sheets MOVE is its own too: sheets are rigid — the tapped one slides up, the ones after it leave below, the ones
24
24
  * before tuck under nearest-first; closing reverses in two beats. Only `translate` (stack.css); it sets classes, never a size.
25
25
  */
26
- export function SheetStack({ mode = 'tap', open, defaultOpen = null, onOpenChange, fanned, closed, base, className, children, onClick, ...rest }) {
27
- const [current, setOwn] = useControlled(open, defaultOpen);
26
+ export function SheetStack({ mode = 'tap', open, defaultOpen = null, onOpenChange, fanned, closed, base, className, children, onClick, onKeyDown, ...rest }) {
27
+ const [current, request] = useControlled(open, defaultOpen, onOpenChange);
28
28
  const ref = useRef(null);
29
29
  const was = useRef(null);
30
30
  const untuck = useRef(null);
31
- const request = (next) => {
32
- if (next === current)
33
- return;
34
- setOwn(next);
35
- onOpenChange?.(next);
36
- };
37
31
  /* paint the state onto the sheets — after every render, so a re-rendered Card never loses its place */
38
32
  useLayoutEffect(() => {
39
33
  const stack = ref.current;
40
34
  if (!stack || mode !== 'tap')
41
35
  return;
42
36
  const sheets = sheetsOf(stack);
37
+ // the keyboard's way in: a head's title takes the focus where a tap on it would do something — every title while none is
38
+ // open (Enter opens that sheet), only the open one's while one is (Enter folds it)
39
+ sheets.forEach((c, i) => {
40
+ const title = c.querySelector(':scope > .card__head > :is(h1, h2, h3)');
41
+ if (title)
42
+ title.tabIndex = current == null || i === current ? 0 : -1;
43
+ });
43
44
  const prev = was.current;
44
45
  was.current = current;
45
46
  if (current != null) {
@@ -86,5 +87,12 @@ export function SheetStack({ mode = 'tap', open, defaultOpen = null, onOpenChang
86
87
  if (at >= 0 && current == null)
87
88
  request(at);
88
89
  };
89
- return (_jsxs("div", { ref: ref, onClick: click, className: cx('sheet-stack', mode !== 'static' && `sheet-stack--${mode}`, fanned && 'sheet-stack--fanned', closed && 'sheet-stack--closed', className), ...rest, children: [children, base != null && _jsx("div", { className: "sheet-stack__base", children: base })] }));
90
+ const key = (e) => {
91
+ onKeyDown?.(e);
92
+ if ((e.key === 'Enter' || e.key === ' ') && e.target.matches('.card__head > :is(h1, h2, h3)')) {
93
+ e.preventDefault();
94
+ e.target.click();
95
+ }
96
+ };
97
+ return (_jsxs("div", { ref: ref, onClick: click, onKeyDown: key, className: cx('sheet-stack', mode !== 'static' && `sheet-stack--${mode}`, fanned && 'sheet-stack--fanned', closed && 'sheet-stack--closed', className), ...rest, children: [children, base != null && _jsx("div", { className: "sheet-stack__base", children: base })] }));
90
98
  }