@usableapp/cardds 0.7.85 → 0.7.87

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 (46) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +44 -1
  3. package/bin/cardds.mjs +8 -3
  4. package/css/actions.css +12 -7
  5. package/css/card.css +1 -2
  6. package/css/choice.css +2 -2
  7. package/css/finish.css +97 -11
  8. package/css/forms.css +1 -2
  9. package/css/numbers.css +1 -2
  10. package/css/post.css +1 -2
  11. package/css/stack.css +1 -1
  12. package/css/stamp.css +44 -4
  13. package/css/step.css +6 -0
  14. package/css/sticky.css +45 -2
  15. package/css/tokens.css +6 -0
  16. package/dist/cardds.css +217 -35
  17. package/dist/cards/CardFeed.js +2 -9
  18. package/dist/catalog.json +1 -1
  19. package/dist/index.d.ts +1 -0
  20. package/dist/index.js +1 -0
  21. package/dist/lists/Row.js +2 -9
  22. package/dist/pressable.d.ts +8 -0
  23. package/dist/pressable.js +6 -0
  24. package/dist/scaffold/Screen.js +2 -9
  25. package/dist/sheets/StackSheet.js +3 -7
  26. package/dist/sheets/sheetMeasure.d.ts +1 -0
  27. package/dist/sheets/sheetMeasure.js +14 -9
  28. package/dist/sheets/stackCard.js +41 -48
  29. package/dist/stamp/Stamp.js +3 -6
  30. package/dist/stamp/StampBook.js +2 -3
  31. package/dist/stamp/StampOn.d.ts +21 -3
  32. package/dist/stamp/StampOn.js +75 -18
  33. package/dist/stamp/underNotes.d.ts +3 -0
  34. package/dist/stamp/underNotes.js +4 -0
  35. package/dist/state.d.ts +5 -1
  36. package/dist/state.js +8 -0
  37. package/dist/sticky/Sticky.d.ts +2 -2
  38. package/dist/sticky/Sticky.js +2 -3
  39. package/dist/sticky/StickyPile.d.ts +20 -0
  40. package/dist/sticky/StickyPile.js +31 -0
  41. package/dist/words.d.ts +1 -1
  42. package/dist/words.js +2 -2
  43. package/lib/catalog.mjs +24 -7
  44. package/lib/mcp.mjs +3 -3
  45. package/lint/rules.mjs +1 -1
  46. package/package.json +1 -1
package/dist/index.d.ts CHANGED
@@ -81,6 +81,7 @@ export * from './sticky/StickyField.js';
81
81
  export * from './sticky/StickyPick.js';
82
82
  export * from './sticky/StickyThread.js';
83
83
  export * from './sticky/StickyBoard.js';
84
+ export * from './sticky/StickyPile.js';
84
85
  export * from './sticky/StickyAnswer.js';
85
86
  export * from './stamp/Stamp.js';
86
87
  export * from './stamp/StampBook.js';
package/dist/index.js CHANGED
@@ -85,6 +85,7 @@ export * from './sticky/StickyField.js';
85
85
  export * from './sticky/StickyPick.js';
86
86
  export * from './sticky/StickyThread.js';
87
87
  export * from './sticky/StickyBoard.js';
88
+ export * from './sticky/StickyPile.js';
88
89
  export * from './sticky/StickyAnswer.js';
89
90
  export * from './stamp/Stamp.js';
90
91
  export * from './stamp/StampBook.js';
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
+ }
@@ -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
  }
