@usableapp/cardds 0.7.86 → 0.7.88

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.
@@ -23,6 +23,8 @@ export interface RowProps extends Omit<ComponentProps<'div'>, 'title'> {
23
23
  done?: boolean;
24
24
  /** div (default), a link, a label wrapping a Check/Toggle, li */
25
25
  as?: 'div' | 'a' | 'label' | 'li';
26
+ /** with `as="label"`: the `id` of the control the row presses (its `Check`). Needed when the `end` holds a button BEFORE the check (a picked row's own move, "open the map"): a label presses the first control inside it, so without this a touch on the row's words would press that button */
27
+ htmlFor?: string;
26
28
  /** makes the row a link (an `a`); not with `more`, whose row is a summary */
27
29
  href?: string;
28
30
  /** what the row EXPANDS to show under itself (who liked, the rest of a list): the row becomes a `<details>`' summary */
package/dist/lists/Row.js CHANGED
@@ -2,7 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { cloneElement, createElement, isValidElement, useId, useLayoutEffect, useRef } from 'react';
3
3
  import { cx } from '../cx.js';
4
4
  import { Icon } from '../type/Icon.js';
5
- import { reducedMotion, useDisclosure } from '../state.js';
5
+ import { reducedMotion, setRefs, useDisclosure } from '../state.js';
6
6
  /**
7
7
  * Row — a frameless row inside a card: lead · title + meta · end. 48px+ tall, a tap target.
8
8
  * Several go in Rows (hairlines between). A row that IS a card is `Card row`.
@@ -56,12 +56,5 @@ export function Rows({ heading, className, children, ref, ...rest }) {
56
56
  was.current = at;
57
57
  order.current = rows;
58
58
  });
59
- const setRef = (node) => {
60
- own.current = node;
61
- if (typeof ref === 'function')
62
- ref(node);
63
- else if (ref)
64
- ref.current = node;
65
- };
66
- return (_jsxs("div", { ref: setRef, className: cx('rows', className), role: named ? 'group' : undefined, "aria-labelledby": named ? id : undefined, ...rest, children: [named && (isValidElement(heading) ? cloneElement(heading, { id }) : _jsx("span", { className: "rows__head", id: id, children: heading })), children] }));
59
+ return (_jsxs("div", { ref: setRefs(own, ref), 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] }));
67
60
  }
@@ -0,0 +1,8 @@
1
+ import type { ComponentProps } from 'react';
2
+ /** a box that is a `<button>` when it can be pressed (`onClick` given), else `still` — the tag to render and what to spread on it:
3
+ * `type="button"`, the onClick and `extra` (attributes a button alone takes, `aria-pressed`) before `rest`. `tap` says which it is */
4
+ export declare function pressable<E extends 'span' | 'div'>(onClick: (() => void) | undefined, still: E, rest: ComponentProps<E>, extra?: ComponentProps<'button'>): {
5
+ tap: boolean;
6
+ Box: E;
7
+ boxProps: ComponentProps<E>;
8
+ };
@@ -0,0 +1,6 @@
1
+ /** a box that is a `<button>` when it can be pressed (`onClick` given), else `still` — the tag to render and what to spread on it:
2
+ * `type="button"`, the onClick and `extra` (attributes a button alone takes, `aria-pressed`) before `rest`. `tap` says which it is */
3
+ export function pressable(onClick, still, rest, extra) {
4
+ const tap = onClick != null;
5
+ return { tap, Box: (tap ? 'button' : still), boxProps: (tap ? { type: 'button', onClick, ...extra, ...rest } : rest) };
6
+ }
@@ -10,5 +10,6 @@ export interface EmptyStateProps extends Omit<ComponentProps<'div'>, 'title'> {
10
10
  level?: 'h2' | 'h3';
11
11
  }
12
12
  /** EmptyState — centred: a large icon, a heading, a line of body text, an optional CTA. A component in the content: alone in a `Step` it stands at the content's human centre, 40% down (Lh 2026-09-23 — no longer a `Centre` over the screen)
13
- * or inside a card's section ("nothing here yet" under a heading) — the ink follows the card it sits in; full ink throughout, never muted (Lh 2026-09-16). */
13
+ * or inside a card's section ("nothing here yet" under a heading) — the ink follows the card it sits in; full ink throughout, never muted (Lh 2026-09-16).
14
+ * Knob `--empty-state-at`, a number 0–1: the share of the holder's free room that stands above it. In a `Step` the default is 0.4 (two parts above, three below); alone in a `SheetBody` it is 0 (at the top). A theme's `:root { --empty-state-at: 0.2 }` lifts it everywhere, or set it on a `Screen`, `Step` or `Sheet` for the ones inside. */
14
15
  export declare function EmptyState({ icon, title, action, level, className, children, ...rest }: EmptyStateProps): import("react").JSX.Element;
@@ -1,7 +1,8 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { cx } from '../cx.js';
3
3
  /** EmptyState — centred: a large icon, a heading, a line of body text, an optional CTA. A component in the content: alone in a `Step` it stands at the content's human centre, 40% down (Lh 2026-09-23 — no longer a `Centre` over the screen)
4
- * or inside a card's section ("nothing here yet" under a heading) — the ink follows the card it sits in; full ink throughout, never muted (Lh 2026-09-16). */
4
+ * or inside a card's section ("nothing here yet" under a heading) — the ink follows the card it sits in; full ink throughout, never muted (Lh 2026-09-16).
5
+ * Knob `--empty-state-at`, a number 0–1: the share of the holder's free room that stands above it. In a `Step` the default is 0.4 (two parts above, three below); alone in a `SheetBody` it is 0 (at the top). A theme's `:root { --empty-state-at: 0.2 }` lifts it everywhere, or set it on a `Screen`, `Step` or `Sheet` for the ones inside. */
5
6
  export function EmptyState({ icon, title, action, level = 'h2', className, children, ...rest }) {
6
7
  const H = level;
7
8
  return (_jsxs("div", { className: cx('empty-state', className), ...rest, children: [icon, title != null && _jsx(H, { className: "t-h2", children: title }), typeof children === 'string' ? _jsx("p", { className: "t-body", children: children }) : children, action != null && _jsx("div", { className: "empty-state__action", children: action })] }));
@@ -1,5 +1,5 @@
1
1
  import { createElement, useEffect, useRef } from 'react';
2
- import { warnOnce, watchSize } from '../state.js';
2
+ import { setRefs, 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
@@ -39,12 +39,5 @@ export function Screen({ fill, palette, as = 'div', top, bottom, ready = true, c
39
39
  }
40
40
  return watchSize([lead], () => el.style.setProperty('--top-h', `${lead.offsetHeight}px`));
41
41
  }, [top, ready]);
42
- const setRef = (node) => {
43
- own.current = node;
44
- if (typeof ref === 'function')
45
- ref(node);
46
- else if (ref)
47
- ref.current = node;
48
- };
49
- return createElement(as, { className: cx('screen', fill && 'screen--fill', top === 'lead' && 'screen--lead', className), 'data-palette': palette, 'data-pending': ready ? undefined : '', 'aria-busy': ready ? undefined : true, ref: setRef, ...rest }, ready && top !== 'lead' ? top : null, ready ? children : null, ready ? bottom : null);
42
+ return createElement(as, { className: cx('screen', fill && 'screen--fill', top === 'lead' && 'screen--lead', className), 'data-palette': palette, 'data-pending': ready ? undefined : '', 'aria-busy': ready ? undefined : true, ref: setRefs(own, ref), ...rest }, ready && top !== 'lead' ? top : null, ready ? children : null, ready ? bottom : null);
50
43
  }
@@ -12,6 +12,7 @@ import { Text } from '../type/Text.js';
12
12
  import { Icon } from '../type/Icon.js';
13
13
  import { Badge } from '../numbers/Badge.js';
14
14
  import { word } from '../words.js';
15
+ import { runOver } from './sheetMeasure.js';
15
16
  /**
16
17
  * StackSheet — ONE sheet of a `SheetStack`, in the one sheet shape: SHEET HEAD (back · title · tabs or an icon) pinned at the top ·
17
18
  * SHEET BODY (the children; scrolls) · SHEET FOOT (`foot`, the CTA) at the bottom. Its tabs are icon buttons. The chosen one is filled
@@ -22,19 +23,14 @@ import { word } from '../words.js';
22
23
  export function StackSheet({ title, tone, paper, icon, tabs, tab: tabProp, defaultTab, onTabChange, foot, backLabel = word('back'), backAt = 'none', stack, className, children, ...rest }) {
23
24
  const [tab, pick] = useControlled(tabProp, defaultTab ?? tabs?.[0]?.id, onTabChange);
24
25
  // 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
25
- // is measured here (the tabs may grow with the text size) and handed to the CSS as --_over
26
+ // is measured here (the tabs may grow with the text size) and handed to the CSS as --_over / --_run, the Sheet's measurer
26
27
  const titleRef = useRef(null);
27
28
  const [long, setLong] = useState(false);
28
29
  useEffect(() => {
29
30
  const h = titleRef.current, run = h?.firstElementChild;
30
31
  if (!h || !run)
31
32
  return;
32
- const measure = () => {
33
- const over = Math.max(0, Math.ceil(run.scrollWidth - h.clientWidth));
34
- h.style.setProperty('--_over', `${over}px`);
35
- h.style.setProperty('--_run', `${(over / 30 + 3).toFixed(1)}s`);
36
- setLong(over > 0);
37
- };
33
+ const measure = () => setLong(runOver(h, run));
38
34
  measure();
39
35
  return watchSize([h, run], measure);
40
36
  }, [title]);
@@ -5,6 +5,7 @@ type SheetRef = RefObject<HTMLElement | null>;
5
5
  export declare const slid: (el: HTMLElement) => number;
6
6
  export declare const stopsOf: (el: HTMLElement) => Record<Resting, number>;
7
7
  export declare function usePeekStrip(ref: SheetRef): void;
8
+ export declare function runOver(line: HTMLElement, words?: Node): boolean;
8
9
  export declare function useTitleRun(ref: SheetRef, onLong: (long: boolean) => void): void;
9
10
  export declare function useFootRoom(ref: SheetRef): void;
10
11
  export {};
@@ -38,9 +38,19 @@ export function usePeekStrip(ref) {
38
38
  return () => { stop(); box.style.removeProperty('--sheet-peek'); };
39
39
  }, []);
40
40
  }
41
- /* the TITLE is one line; too long for it, it runs sideways like a song title (Lh 2026-10-03): the sheet measures how far its
42
- words overflow the line and hands the widths to the CSS (--_over, --_run). The heading renders `.is-long` from what this
43
- reports. Measured on the words themselves (a range), so the running indent never feeds back into the number. */
41
+ /* a TITLE is one line; too long for it, it runs sideways like a song title (Lh 2026-09-24 for the StackSheet, 2026-10-03 for the Sheet):
42
+ how far its `words` overflow the `line` they stand on, published on the line for the CSS — `--_over` (px) and `--_run` (30px a
43
+ second plus the pauses). Measured on the words themselves (a range), so the running indent or translate never feeds back into
44
+ the number. Returns whether it is long; the heading renders `.is-long` from that. */
45
+ export function runOver(line, words = line) {
46
+ const range = document.createRange();
47
+ range.selectNodeContents(words);
48
+ const over = Math.max(0, Math.ceil(range.getBoundingClientRect().width - line.clientWidth));
49
+ line.style.setProperty('--_over', `${over}px`);
50
+ line.style.setProperty('--_run', `${(over / 30 + 3).toFixed(1)}s`);
51
+ return over > 0;
52
+ }
53
+ /* the Sheet's title: the app's own heading, measured whole (sheet.css runs it with text-indent) */
44
54
  export function useTitleRun(ref, onLong) {
45
55
  useEffect(() => {
46
56
  const el = ref.current, title = el?.querySelector(':scope > .card__head > :is(h1, h2, h3):first-child, :scope > .sheet__head .sheet__title');
@@ -48,12 +58,7 @@ export function useTitleRun(ref, onLong) {
48
58
  return;
49
59
  let was = false;
50
60
  const measure = () => {
51
- const words = document.createRange();
52
- words.selectNodeContents(title);
53
- const over = Math.max(0, Math.ceil(words.getBoundingClientRect().width - title.clientWidth));
54
- title.style.setProperty('--_over', `${over}px`);
55
- title.style.setProperty('--_run', `${(over / 30 + 3).toFixed(1)}s`);
56
- const long = over > 0;
61
+ const long = runOver(title);
57
62
  if (long !== was) {
58
63
  was = long;
59
64
  onLong(long);
@@ -46,6 +46,31 @@ const pullsHead = (target, head) => {
46
46
  return false;
47
47
  return target === head || !!target.closest('.sheet-stack__back') || !!target.closest('.card__head > :is(h1, h2, h3)');
48
48
  };
49
+ /* once a finger is down the DOCUMENT hears it, not the head or the body it started on: the finger leaves them while it drags.
50
+ One set of move / up / cancel at a time; `stop` takes the set off, and is safe to call with none on */
51
+ const docPointer = () => {
52
+ let stop = () => { };
53
+ return {
54
+ listen: (move, up, cancel) => {
55
+ stop();
56
+ document.addEventListener('pointermove', move, { passive: false });
57
+ document.addEventListener('pointerup', up);
58
+ document.addEventListener('pointercancel', cancel);
59
+ stop = () => {
60
+ document.removeEventListener('pointermove', move);
61
+ document.removeEventListener('pointerup', up);
62
+ document.removeEventListener('pointercancel', cancel);
63
+ stop = () => { };
64
+ };
65
+ },
66
+ stop: () => stop(),
67
+ };
68
+ };
69
+ /* a pull that moved ends in a click the browser still fires: the card swallows it for one frame */
70
+ const armSwallow = (swallow) => {
71
+ swallow.current = true;
72
+ requestAnimationFrame(() => { swallow.current = false; });
73
+ };
49
74
  /**
50
75
  * The card's own pull, refresh and focus. Listeners sit on this card's head and body.
51
76
  * `--_pull` / `--_lift` are written while a finger is down, the same way a single Sheet writes `--_drag`.
@@ -86,24 +111,8 @@ export function useStackCard(ref, place, swallow) {
86
111
  const stack = card.closest('.sheet-stack');
87
112
  const head = card.querySelector(':scope > .card__head');
88
113
  const scroller = card.querySelector(':scope > .sheet__body') ?? card;
89
- let stopDoc = () => { };
90
- const listen = (move, up, cancel) => {
91
- stopDoc();
92
- document.addEventListener('pointermove', move, { passive: false });
93
- document.addEventListener('pointerup', up);
94
- document.addEventListener('pointercancel', cancel);
95
- stopDoc = () => {
96
- document.removeEventListener('pointermove', move);
97
- document.removeEventListener('pointerup', up);
98
- document.removeEventListener('pointercancel', cancel);
99
- stopDoc = () => { };
100
- };
101
- };
114
+ const doc = docPointer();
102
115
  const primary = (e) => e.isPrimary && e.button === 0;
103
- const armSwallow = () => {
104
- swallow.current = true;
105
- requestAnimationFrame(() => { swallow.current = false; });
106
- };
107
116
  const release = (prop) => {
108
117
  live.current = null;
109
118
  card.classList.remove('is-dragging');
@@ -144,7 +153,7 @@ export function useStackCard(ref, place, swallow) {
144
153
  const dy = Math.max(pull.last, along('down', pull.startY, ev.clientY));
145
154
  const moved = pull.moved || dy >= pull.slop;
146
155
  headPull.current = null;
147
- stopDoc();
156
+ doc.stop();
148
157
  if (moved && dy >= pull.commit) {
149
158
  card.classList.remove('is-dragging');
150
159
  readFrame(card);
@@ -156,16 +165,16 @@ export function useStackCard(ref, place, swallow) {
156
165
  else
157
166
  release('--_pull');
158
167
  if (moved)
159
- armSwallow();
168
+ armSwallow(swallow);
160
169
  };
161
170
  const onCancel = (ev) => {
162
171
  if (ev.pointerId !== pull.pointerId || headPull.current !== pull)
163
172
  return;
164
173
  headPull.current = null;
165
- stopDoc();
174
+ doc.stop();
166
175
  release('--_pull');
167
176
  };
168
- listen(onMove, onUp, onCancel);
177
+ doc.listen(onMove, onUp, onCancel);
169
178
  };
170
179
  const onBodyDown = (e) => {
171
180
  const ask = placeRef.current;
@@ -220,22 +229,22 @@ export function useStackCard(ref, place, swallow) {
220
229
  const dy = Math.max(pull.lastDy, Math.max(0, ev.clientY - pull.startY));
221
230
  const go = pull.moved && !pull.drop && scroller.scrollTop <= 0 && dy >= pull.commit;
222
231
  bodyPull.current = null;
223
- stopDoc();
232
+ doc.stop();
224
233
  readFrame(scroller);
225
234
  finish();
226
235
  if (go)
227
236
  placeRef.current?.onRefresh?.();
228
237
  if (pull.moved)
229
- armSwallow();
238
+ armSwallow(swallow);
230
239
  };
231
240
  const onCancel = (ev) => {
232
241
  if (ev.pointerId !== pull.pointerId || bodyPull.current !== pull)
233
242
  return;
234
243
  bodyPull.current = null;
235
- stopDoc();
244
+ doc.stop();
236
245
  finish();
237
246
  };
238
- listen(onMove, onUp, onCancel);
247
+ doc.listen(onMove, onUp, onCancel);
239
248
  };
240
249
  /* a finger: the browser takes a touch for its own scroll at the first move unless that move is refused here */
241
250
  let touch = null;
@@ -264,7 +273,7 @@ export function useStackCard(ref, place, swallow) {
264
273
  return () => {
265
274
  scroller.removeEventListener('touchstart', onTouchStart);
266
275
  scroller.removeEventListener('touchmove', onTouchMove);
267
- stopDoc();
276
+ doc.stop();
268
277
  headPull.current = null;
269
278
  bodyPull.current = null;
270
279
  scroller.style.removeProperty('--_refresh');
@@ -285,23 +294,7 @@ export function useStackCard(ref, place, swallow) {
285
294
  const head = card.querySelector(':scope > .card__head');
286
295
  if (!head || !stack)
287
296
  return;
288
- let stopDoc = () => { };
289
- const listen = (move, up, cancel) => {
290
- stopDoc();
291
- document.addEventListener('pointermove', move, { passive: false });
292
- document.addEventListener('pointerup', up);
293
- document.addEventListener('pointercancel', cancel);
294
- stopDoc = () => {
295
- document.removeEventListener('pointermove', move);
296
- document.removeEventListener('pointerup', up);
297
- document.removeEventListener('pointercancel', cancel);
298
- stopDoc = () => { };
299
- };
300
- };
301
- const armSwallow = () => {
302
- swallow.current = true;
303
- requestAnimationFrame(() => { swallow.current = false; });
304
- };
297
+ const doc = docPointer();
305
298
  const release = () => {
306
299
  live.current = null;
307
300
  card.classList.remove('is-dragging');
@@ -365,7 +358,7 @@ export function useStackCard(ref, place, swallow) {
365
358
  const upPx = Math.max(pull.last, along('up', pull.startY, ev.clientY));
366
359
  const moved = pull.moved || upPx >= pull.slop;
367
360
  headPull.current = null;
368
- stopDoc();
361
+ doc.stop();
369
362
  if (moved && upPx >= pull.commit) {
370
363
  card.classList.remove('is-dragging');
371
364
  readFrame(card);
@@ -377,20 +370,20 @@ export function useStackCard(ref, place, swallow) {
377
370
  else
378
371
  release();
379
372
  if (moved)
380
- armSwallow();
373
+ armSwallow(swallow);
381
374
  };
382
375
  const onCancel = (ev) => {
383
376
  if (ev.pointerId !== pull.pointerId || headPull.current !== pull)
384
377
  return;
385
378
  headPull.current = null;
386
- stopDoc();
379
+ doc.stop();
387
380
  release();
388
381
  };
389
- listen(onMove, onUp, onCancel);
382
+ doc.listen(onMove, onUp, onCancel);
390
383
  };
391
384
  head.addEventListener('pointerdown', onDown);
392
385
  return () => {
393
- stopDoc();
386
+ doc.stop();
394
387
  headPull.current = null;
395
388
  if (live.current?.prop === '--_lift')
396
389
  release();
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { cx } from '../cx.js';
3
+ import { pressable } from '../pressable.js';
3
4
  /**
4
5
  * Stamp — a perforated paper stamp, 4:5: the pictures fill the inner frame on a tone, the words sit on the picture's foot.
5
6
  * Every stamp is ONE size wherever it stands (`--stamp-size`, two taps wide: 96 × 120) — what holds it only places it; a holder narrower than that shows it whole, smaller.
@@ -8,9 +9,7 @@ import { cx } from '../cx.js';
8
9
  */
9
10
  export function Stamp({ pictures, tone = 'custard', name, words, bare, tilt, onClick, className, ...rest }) {
10
11
  const shown = pictures.slice(0, 4);
11
- const tap = onClick != null;
12
- const Box = (tap ? 'button' : 'span');
13
- const boxProps = (tap ? { type: 'button', onClick, ...rest } : rest);
12
+ const { tap, Box, boxProps } = pressable(onClick, 'span', rest);
14
13
  return (_jsx(Box, { className: cx('stamp', `stamp--${shown.length}`, bare && 'stamp--bare', tilt && `stamp--tilt-${tilt}`, tap && 'stamp--tap', className), ...boxProps, children: _jsx("span", { className: "stamp__paper", children: _jsxs("span", { className: cx('stamp__pic', `sticky--${tone}`), children: [shown.map((picture, i) => _jsx("span", { className: "stamp__art", "aria-hidden": "true", children: picture }, i)), (name != null || words != null) && (_jsxs("span", { className: "stamp__cap", children: [words != null && _jsx("span", { className: "stamp__words", children: words }), name != null && _jsx("span", { className: "stamp__name", children: name })] }))] }) }) }));
15
14
  }
16
15
  /**
@@ -19,8 +18,6 @@ export function Stamp({ pictures, tone = 'custard', name, words, bare, tilt, onC
19
18
  * and a `--stamp-place-dash` above 0 stretches each dot into a dash.
20
19
  */
21
20
  export function StampPlace({ faint, onClick, className, children, ...rest }) {
22
- const tap = onClick != null;
23
- const Box = (tap ? 'button' : 'span');
24
- const boxProps = (tap ? { type: 'button', onClick, ...rest } : rest);
21
+ const { Box, boxProps } = pressable(onClick, 'span', rest);
25
22
  return (_jsxs(Box, { className: cx('stamp-place', faint && 'stamp-place--faint', className), ...boxProps, children: [_jsx("svg", { className: "stamp-place__edge", "aria-hidden": "true", focusable: "false", children: _jsx("rect", {}) }), children] }));
26
23
  }
@@ -1,6 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Children } from 'react';
3
3
  import { cx } from '../cx.js';
4
+ import { pressable } from '../pressable.js';
4
5
  import { Icon } from '../type/Icon.js';
5
6
  import { StampPlace } from './Stamp.js';
6
7
  /** StampBook — the album page: StampCells three across on an album ground, the free places drawn empty up to `places`. The book places its stamps and never sizes them: each is the one size of a stamp, in the middle of its third. */
@@ -10,8 +11,6 @@ export function StampBook({ places = 0, strip, bare, className, children, ...res
10
11
  }
11
12
  /** StampCell — one place of a StampBook: a Stamp (the child) and a short label under it. */
12
13
  export function StampCell({ label, onClick, on, className, children, ...rest }) {
13
- const tap = onClick != null;
14
- const Box = (tap ? 'button' : 'span');
15
- const boxProps = (tap ? { type: 'button', onClick, 'aria-pressed': on, ...rest } : rest);
14
+ const { Box, boxProps } = pressable(onClick, 'span', rest, { 'aria-pressed': on });
16
15
  return (_jsxs(Box, { className: cx('stamp-cell', className), ...boxProps, children: [children, label != null && _jsx("span", { className: "stamp-cell__label", children: label }), on && _jsx("span", { className: "stamp-cell__on", "aria-hidden": "true", children: _jsx(Icon, { name: "check" }) })] }));
17
16
  }
@@ -18,7 +18,7 @@ function within(el, x, y) {
18
18
  const mx = el.offsetWidth / 2 / box.clientWidth, my = el.offsetHeight / 2 / box.clientHeight;
19
19
  return { x: round(clamp(x, mx, 1 - mx)), y: round(clamp(y, my, 1 - my)) };
20
20
  }
21
- const STRIKE = 'stamp-on-strike'; // css/stamp.css
21
+ const STRIKE = 'strike'; // css/tokens.css — the strike every mark struck onto paper reads
22
22
  const write = (el, at) => { el.style.setProperty('--stamp-on-x', String(at.x)); el.style.setProperty('--stamp-on-y', String(at.y)); };
23
23
  /**
24
24
  * StampOn — a Stamp (the child) stuck on a surface at the place the member chose. The surface is the nearest positioned box:
package/dist/state.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { type SyntheticEvent } from 'react';
1
+ import { type Ref, type SyntheticEvent } from 'react';
2
2
  /** controlled like an input: `value` given = the app owns it; undefined = the component keeps its own, starting at `initial`.
3
3
  * Returns the value shown and `request(next, …extra)`: nothing when `next` is what shows; else it moves the component's own copy
4
4
  * (uncontrolled only) and ASKS the app — `onChange(next, …extra)`. `request` keeps one identity for the component's life. */
@@ -12,6 +12,10 @@ export declare function useDisclosure(open: boolean | undefined, defaultOpen?: b
12
12
  open: boolean;
13
13
  onToggle: (e: SyntheticEvent<HTMLDetailsElement>) => void;
14
14
  };
15
+ /** the node to the component's own ref AND the caller's `ref` (a function or an object: React 19 passes it as a prop). Give the result to the element's `ref` */
16
+ export declare const setRefs: <T extends Element>(own: {
17
+ current: Element | null;
18
+ }, ref: Ref<T> | undefined) => (node: T | null) => void;
15
19
  /** the latest value in a ref — for a handler that a timer, an observer or a listener set up once calls later */
16
20
  export declare function useLatest<T>(value: T): import("react").RefObject<T>;
17
21
  /** watch boxes for a size change (the keyboard, a font arriving, a wrap): `fn` on each — once at the start too, as the browser
package/dist/state.js CHANGED
@@ -30,6 +30,14 @@ export function useDisclosure(open, defaultOpen = false, onOpenChange) {
30
30
  },
31
31
  };
32
32
  }
33
+ /** the node to the component's own ref AND the caller's `ref` (a function or an object: React 19 passes it as a prop). Give the result to the element's `ref` */
34
+ export const setRefs = (own, ref) => (node) => {
35
+ own.current = node;
36
+ if (typeof ref === 'function')
37
+ ref(node);
38
+ else if (ref)
39
+ ref.current = node;
40
+ };
33
41
  /** the latest value in a ref — for a handler that a timer, an observer or a listener set up once calls later */
34
42
  export function useLatest(value) {
35
43
  const ref = useRef(value);
@@ -1,5 +1,6 @@
1
1
  import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
2
  import { cx } from '../cx.js';
3
+ import { pressable } from '../pressable.js';
3
4
  import { word } from '../words.js';
4
5
  /**
5
6
  * Sticky — a paper note: a flat pastel SQUARE of one size (168, the knob `--sticky-size`), square corners, a soft shadow under it.
@@ -9,9 +10,7 @@ import { word } from '../words.js';
9
10
  */
10
11
  export function Sticky({ tone = 'custard', flat, unreadLabel, head, date, tilt, foot, corner, stuck, onClick, className, children, ...rest }) {
11
12
  const notched = corner != null;
12
- const tap = onClick != null && !notched;
13
- const Box = (tap ? 'button' : 'div');
13
+ const { tap, Box, boxProps } = pressable(notched ? undefined : onClick, 'div', rest);
14
14
  const Part = tap ? 'span' : 'div';
15
- const boxProps = (tap ? { type: 'button', onClick, ...rest } : rest);
16
15
  return (_jsxs(Box, { className: cx('sticky', `sticky--${tone}`, flat && 'sticky--flat', notched && 'sticky--corner', tilt && `sticky--tilt-${tilt}`, tap && 'sticky--tap', className), ...boxProps, children: [_jsxs(Part, { className: "sticky__paper", children: [(head != null || date != null) && _jsxs(Part, { className: "sticky__head", children: [head, date] }), _jsx(Part, { className: "sticky__body", children: children }), foot != null && _jsx(Part, { className: "sticky__foot", children: foot }), !flat && !notched && _jsx("span", { className: "sticky__fold", "aria-hidden": "true" }), !flat && !notched && _jsx("span", { className: "sticky__state t-sr", children: unreadLabel ?? word('unread') })] }), stuck != null && _jsx(Part, { className: "sticky__stuck", children: stuck }), notched && _jsx("div", { className: "sticky__corner", children: corner })] }));
17
16
  }
package/lib/catalog.mjs CHANGED
@@ -29,8 +29,10 @@ const representsOf = ({ represents }) => represents.filter((v) => v !== '—');
29
29
 
30
30
  /** the names only — the first call of nearly every session, so it stays small. `group` is the src folder; `represents`, `shape` and `interact` say what it is for */
31
31
  export const list = (cat) => cat.components.map(({ name, group, role }) => ({ name, group, represents: representsOf(role), shape: shapeOf(role), interact: interactOf(role) }));
32
+ /** the finishes of the paper (`data-finish`, css/finish.css): a style apart from colour, not a component — each with what it is and its knobs */
33
+ export const finishes = (cat) => cat.finishes ?? [];
32
34
 
33
- const sentence = (text) => {
35
+ export const sentence = (text) => {
34
36
  const flat = text.replace(/\s+/g, ' ').trim();
35
37
  const one = flat.match(/^.*?[.!?](?=\s|$)/)?.[0] ?? flat;
36
38
  return one.length > 160 ? `${one.slice(0, 157)}…` : one;
@@ -56,7 +58,7 @@ const closest = (names, wanted) => {
56
58
  return [...names].sort((a, b) => rank(a) - rank(b)).slice(0, 5);
57
59
  };
58
60
 
59
- /** one component: its props (a ReactNode prop is a slot) and its variations — the stories of its own file first, then a few that use it */
61
+ /** one component: its props (a ReactNode prop is a slot), what it composes and what composes it, its knobs, and its variations — the stories of its own file first, then a few that use it */
60
62
  export function get(cat, name, { html = false, all = false } = {}) {
61
63
  const c = cat.components.find((x) => x.name.toLowerCase() === String(name).toLowerCase());
62
64
  if (!c) return { error: `no component "${name}"`, closest: closest(cat.components.map((x) => x.name), String(name)) };
@@ -72,6 +74,9 @@ export function get(cat, name, { html = false, all = false } = {}) {
72
74
  element: c.element,
73
75
  props: c.props.map((p) => ({ ...p, ...(isSlot(p.type) && { slot: true }) })),
74
76
  ...(c.params && { params: c.params }),
77
+ composes: c.composes,
78
+ composedBy: c.composedBy,
79
+ knobs: c.knobs,
75
80
  variations: [...own, ...elsewhere.filter((s) => shown.includes(s))].map(show),
76
81
  ...(elsewhere.length > shown.length && { alsoUsedIn: elsewhere.filter((s) => !shown.includes(s)).map((s) => s.id) }),
77
82
  };
@@ -82,6 +87,7 @@ const STOP = new Set(['the', 'and', 'for', 'with', 'from', 'that', 'this', 'into
82
87
  const tokens = (query) => [...new Set(words(String(query)).filter((t) => t.length > 1 && !STOP.has(t)))];
83
88
  const stem = (t) => (t.length > 3 && t.endsWith('s') ? t.slice(0, -1) : t);
84
89
  const hits = (text, token) => words(text).some((w) => w.startsWith(token) || w.startsWith(stem(token)));
90
+ const isName = (name, token) => name.toLowerCase() === token || name.toLowerCase() === stem(token);
85
91
 
86
92
  const TOP = 8; // answers per kind; the components that match every axis are never cut
87
93
 
@@ -119,7 +125,7 @@ const matchesUnion = (roles, want) => roles.length > 0
119
125
  /** short ranked answer to "what is there for this?". `axes` ({ represents?, shape?, interact? } — what it represents, how many, what the member can do with it) are
120
126
  * asked first: whatever matches every one of them (a component, or a screen where one component does, then a screen whose parts do between them) ranks above whatever matches only words;
121
127
  * with axes and no words they are the whole answer. Among screens that tie on words, the one whose description says the axes comes first, then the one where a single component matches them all.
122
- * Then the words: screens already drawn, then components, then what they are used with.
128
+ * Then the words: screens already drawn, then components, then what they are used with — a screen that answers fewer of the words than the best component comes after the components.
123
129
  * A query that matches nothing (another language, say) returns `fallback: true`, a hint and how many screens are drawn — not their ids:
124
130
  * they did not help to choose; ask again in English or by the axes, or `scaffold` with no id lists them. */
125
131
  export function find(cat, query, axes = {}) {
@@ -129,11 +135,11 @@ export function find(cat, query, axes = {}) {
129
135
  const roles = new Map(cat.components.map((c) => [c.name, roleOf(c)]));
130
136
  const toks = tokens(query);
131
137
  const axisWords = [...(asked.want.represents ?? []), ...(asked.want.interact || [])]; // the axis values as words: a screen's own description may say them
132
- const rank = (items, fields, level, said = () => 0) => {
138
+ const rank = (items, fields, level, said = () => 0) => { // a field is [text, weight, match = hits]
133
139
  const scored = items.map((item) => {
134
140
  let matched = 0, score = 0;
135
141
  for (const t of toks) {
136
- const w = fields(item).filter(([text]) => hits(text, t)).reduce((sum, [, weight]) => sum + weight, 0);
142
+ const w = fields(item).filter(([text, , match = hits]) => match(text, t)).reduce((sum, [, weight]) => sum + weight, 0);
137
143
  if (w) { matched++; score += w; }
138
144
  }
139
145
  return { item, matched, score, level: active ? level(item) : 0, said: active ? said(item) : 0 };
@@ -152,9 +158,20 @@ export function find(cat, query, axes = {}) {
152
158
  };
153
159
  const said = (s) => axisWords.filter((v) => hits(s.description, v)).length;
154
160
  const [screenAxis, screenRest] = tier(rank(screens, (s) => [[s.id, 5], [s.description, 3], [s.uses.join(' '), 1]], level, said), TOP);
155
- const components = rank(cat.components, (c) => [[c.name, 6], [c.description, 2], [c.props.map((p) => p.name).join(' '), 1]], (c) => Number(matches(roles.get(c.name), asked.want)));
161
+ // a component is found by its name (the name that IS the word counts as much again as one that contains it: "card" is Card before FeedCard, whatever
162
+ // their other words say), its description, its role's note (what it is FOR, in the words of a request: "a setting turned on or off") and what it stands in for, then its prop names
163
+ const components = rank(cat.components, (c) => [[c.name, 6, isName], [c.name, 6], [c.description, 2], [c.role?.note ?? '', 2], [(c.role?.standsInFor ?? []).map((s) => `${s.name} ${s.condition}`).join(' '), 1], [c.props.map((p) => p.name).join(' '), 1]], (c) => Number(matches(roles.get(c.name), asked.want)));
156
164
  const [componentAxis, componentRest] = tier(components, Math.max(TOP, components.axis.length));
157
- const ranked = [...screenAxis.map(screenOf), ...componentAxis.map(componentOf), ...screenRest.map(screenOf), ...componentRest.map(componentOf)];
165
+ // a screen drawn comes before the components when it answers the words as well as the best component does; one that answers FEWER of them
166
+ // comes after (a question by intention, "a setting turned on or off", is answered by Toggle, not by eight screens that say "off" or "or")
167
+ const best = componentRest[0]?.matched ?? 0;
168
+ // a finish asked for by its name ("printed", "sticker") or by the word finish is the thing itself, so it comes before the screens that only show it;
169
+ // one that answers a word of its description ("card") is an aside, after the components
170
+ const finishOf = ({ item: f }) => ({ kind: 'finish', name: f.name, summary: sentence(f.is), knobs: f.knobs.map((k) => k.name) });
171
+ const named = (text, t) => t.startsWith(text) || text.startsWith(t);
172
+ const finishRest = rank(finishes(cat), (f) => [[f.name, 6, named], ['finish', 6], [f.is, 2], [f.knobs.map((k) => k.name).join(' '), 1]], () => 0).rest;
173
+ const ranked = [...screenAxis.map(screenOf), ...componentAxis.map(componentOf), ...finishRest.filter((r) => r.score >= 6).map(finishOf), ...screenRest.filter((r) => r.matched >= best).map(screenOf),
174
+ ...componentRest.map(componentOf), ...finishRest.filter((r) => r.score < 6).map(finishOf), ...screenRest.filter((r) => r.matched < best).map(screenOf)];
158
175
  if (!ranked.length && active && !toks.length) return { query, results: [], hint: 'no component or screen has that role: drop an axis, or add English words' };
159
176
  if (!ranked.length) return { query, fallback: true, hint: 'the names and descriptions are English: ask again in English words, or by the axes (represents · shape · interact); `scaffold` with no id lists the screens', screens: screens.length };
160
177
 
package/lib/mcp.mjs CHANGED
@@ -36,19 +36,19 @@ const bool = (description) => ({ type: 'boolean', description });
36
36
  const TOOLS = [
37
37
  {
38
38
  name: 'list_components',
39
- description: 'The names of every cardds component and what each is FOR, nothing else. represents is what it stands for (person, group, time…) or, for a control that stands for no one thing, the KIND of data it carries (words, number, picture, signal); the word in parentheses after a name in the text is its represents. Every component shows information; shape says how much: "one" (one thing), "group-brief" (a group read at a glance or in a line), "group-detail" (a group read in detail), "—" (a helper function or structure, not something drawn). interact says whether the member can do something with it: false (it only shows or holds), or the kinds, any of pick, act, write, navigate — alone when shape is one (a Btn acts), as a group when shape is a group (a BtnGroup picks among several). Two kinds mean it does both. group is the src folder, not what it is for. Call this once, first, in a session that will build a UI with cardds, to learn what exists; then get_component for the ones you use.',
39
+ description: 'The names of every cardds component and what each is FOR, nothing else. represents is what it stands for (person, group, time…) or, for a control that stands for no one thing, the KIND of data it carries (words, number, picture, signal); the word in parentheses after a name in the text is its represents. Every component shows information; shape says how much: "one" (one thing), "group-brief" (a group read at a glance or in a line), "group-detail" (a group read in detail), "—" (a helper function or structure, not something drawn). interact says whether the member can do something with it: false (it only shows or holds), or the kinds, any of pick, act, write, navigate — alone when shape is one (a Btn acts), as a group when shape is a group (a BtnGroup picks among several). Two kinds mean it does both. group is the src folder, not what it is for. Call this once, first, in a session that will build a UI with cardds, to learn what exists; then get_component for the ones you use. The finishes of the paper (data-finish on <html>: lit, print, sticker, ink — a style apart from colour, not a component) are answered by find_components ("printed paper", "finish").',
40
40
  inputSchema: { type: 'object', properties: {} },
41
41
  run: (cat) => catalog.list(cat),
42
42
  },
43
43
  {
44
44
  name: 'get_component',
45
- description: 'One component in full: what it is, its props (a prop marked slot takes children: ReactNode), and its variations, each a working story with its code. Call it before you use a component you have not used yet, and copy a variation rather than guessing a prop. A component used everywhere (Btn, Text) comes with a few of its stories and the ids of the rest (pass all for their code). Names are case-insensitive; a miss returns the closest names.',
45
+ description: 'One component in full: what it is, its props (a prop marked slot takes children: ReactNode), what it composes and what composes it (composes · composedBy: the catalog names its source renders or calls, and the inverse), its knobs (the CSS custom properties a theme sets to retune it, each with its default, what it sets and the selector that reads it — joined by the classes the component emits, so a knob named for an old name is listed under the component that has it now), and its variations, each a working story with its code. Call it before you use a component you have not used yet, and copy a variation rather than guessing a prop. A component used everywhere (Btn, Text) comes with a few of its stories and the ids of the rest (pass all for their code). Names are case-insensitive; a miss returns the closest names.',
46
46
  inputSchema: { type: 'object', properties: { name: str('component name, e.g. Menu'), html: bool('also return each variation\'s rendered HTML — only to see the markup it makes'), all: bool('include the code of every story that uses the component') }, required: ['name'] },
47
47
  run: (cat, a) => catalog.get(cat, a.name, { html: a.html, all: a.all }),
48
48
  },
49
49
  {
50
50
  name: 'find_components',
51
- description: (cat) => `Find what to build with when you do not know the component name. Ask BOTH ways and read both answers: by the AXES (translated from the request) AND by the English noun of the task ("stamp", "otp", "postcard") — the screen already drawn is often found by the noun. The axes are a closed vocabulary, so ask by them in any language: represents (what it stands for, or for a control the KIND of data it carries${cat ? `: ${catalog.vocabulary(cat).represents.join(' · ')}` : ' — the word in parentheses after a name in list_components'}), shape (one · group-brief · group-detail · group = either group shape), interact (the kinds the member can do with it: ${catalog.KINDS.join(' · ')}, or "none" = it only shows). "Show all the members" is represents [person] + shape group; adding interact [pick] narrows it to the ones the member chooses from. Whatever matches every axis you give comes first, each with its role so you see why; with axes and no words they are the whole answer. Words ("stamp", "otp pin", "bottom sheet") rank what matches only by words, in ENGLISH — the names and descriptions are English, translate first. Answers short: the screens cardds has already drawn (a screen is a composition: it counts when its parts answer the axes between them, one component per axis, and one that answers them all ranks higher), then components, then the components most often used together with them. Call it at the start of a task, before composing a screen from parts: if a screen like it exists, scaffold_screen gives its code. A query that matches nothing returns fallback: true, a hint and the number of screens drawn, not their ids: ask again in English words or by the axes, or call scaffold_screen with no id to list them.`,
51
+ description: (cat) => `Find what to build with when you do not know the component name. Ask BOTH ways and read both answers: by the AXES (translated from the request) AND by the English noun of the task ("stamp", "otp", "postcard") — the screen already drawn is often found by the noun. The axes are a closed vocabulary, so ask by them in any language: represents (what it stands for, or for a control the KIND of data it carries${cat ? `: ${catalog.vocabulary(cat).represents.join(' · ')}` : ' — the word in parentheses after a name in list_components'}), shape (one · group-brief · group-detail · group = either group shape), interact (the kinds the member can do with it: ${catalog.KINDS.join(' · ')}, or "none" = it only shows). "Show all the members" is represents [person] + shape group; adding interact [pick] narrows it to the ones the member chooses from. Whatever matches every axis you give comes first, each with its role so you see why; with axes and no words they are the whole answer. Words ("stamp", "otp pin", "bottom sheet") rank what matches only by words, in ENGLISH — the names and descriptions are English, translate first; a component's words include its role's note and what it stands in for, so a question by intention ("a setting turned on or off", "a code digit by digit") finds the piece. Answers short: the screens cardds has already drawn (a screen is a composition: it counts when its parts answer the axes between them, one component per axis, and one that answers them all ranks higher), then components, then the components most often used together with them; a screen that answers fewer of the words than the best component comes after the components, so a question by intention ("a setting turned on or off") names the piece first. A FINISH of the paper (data-finish on <html>, beside any palette: lit, print, sticker, ink) is answered too, kind "finish", with its knobs: ask "printed paper", "sticker", or "finish" for all four. Call it at the start of a task, before composing a screen from parts: if a screen like it exists, scaffold_screen gives its code. A query that matches nothing returns fallback: true, a hint and the number of screens drawn, not their ids: ask again in English words or by the axes, or call scaffold_screen with no id to list them.`,
52
52
  inputSchema: {
53
53
  type: 'object',
54
54
  properties: {
package/lint/rules.mjs CHANGED
@@ -209,5 +209,12 @@ export function checkSource(source, file, { classes = carddsClasses() } = {}) {
209
209
  }
210
210
  }
211
211
 
212
+ // 8 · a label presses the FIRST control inside it: a button before the row's Check takes the press meant for the row
213
+ for (const m of text.matchAll(/<Row\b/g)) {
214
+ const tag = tagAt(text, m.index);
215
+ if (/\sas\s*=\s*\{?\s*["']label["']/.test(tag) && /<(?:IconBtn|Btn|Link)\b/.test(tag) && !/\shtmlFor\s*=/.test(tag))
216
+ add('row-label-button', 'warn', m.index, '<Row as="label"> holding a button and no htmlFor: a label presses the first control inside it, so a touch on the row\'s words presses that button, not its Check. Give the Check an id and the row htmlFor={id}.');
217
+ }
218
+
212
219
  return findings.sort((a, b) => a.line - b.line);
213
220
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usableapp/cardds",
3
- "version": "0.7.86",
3
+ "version": "0.7.88",
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.",