@@ -15,6 +15,14 @@ export interface StampOnProps extends ComponentProps<'div'> {
15
15
  onAtChange?: (at: StampAt) => void;
16
16
  /** a draggable stamp's accessible name ("แสตมป์ของต้น") */
17
17
  label?: string;
18
+ /** the stamp can be TAKEN OFF: when a drag ends, an arrow key moves it, or (on a stamp that cannot be dragged) it is pressed, a cross stands on its top right corner for `removeFor` ms; a press on the cross — or Delete / Backspace on the focused stamp — ASKS. The app owns what is stuck (as it owns `at`): it takes the `StampOn` away. Without it nothing changes: no cross, the stamp is dragged as before and cannot be taken off. A press or a drag on a removable stamp never reaches what lies under it */
19
+ onRemove?: () => void;
20
+ /** the cross's accessible name (default: cardds's own word, "Remove" / "เอาออก" by the page's language) */
21
+ removeLabel?: string;
22
+ /** how long the cross stands after the stamp was touched, in ms (default 3000). It stays while it holds the keyboard focus; a new drag takes it away until the drag ends */
23
+ removeFor?: number;
24
+ /** the stamp at the stamp's MAIN size, 96 × 120 (`--stamp-size`), to cover what it lies on; the default is the small size, 48 × 60 (`--stamp-sm-size`), which leaves a note's words readable */
25
+ main?: boolean;
18
26
  /** the stamp sticks ITSELF: as it comes onto the screen it is struck onto its place (shown large, pressed down to its size). Nobody
19
27
  * picked it or pressed to stick it — the app shows it when its own move (send) begins. With `onArrived` */
20
28
  arrive?: boolean;
@@ -24,9 +32,19 @@ export interface StampOnProps extends ComponentProps<'div'> {
24
32
  /**
25
33
  * StampOn — a Stamp (the child) stuck on a surface at the place the member chose. The surface is the nearest positioned box:
26
34
  * a Sticky's `stuck`, a postcard's face. `at` is the stamp's middle as a share of that surface, so the note reads the same at any size.
27
- * The stamp is the SMALL size: 48 × 60 on any surface, whatever the surface's size (`--stamp-sm-size`, one tap wide).
35
+ * The stamp is the SMALL size: 48 × 60 on any surface, whatever the surface's size (`--stamp-sm-size`, one tap wide); `main` makes it the
36
+ * stamp's main size, 96 × 120, to cover what it lies on.
28
37
  * With `onAtChange` the member drags it (finger or mouse; the arrow keys move it too): while the finger holds, the stamp writes its
29
38
  * own place, and on letting go it ASKS — the app owns `at`. It never leaves the surface.
30
- * With `arrive` it sticks itself: struck onto its place as it comes on, and `onArrived` tells the app it has landed.
39
+ * With `onRemove` it can be taken off: once touched (a drag let go, an arrow key, or a press on a stamp that cannot be dragged) a cross
40
+ * stands on its top right corner for `removeFor` ms; a press on the cross, or Delete / Backspace on the focused stamp, ASKS, and the app
41
+ * takes the stamp away. `onRemove` without `onAtChange` is a stamp that stays where it is and is taken off: a press shows the cross
42
+ * (a warning instead would leave the prop dead, since the cross needs a touch to appear). A press or a drag on a removable stamp or its
43
+ * cross stops there, so a pressable card or pile under it is not pressed. The cross stays upright whatever the stamp's `turn`: it is a
44
+ * button, and its 44 × 44 touch target round the 24 disc should be square to the screen.
45
+ * Inside a `StickyPile`, a stamp on a note under the top one is inert: no drag, no cross, no focus.
46
+ * With `arrive` it sticks itself: struck onto its place as it comes on, and `onArrived` tells the app it has landed. A stamp that is still
47
+ * arriving cannot be taken off: no cross, no focus, no Delete until it has landed (it is not stuck yet). `arrive` with `main` strikes
48
+ * the main size, 96 × 120, onto its place.
31
49
  */
32
- export declare function StampOn({ at, defaultAt, turn, onAtChange, label, arrive, onArrived, className, style, onAnimationEnd, children, ...rest }: StampOnProps): import("react").JSX.Element;
50
+ export declare function StampOn({ at, defaultAt, turn, onAtChange, label, onRemove, removeLabel, removeFor, main, arrive, onArrived, className, style, onAnimationEnd, children, ...rest }: StampOnProps): import("react").JSX.Element;
@@ -1,7 +1,10 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
2
- import { useEffect, useRef, useState } from 'react';
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useContext, useEffect, useRef, useState } from 'react';
3
3
  import { cx } from '../cx.js';
4
4
  import { reducedMotion, useControlled } from '../state.js';
5
+ import { Icon } from '../type/Icon.js';
6
+ import { word } from '../words.js';
7
+ import { UnderNotes } from './underNotes.js';
5
8
  const MIDDLE = { x: 0.5, y: 0.5 };
6
9
  const STEP = 0.02; // an arrow key's move: a share of the surface
7
10
  const MOVES = { ArrowLeft: [-STEP, 0], ArrowRight: [STEP, 0], ArrowUp: [0, -STEP], ArrowDown: [0, STEP] };
@@ -15,23 +18,42 @@ function within(el, x, y) {
15
18
  const mx = el.offsetWidth / 2 / box.clientWidth, my = el.offsetHeight / 2 / box.clientHeight;
16
19
  return { x: round(clamp(x, mx, 1 - mx)), y: round(clamp(y, my, 1 - my)) };
17
20
  }
18
- const STRIKE = 'stamp-on-strike'; // css/stamp.css
21
+ const STRIKE = 'strike'; // css/tokens.css — the strike every mark struck onto paper reads
19
22
  const write = (el, at) => { el.style.setProperty('--stamp-on-x', String(at.x)); el.style.setProperty('--stamp-on-y', String(at.y)); };
20
23
  /**
21
24
  * StampOn — a Stamp (the child) stuck on a surface at the place the member chose. The surface is the nearest positioned box:
22
25
  * a Sticky's `stuck`, a postcard's face. `at` is the stamp's middle as a share of that surface, so the note reads the same at any size.
23
- * The stamp is the SMALL size: 48 × 60 on any surface, whatever the surface's size (`--stamp-sm-size`, one tap wide).
26
+ * The stamp is the SMALL size: 48 × 60 on any surface, whatever the surface's size (`--stamp-sm-size`, one tap wide); `main` makes it the
27
+ * stamp's main size, 96 × 120, to cover what it lies on.
24
28
  * With `onAtChange` the member drags it (finger or mouse; the arrow keys move it too): while the finger holds, the stamp writes its
25
29
  * own place, and on letting go it ASKS — the app owns `at`. It never leaves the surface.
26
- * With `arrive` it sticks itself: struck onto its place as it comes on, and `onArrived` tells the app it has landed.
30
+ * With `onRemove` it can be taken off: once touched (a drag let go, an arrow key, or a press on a stamp that cannot be dragged) a cross
31
+ * stands on its top right corner for `removeFor` ms; a press on the cross, or Delete / Backspace on the focused stamp, ASKS, and the app
32
+ * takes the stamp away. `onRemove` without `onAtChange` is a stamp that stays where it is and is taken off: a press shows the cross
33
+ * (a warning instead would leave the prop dead, since the cross needs a touch to appear). A press or a drag on a removable stamp or its
34
+ * cross stops there, so a pressable card or pile under it is not pressed. The cross stays upright whatever the stamp's `turn`: it is a
35
+ * button, and its 44 × 44 touch target round the 24 disc should be square to the screen.
36
+ * Inside a `StickyPile`, a stamp on a note under the top one is inert: no drag, no cross, no focus.
37
+ * With `arrive` it sticks itself: struck onto its place as it comes on, and `onArrived` tells the app it has landed. A stamp that is still
38
+ * arriving cannot be taken off: no cross, no focus, no Delete until it has landed (it is not stuck yet). `arrive` with `main` strikes
39
+ * the main size, 96 × 120, onto its place.
27
40
  */
28
- export function StampOn({ at, defaultAt = MIDDLE, turn = 0, onAtChange, label, arrive, onArrived, className, style, onAnimationEnd, children, ...rest }) {
41
+ export function StampOn({ at, defaultAt = MIDDLE, turn = 0, onAtChange, label, onRemove, removeLabel, removeFor = 3000, main, arrive, onArrived, className, style, onAnimationEnd, children, ...rest }) {
42
+ const under = useContext(UnderNotes);
29
43
  const [shown, request] = useControlled(at, defaultAt, onAtChange);
30
44
  const [held, setHeld] = useState(false);
45
+ const [cross, setCross] = useState(false);
31
46
  const grip = useRef(null);
32
- const moves = onAtChange != null;
47
+ const timer = useRef(undefined);
48
+ const moves = onAtChange != null && !under;
49
+ const [arrived, setArrived] = useState(false);
50
+ const removes = onRemove != null && !under && !(arrive && !arrived);
33
51
  const ask = (next) => { if (next.x !== shown.x || next.y !== shown.y)
34
52
  request(next); };
53
+ const stop = () => clearTimeout(timer.current);
54
+ const show = () => { if (!removes)
55
+ return; stop(); setCross(true); timer.current = setTimeout(() => setCross(false), removeFor); };
56
+ useEffect(() => stop, []);
35
57
  const down = (e) => {
36
58
  const el = e.currentTarget, box = el.offsetParent;
37
59
  if (!box || e.button !== 0)
@@ -40,6 +62,8 @@ export function StampOn({ at, defaultAt = MIDDLE, turn = 0, onAtChange, label, a
40
62
  grip.current = { dx: (e.clientX - r.left - box.clientLeft) / box.clientWidth - shown.x, dy: (e.clientY - r.top - box.clientTop) / box.clientHeight - shown.y, next: shown };
41
63
  el.setPointerCapture(e.pointerId);
42
64
  setHeld(true);
65
+ stop();
66
+ setCross(false); // a new drag takes the cross away until it ends
43
67
  };
44
68
  const move = (e) => {
45
69
  const el = e.currentTarget, box = el.offsetParent, g = grip.current;
@@ -56,26 +80,59 @@ export function StampOn({ at, defaultAt = MIDDLE, turn = 0, onAtChange, label, a
56
80
  grip.current = null;
57
81
  setHeld(false);
58
82
  write(e.currentTarget, shown); // back to what the app has: if it takes the new place, the next render writes it
59
- if (e.type === 'pointerup')
83
+ if (e.type === 'pointerup') {
60
84
  ask(g.next);
85
+ show();
86
+ }
61
87
  };
62
88
  const key = (e) => {
63
- const by = MOVES[e.key];
64
- if (!by)
65
- return;
66
- e.preventDefault();
67
- ask(within(e.currentTarget, shown.x + by[0], shown.y + by[1]));
89
+ if (e.target !== e.currentTarget)
90
+ return; // the cross has its own keys
91
+ const by = moves ? MOVES[e.key] : undefined;
92
+ if (by) {
93
+ e.preventDefault();
94
+ e.stopPropagation();
95
+ ask(within(e.currentTarget, shown.x + by[0], shown.y + by[1]));
96
+ show();
97
+ }
98
+ else if (removes && (e.key === 'Delete' || e.key === 'Backspace')) {
99
+ e.preventDefault();
100
+ e.stopPropagation();
101
+ stop();
102
+ setCross(false);
103
+ onRemove();
104
+ }
105
+ else if (removes && !moves && (e.key === 'Enter' || e.key === ' ')) {
106
+ e.preventDefault();
107
+ e.stopPropagation();
108
+ show();
109
+ }
110
+ };
111
+ // nothing that starts on a removable stamp or its cross reaches what lies under it: a click is the end of a press, and a drag's end is one too
112
+ const click = (e) => {
113
+ e.stopPropagation();
114
+ rest.onClick?.(e);
115
+ if (!moves)
116
+ show();
68
117
  };
69
118
  const landed = useRef(onArrived);
70
119
  landed.current = onArrived;
71
- useEffect(() => { if (arrive && reducedMotion())
72
- landed.current?.(); }, [arrive]); // no strike to wait for
120
+ const land = () => { setArrived(true); landed.current?.(); };
121
+ useEffect(() => { if (!arrive)
122
+ setArrived(false);
123
+ else if (reducedMotion()) {
124
+ setArrived(true);
125
+ landed.current?.();
126
+ } }, [arrive]); // no strike to wait for
73
127
  const struck = (e) => {
74
128
  onAnimationEnd?.(e);
75
129
  if (arrive && e.target === e.currentTarget && e.animationName === STRIKE)
76
- onArrived?.();
130
+ land();
77
131
  };
78
132
  const place = { '--stamp-on-x': shown.x, '--stamp-on-y': shown.y, '--stamp-on-turn': turn, ...style };
79
- const drag = moves ? { tabIndex: 0, role: 'group', 'aria-label': label, onPointerDown: down, onPointerMove: move, onPointerUp: up, onPointerCancel: up, onKeyDown: key } : null;
80
- return (_jsx("div", { className: cx('stamp-on', moves && 'stamp-on--move', arrive && 'stamp-on--arrive', held && 'is-held', className), style: place, onAnimationEnd: struck, ...rest, ...drag, children: children }));
133
+ const own = moves || removes ? { tabIndex: 0, role: 'group', 'aria-label': label, onKeyDown: key } : null;
134
+ const drag = moves ? { onPointerDown: down, onPointerMove: move, onPointerUp: up, onPointerCancel: up } : null;
135
+ const guard = removes ? { onClick: click } : null;
136
+ return (_jsxs("div", { className: cx('stamp-on', moves && 'stamp-on--move', main && 'stamp-on--main', arrive && 'stamp-on--arrive', held && 'is-held', className), style: place, onAnimationEnd: struck, ...rest, ...own, ...drag, ...guard, inert: under || undefined, children: [children, removes && cross && (_jsx("button", { type: "button", className: "stamp-on__remove", "aria-label": removeLabel ?? word('remove'), onPointerDown: (e) => e.stopPropagation(), onClick: (e) => { e.stopPropagation(); stop(); setCross(false); onRemove(); }, onFocus: stop, onBlur: () => { if (!grip.current)
137
+ show(); }, children: _jsx("span", { className: "stamp-on__remove-disc", children: _jsx(Icon, { name: "x" }) }) }))] }));
81
138
  }
@@ -0,0 +1,3 @@
1
+ /** set by a holder whose notes lie one under another (`StickyPile`): a stamp on a note that only shows what peeks out is inert — no drag, no cross, no focus.
2
+ * Not part of the package's API: a holder writes it, `StampOn` reads it. */
3
+ export declare const UnderNotes: import("react").Context<boolean>;
@@ -0,0 +1,4 @@
1
+ import { createContext } from 'react';
2
+ /** set by a holder whose notes lie one under another (`StickyPile`): a stamp on a note that only shows what peeks out is inert — no drag, no cross, no focus.
3
+ * Not part of the package's API: a holder writes it, `StampOn` reads it. */
4
+ export const UnderNotes = createContext(false);
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);