@usableapp/cardds 0.7.6 → 0.7.8

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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,38 @@
2
2
 
3
3
  > For: a project that consumes `@usableapp/cardds` and its AI — what changed between versions, written as before → after, so a screen can be brought up to date without reading the source. Newest first. A consumer never edits cardds; if something here leaves you without a way to do what you did before, report the gap.
4
4
 
5
+ ## 0.7.8 — 2026-09-25
6
+
7
+ ### Changed
8
+ - `Pin error` (the app's answer, a refused OTP): before — the red digits stayed until the next one was typed. After — the Pin
9
+ shakes red, then EMPTIES itself (`onChange('', false)`), the cursor in the first cell, on the same step — as a `match` miss.
10
+ Give a new `error` value per refusal (a counter) so a second one shakes again.
11
+
12
+ ### Added
13
+ - `WaitLink` — a Link that waits: `until` (ms time) → counts down ("ขอรหัสใหม่ได้ใน 0:27", `waiting={(t) => …}`), takes no
14
+ tap until then. The app owns the rule (30 s, +30 s per ask) and sets the next `until` in its onClick. `Link disabled`
15
+ (muted, knob `--link-disabled-ink`). Stories `WaitLink · Resend`, `SheetSteps · Errors` (ask for a new code).
16
+ - `SheetSteps reached` — controlled progress: how many steps are done. Before: the steps remembered the furthest step reached,
17
+ so after the app sent `at` back (the server refused the code at the end) the later steps still showed ✓ and their summary.
18
+ After: `reached={1}` with `at={1}` → the steps after it are "next" again. Once set the app owns it — keep
19
+ `reached = max(reached, at)` beside `at`. Without it: as before. Story `SheetSteps · Reached` (timebank card 142).
20
+
21
+ ## 0.7.7 — 2026-09-25
22
+
23
+ ### Changed (breaking for a confirm PIN)
24
+ - `Pin match` that does not match: before — red for 3 s, then the steps went back one on their own (`onAtChange(at - 1,
25
+ 'mismatch')`). After — the step STAYS: the Pin shakes with red edges, the step shows `formatError`, then the Pin empties
26
+ itself (`onChange('', false)`) ready for a new try; the words go with the next digit. Going back is the member's choice:
27
+ put a `Link` under the Pin that sets `at` back (and clears both codes) — story `SheetSteps · Errors` ("ตั้ง PIN ใหม่").
28
+ The reason `'mismatch'` is gone from `SheetStepsReason` — drop its branch from `onAtChange`. Knobs `--pin-shake-x`, `--pin-shake-time`.
29
+ - `SheetStep`: a tap on a `Link` in the step keeps the cursor in its field, like its buttons — before, the field lost the
30
+ focus, the pile scrolled back and the link's click was lost (a "change number" link under an OTP too).
31
+
32
+ ### Added
33
+ - `Field mask` — `"###-###-####"`: `#` = a digit, anything else is written in for the member as they type (081-234-5678).
34
+ Only digits stay; a mark comes only once a digit follows it. `onChange` reads the shaped value.
35
+ - `Field`: a placeholder longer than the box shrinks to show whole (measured in the input's font; the typed value keeps its size).
36
+
5
37
  ## 0.7.6 — 2026-09-24
6
38
 
7
39
  ### Changed
package/README.md CHANGED
@@ -57,6 +57,14 @@ story live in a 375×812 phone cell, grouped by `src/<group>/`, searchable; swit
57
57
  it comes up over the phone cell and publishes `--kb` there — to SEE what a keyboard leaves of the screen; it
58
58
  types nothing), text size, and a slider per size base. Nothing is hand-listed: add a preview file or a story and it shows.
59
59
 
60
+ **On a real phone** — the mock keyboard shows the room a keyboard takes; the REAL one (its kind by `inputMode`: `numeric` /
61
+ `tel` = digits, `email`, `text`; its language is the member's own setting) only shows on a device. The gallery's server is
62
+ open on the Wi-Fi (`server.host: true`): `npm run dev` prints a `Network:` address — open it on a phone on the same
63
+ Wi-Fi. A project using cardds does the same in its own `vite.config` (`server: { host: true }`, or `vite --host`).
64
+ The iOS Simulator's Safari shows the iOS keyboard too (⌘K if it hides); desktop device modes show none. Installing as a
65
+ PWA needs https — a tunnel (`cloudflared tunnel --url http://localhost:<port>`), then Add to Home Screen. `host: true`
66
+ lets anyone on the same network open the dev server: use it on a network you trust.
67
+
60
68
  `tests/fixtures/*.html` are the pages the geometry tests measure — not a demo. Nothing here is verified by eye.
61
69
 
62
70
  ### In another project
package/css/forms.css CHANGED
@@ -28,7 +28,7 @@
28
28
  outline: none; /* the browser's ring drew a second box inside the field (Lh 2026-09-22) — the field itself tells focus */
29
29
  caret-color: currentColor;
30
30
  }
31
- .field input::placeholder { color: inherit; opacity: 0.55; }
31
+ .field input::placeholder { color: inherit; opacity: 0.55; font-size: calc(1em * var(--_ph-fit, 1)); } /* --_ph-fit: Field's measure — a placeholder longer than the box shrinks to show whole (Lh 2026-09-25) */
32
32
  /* the field the cursor is in: its own border goes full ink, in every palette (the coloured ones fade it to 25% at rest) */
33
33
  .field:has(:focus) { border-color: var(--field-active-border, currentColor); }
34
34
 
@@ -72,6 +72,11 @@
72
72
  <p class="form-note form-note--busy" role="status"><svg …/>Sending the code…</p> (the ring turns) */
73
73
  .field.field--error, [data-palette] .field.field--error { border-color: var(--field-error-border, var(--signal-error)); }
74
74
  .pin.pin--error .pin__cell, .pin.pin--error .pin__cell:focus { border-color: var(--pin-error-border, var(--signal-error)); color: var(--pin-error-ink, var(--signal-error)); } /* ALL the cells, edge and digit — the one the cursor waits in too (Lh 2026-09-24); the wrong digits stay in red until the member types again */
75
+ /* a code that does not match (Pin match): the row shakes once, sideways, then the Pin empties itself for a new try (Lh 2026-09-25) */
76
+ @media (prefers-reduced-motion: no-preference) {
77
+ .pin--shake { animation: pin-shake var(--pin-shake-time, 0.4s) ease-in-out; }
78
+ }
79
+ @keyframes pin-shake { 0%, 100% { translate: 0; } 20%, 60% { translate: calc(-1 * var(--pin-shake-x, var(--sp-2))); } 40%, 80% { translate: var(--pin-shake-x, var(--sp-2)); } }
75
80
  .form-note { display: flex; align-items: center; justify-content: center; gap: var(--sp-2); margin: 0; font: var(--form-note-font, var(--type-caption)); color: var(--form-note-ink, var(--card-muted)); text-align: center; }
76
81
  .form-note > .icon { flex: none; }
77
82
  .form-note--error { color: var(--form-note-error-ink, var(--signal-error)); }
package/css/lists.css CHANGED
@@ -206,4 +206,6 @@
206
206
  .link--sm { position: relative; font-size: var(--link-sm-font-size, var(--fs-2xs)); min-height: var(--link-sm-min-h, calc(var(--tap-base) * 7 / 12)); }
207
207
  .link--sm::after { content: ""; position: absolute; inset: calc((var(--link-sm-min-h, calc(var(--tap-base) * 7 / 12)) - var(--tap-sm)) / 2) 0; }
208
208
  .link--muted { color: var(--link-muted-ink, var(--card-muted)); }
209
+ .link:disabled { color: var(--link-disabled-ink, var(--card-muted)); cursor: default; } /* inactive = muted (a WaitLink counting down) */
210
+ .link--wait { font-variant-numeric: tabular-nums; } /* the countdown's digits keep their width: the words do not jiggle each second */
209
211
  .card--3 .link--muted { color: var(--card-3-muted); }
package/css/sheet.css CHANGED
@@ -381,7 +381,7 @@
381
381
  falls — no height is written. Nothing fades: done and next read at full ink. */
382
382
  .sheet__body:has(> .sheet-steps) { display: flex; flex-direction: column; }
383
383
  .sheet-steps { flex-shrink: 0; min-height: calc(100% + var(--kb, 0rem)); /* the keyboard's height more: the pile of next steps stays under the keyboard, it never rises with it */ display: flex; flex-direction: column; touch-action: pan-y; }
384
- .sheet-steps__holder { position: absolute; top: 0; left: 0; width: 1px; height: 1px; padding: 0; border: 0; opacity: 0; pointer-events: none; font-size: 1rem; } /* holds the cursor (and the phone's keyboard) while a step folds; 1rem (16px at the browser's size) so iOS does not zoom to it */
384
+ .sheet-steps__holder { position: absolute; top: 0; left: 0; width: 1rem; height: 1rem; padding: 0; border: 0; opacity: 0; pointer-events: none; font-size: 1rem; } /* holds the cursor (and the phone's keyboard) while a step folds; font 1rem = the browser's own size, so iOS does not zoom to it */
385
385
  .sheet-steps { position: relative; }
386
386
  .sheet-steps__track { list-style: none; margin: 0; padding: 0; flex: 1; display: flex; flex-direction: column; }
387
387
 
@@ -403,7 +403,7 @@
403
403
  .sheet-step__body { min-height: 0; overflow: clip; overflow-clip-margin: var(--sp-1); }
404
404
  .sheet-step__inner { display: grid; gap: var(--card-gap); padding-bottom: var(--card-gap); }
405
405
  .sheet-step__inner > * { min-width: 0; }
406
- .sheet-step__skip { justify-self: center; }
406
+ .sheet-step__inner > .link { justify-self: center; } /* a link of its own row in a step (skip, "change number", "set PIN again"): centred under the step, as the skip (Lh 2026-09-25) */
407
407
  .sheet-step:not(.sheet-step--now) > .sheet-step__body { visibility: hidden; transition: visibility 0s linear var(--motion-sheet); }
408
408
  .sheet-step__go { position: absolute; inset: 0; margin: 0; padding: 0; border: 0; background: none; cursor: pointer; -webkit-tap-highlight-color: transparent; }
409
409
  /* the last step's finish, when the app gives one: under the steps */
@@ -9,9 +9,13 @@ export interface LinkProps extends ComponentProps<'a'> {
9
9
  sm?: boolean;
10
10
  /** without an href it renders as a button */
11
11
  href?: string;
12
+ /** a button link that takes no taps now (reads inactive: muted) — `WaitLink` while it counts down */
13
+ disabled?: boolean;
12
14
  }
13
15
  /** Link — an inline text action big enough to tap (40px): "See all ↗" in a card head, "Skip" between pager buttons. */
14
16
  export declare function Link({ muted, sm, icon, href, className, children, ...rest }: LinkProps): import("react").DetailedReactHTMLElement<{
17
+ /** a button link that takes no taps now (reads inactive: muted) — `WaitLink` while it counts down */
18
+ disabled?: boolean;
15
19
  ref?: import("react").Ref<HTMLAnchorElement> | undefined;
16
20
  key?: import("react").Key | null | undefined;
17
21
  download?: any;
@@ -0,0 +1,14 @@
1
+ import { type ReactNode } from 'react';
2
+ import { type LinkProps } from './Link.js';
3
+ export interface WaitLinkProps extends Omit<LinkProps, 'href'> {
4
+ /** CONTROLLED: when it may be pressed again (a time in ms, `Date.now()` based). Before that it counts down and takes no taps;
5
+ * null / past = ready. The APP owns the rule — 30 s, +30 s each time — and sets a new `until` in its onClick. */
6
+ until?: number | null;
7
+ /** the words while it waits, given the time left as "0:27" (default: the children, then the time) */
8
+ waiting?: (left: string) => ReactNode;
9
+ }
10
+ /**
11
+ * WaitLink — a Link that waits: "ขอรหัสใหม่ได้ใน 0:27", counting down each second, then "ขอรหัสใหม่" to press (a new OTP, a retry).
12
+ * A link, not a button: it is the step's second way, under its control; in a SheetStep it stands centred on its own row.
13
+ */
14
+ export declare function WaitLink({ until, waiting, children, className, disabled, ...rest }: WaitLinkProps): import("react").JSX.Element;
@@ -0,0 +1,22 @@
1
+ import { Fragment as _Fragment, jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ import { useEffect, useState } from 'react';
3
+ import { cx } from '../cx.js';
4
+ import { Link } from './Link.js';
5
+ /**
6
+ * WaitLink — a Link that waits: "ขอรหัสใหม่ได้ใน 0:27", counting down each second, then "ขอรหัสใหม่" to press (a new OTP, a retry).
7
+ * A link, not a button: it is the step's second way, under its control; in a SheetStep it stands centred on its own row.
8
+ */
9
+ export function WaitLink({ until, waiting, children, className, disabled, ...rest }) {
10
+ const [now, setNow] = useState(() => Date.now());
11
+ const left = until != null ? Math.ceil((until - now) / 1000) : 0;
12
+ useEffect(() => {
13
+ if (until == null || until <= Date.now())
14
+ return;
15
+ setNow(Date.now());
16
+ const t = setInterval(() => { setNow(Date.now()); if (Date.now() >= until)
17
+ clearInterval(t); }, 250);
18
+ return () => clearInterval(t);
19
+ }, [until]);
20
+ const time = `${Math.floor(Math.max(left, 0) / 60)}:${String(Math.max(left, 0) % 60).padStart(2, '0')}`;
21
+ return (_jsx(Link, { className: cx('link--wait', className), disabled: left > 0 || disabled, ...rest, children: left > 0 ? (waiting ? waiting(time) : _jsxs(_Fragment, { children: [children, " ", time] })) : children }));
22
+ }
package/dist/cardds.css CHANGED
@@ -1558,7 +1558,7 @@ button { font: inherit; cursor: pointer; }
1558
1558
  falls — no height is written. Nothing fades: done and next read at full ink. */
1559
1559
  .sheet__body:has(> .sheet-steps) { display: flex; flex-direction: column; }
1560
1560
  .sheet-steps { flex-shrink: 0; min-height: calc(100% + var(--kb, 0rem)); /* the keyboard's height more: the pile of next steps stays under the keyboard, it never rises with it */ display: flex; flex-direction: column; touch-action: pan-y; }
1561
- .sheet-steps__holder { position: absolute; top: 0; left: 0; width: 1px; height: 1px; padding: 0; border: 0; opacity: 0; pointer-events: none; font-size: 1rem; } /* holds the cursor (and the phone's keyboard) while a step folds; 1rem (16px at the browser's size) so iOS does not zoom to it */
1561
+ .sheet-steps__holder { position: absolute; top: 0; left: 0; width: 1rem; height: 1rem; padding: 0; border: 0; opacity: 0; pointer-events: none; font-size: 1rem; } /* holds the cursor (and the phone's keyboard) while a step folds; font 1rem = the browser's own size, so iOS does not zoom to it */
1562
1562
  .sheet-steps { position: relative; }
1563
1563
  .sheet-steps__track { list-style: none; margin: 0; padding: 0; flex: 1; display: flex; flex-direction: column; }
1564
1564
 
@@ -1580,7 +1580,7 @@ button { font: inherit; cursor: pointer; }
1580
1580
  .sheet-step__body { min-height: 0; overflow: clip; overflow-clip-margin: var(--sp-1); }
1581
1581
  .sheet-step__inner { display: grid; gap: var(--card-gap); padding-bottom: var(--card-gap); }
1582
1582
  .sheet-step__inner > * { min-width: 0; }
1583
- .sheet-step__skip { justify-self: center; }
1583
+ .sheet-step__inner > .link { justify-self: center; } /* a link of its own row in a step (skip, "change number", "set PIN again"): centred under the step, as the skip (Lh 2026-09-25) */
1584
1584
  .sheet-step:not(.sheet-step--now) > .sheet-step__body { visibility: hidden; transition: visibility 0s linear var(--motion-sheet); }
1585
1585
  .sheet-step__go { position: absolute; inset: 0; margin: 0; padding: 0; border: 0; background: none; cursor: pointer; -webkit-tap-highlight-color: transparent; }
1586
1586
  /* the last step's finish, when the app gives one: under the steps */
@@ -1785,7 +1785,7 @@ button { font: inherit; cursor: pointer; }
1785
1785
  outline: none; /* the browser's ring drew a second box inside the field (Lh 2026-09-22) — the field itself tells focus */
1786
1786
  caret-color: currentColor;
1787
1787
  }
1788
- .field input::placeholder { color: inherit; opacity: 0.55; }
1788
+ .field input::placeholder { color: inherit; opacity: 0.55; font-size: calc(1em * var(--_ph-fit, 1)); } /* --_ph-fit: Field's measure — a placeholder longer than the box shrinks to show whole (Lh 2026-09-25) */
1789
1789
  /* the field the cursor is in: its own border goes full ink, in every palette (the coloured ones fade it to 25% at rest) */
1790
1790
  .field:has(:focus) { border-color: var(--field-active-border, currentColor); }
1791
1791
 
@@ -1829,6 +1829,11 @@ button { font: inherit; cursor: pointer; }
1829
1829
  <p class="form-note form-note--busy" role="status"><svg …/>Sending the code…</p> (the ring turns) */
1830
1830
  .field.field--error, [data-palette] .field.field--error { border-color: var(--field-error-border, var(--signal-error)); }
1831
1831
  .pin.pin--error .pin__cell, .pin.pin--error .pin__cell:focus { border-color: var(--pin-error-border, var(--signal-error)); color: var(--pin-error-ink, var(--signal-error)); } /* ALL the cells, edge and digit — the one the cursor waits in too (Lh 2026-09-24); the wrong digits stay in red until the member types again */
1832
+ /* a code that does not match (Pin match): the row shakes once, sideways, then the Pin empties itself for a new try (Lh 2026-09-25) */
1833
+ @media (prefers-reduced-motion: no-preference) {
1834
+ .pin--shake { animation: pin-shake var(--pin-shake-time, 0.4s) ease-in-out; }
1835
+ }
1836
+ @keyframes pin-shake { 0%, 100% { translate: 0; } 20%, 60% { translate: calc(-1 * var(--pin-shake-x, var(--sp-2))); } 40%, 80% { translate: var(--pin-shake-x, var(--sp-2)); } }
1832
1837
  .form-note { display: flex; align-items: center; justify-content: center; gap: var(--sp-2); margin: 0; font: var(--form-note-font, var(--type-caption)); color: var(--form-note-ink, var(--card-muted)); text-align: center; }
1833
1838
  .form-note > .icon { flex: none; }
1834
1839
  .form-note--error { color: var(--form-note-error-ink, var(--signal-error)); }
@@ -3118,6 +3123,8 @@ button { font: inherit; cursor: pointer; }
3118
3123
  .link--sm { position: relative; font-size: var(--link-sm-font-size, var(--fs-2xs)); min-height: var(--link-sm-min-h, calc(var(--tap-base) * 7 / 12)); }
3119
3124
  .link--sm::after { content: ""; position: absolute; inset: calc((var(--link-sm-min-h, calc(var(--tap-base) * 7 / 12)) - var(--tap-sm)) / 2) 0; }
3120
3125
  .link--muted { color: var(--link-muted-ink, var(--card-muted)); }
3126
+ .link:disabled { color: var(--link-disabled-ink, var(--card-muted)); cursor: default; } /* inactive = muted (a WaitLink counting down) */
3127
+ .link--wait { font-variant-numeric: tabular-nums; } /* the countdown's digits keep their width: the words do not jiggle each second */
3121
3128
  .card--3 .link--muted { color: var(--card-3-muted); }
3122
3129
 
3123
3130
  /* ---- css/media.css — cover card, quote, tile grid, fold, mosaic, wave ---- */
@@ -20,11 +20,17 @@ export interface FieldProps extends Omit<ComponentProps<'input'>, 'className' |
20
20
  /** the words when the format check fails — shown under the step's button (the app's words: cardds has none). `required`
21
21
  * (the attribute) makes an empty field fail when the button is pressed. */
22
22
  formatError?: ReactNode;
23
+ /** the shape the digits are written in, AS THE MEMBER TYPES: `#` = one digit, anything else is put in for them ("###-###-####"
24
+ * → 081-234-5678). Only digits are kept; a mark comes only once a digit follows it, so Backspace never sticks on a dash. The
25
+ * app's `onChange` reads the shaped value (the digits alone: `value.replace(/\D/g, '')`). Lh 2026-09-25 */
26
+ mask?: string;
23
27
  className?: string;
24
28
  style?: React.CSSProperties;
25
29
  }
30
+ /** the digits of `v`, written into `mask`; a mark is written only when a digit comes after it */
31
+ export declare function applyMask(v: string, mask: string): string;
26
32
  /**
27
33
  * Field — the flat outlined input: the LABEL IS THE PLACEHOLDER. 48px tall (56px with an action inside).
28
34
  * Fields belong in a Sheet (what the member acts on), 1–3 per step, never in a reading card.
29
35
  */
30
- export declare function Field({ icon, action, label, error, format, digits, formatError, className, style, onBlur, onInput, ...input }: FieldProps): import("react").JSX.Element;
36
+ export declare function Field({ icon, action, label, error, format, digits, formatError, mask, className, style, onBlur, onInput, onChange, ...input }: FieldProps): import("react").JSX.Element;
@@ -1,13 +1,27 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useEffect, useRef, useState } from 'react';
2
+ import { useEffect, useLayoutEffect, useRef, useState } from 'react';
3
3
  import { formatOk, useCheck } from './check.js';
4
4
  import { cx } from '../cx.js';
5
5
  import { Icon } from '../type/Icon.js';
6
+ /** the digits of `v`, written into `mask`; a mark is written only when a digit comes after it */
7
+ export function applyMask(v, mask) {
8
+ const d = v.replace(/\D/g, '');
9
+ let out = '', k = 0;
10
+ for (const m of mask) {
11
+ if (k >= d.length)
12
+ break;
13
+ if (m === '#')
14
+ out += d[k++];
15
+ else
16
+ out += m;
17
+ }
18
+ return out;
19
+ }
6
20
  /**
7
21
  * Field — the flat outlined input: the LABEL IS THE PLACEHOLDER. 48px tall (56px with an action inside).
8
22
  * Fields belong in a Sheet (what the member acts on), 1–3 per step, never in a reading card.
9
23
  */
10
- export function Field({ icon, action, label, error, format, digits, formatError, className, style, onBlur, onInput, ...input }) {
24
+ export function Field({ icon, action, label, error, format, digits, formatError, mask, className, style, onBlur, onInput, onChange, ...input }) {
11
25
  const ref = useRef(null);
12
26
  const [bad, setBad] = useState(false); // the format check failed, and has not been fixed since
13
27
  const wrong = (error != null && error !== false && error !== '') || bad;
@@ -21,6 +35,35 @@ export function Field({ icon, action, label, error, format, digits, formatError,
21
35
  };
22
36
  const { say } = useCheck((strict) => { const w = verdict(strict); if (w != null)
23
37
  setBad(true); return w; }, () => { ref.current?.focus(); ref.current?.select(); });
38
+ /* a placeholder longer than the box SHRINKS to show whole (Lh 2026-09-25): its width is measured in the input's own font,
39
+ and `--_ph-fit` (the share that fits, ≤ 1) scales the placeholder's size — again whenever the box or the font changes */
40
+ useLayoutEffect(() => {
41
+ const el = ref.current, text = input.placeholder;
42
+ if (!el)
43
+ return;
44
+ if (!text) {
45
+ el.style.removeProperty('--_ph-fit');
46
+ return;
47
+ }
48
+ const ctx = document.createElement('canvas').getContext('2d');
49
+ const fit = () => {
50
+ if (!ctx)
51
+ return;
52
+ const cs = getComputedStyle(el);
53
+ ctx.font = cs.font;
54
+ const room = el.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight), need = ctx.measureText(text).width;
55
+ const share = need > room && room > 0 ? Math.floor((room / need) * 1000) / 1000 : 1;
56
+ if (share < 1)
57
+ el.style.setProperty('--_ph-fit', String(share));
58
+ else
59
+ el.style.removeProperty('--_ph-fit');
60
+ };
61
+ fit();
62
+ const ro = new ResizeObserver(fit);
63
+ ro.observe(el);
64
+ document.fonts?.ready.then(fit);
65
+ return () => ro.disconnect();
66
+ }, [input.placeholder]);
24
67
  useEffect(() => {
25
68
  if (wrong) {
26
69
  ref.current?.focus();
@@ -29,7 +72,21 @@ export function Field({ icon, action, label, error, format, digits, formatError,
29
72
  // eslint-disable-next-line react-hooks/exhaustive-deps -- when the app's error comes or changes
30
73
  }, [error]);
31
74
  return (_jsxs("label", { className: cx('field', action != null && 'field--action', wrong && 'field--error', className), style: style, children: [icon && _jsx(Icon, { name: icon, size: "sm" }), _jsx("input", { ref: ref, "aria-label": label, "aria-invalid": wrong || undefined, ...input, onBlur: (e) => { onBlur?.(e); if (!checked)
32
- return; const w = verdict(false); setBad(w != null); say(w); }, onInput: (e) => { onInput?.(e); if (bad && verdict(false) == null) {
75
+ return; const w = verdict(false); setBad(w != null); say(w); }, onChange: (e) => {
76
+ if (mask) {
77
+ const el = e.currentTarget, before = el.value.slice(0, el.selectionStart ?? el.value.length).replace(/\D/g, '').length;
78
+ const shaped = applyMask(el.value, mask);
79
+ if (shaped !== el.value) {
80
+ el.value = shaped;
81
+ let at = 0;
82
+ for (let n = 0; at < shaped.length && n < before; at++)
83
+ if (/\d/.test(shaped[at]))
84
+ n++; // the caret stays after the same digit
85
+ el.setSelectionRange(at, at);
86
+ }
87
+ }
88
+ onChange?.(e);
89
+ }, onInput: (e) => { onInput?.(e); if (bad && verdict(false) == null) {
33
90
  setBad(false);
34
91
  say(null);
35
92
  } } }), action] }));
@@ -10,12 +10,15 @@ export interface PinProps extends Omit<ComponentProps<'div'>, 'onChange' | 'defa
10
10
  label?: string;
11
11
  /** the code so far, after every change; `complete` once every cell holds a digit */
12
12
  onChange?: (code: string, complete: boolean) => void;
13
- /** the code is wrong: the digits stay in sight in `--signal-error` (words, no fill) with red edges, the cursor goes back to the
14
- * first cell, and the next digit typed starts a new code. The words are the step's (`SheetStep error`). */
13
+ /** the code is wrong (the app's answer — an OTP the server refused): the Pin SHAKES with red edges and its digits in sight,
14
+ * then empties itself (`onChange('', false)`) and the cursor waits in the first cell for a new try — the same as a `match`
15
+ * miss (Lh 2026-09-25; the red digits used to stay until the next one was typed). Give a NEW value for each refusal (a
16
+ * counter) so a second one shakes again. The words are the step's (`SheetStep error`): the app keeps them until it clears `error`. */
15
17
  error?: unknown;
16
18
  /** a FORMAT check on the client: the code it must equal (the first PIN, when this one confirms it). Once every cell is filled
17
- * and it differs, the Pin turns red with its digits in sight, the step shows `formatError` under it for three seconds and then
18
- * steps BACK one step (`onAtChange(at - 1, 'mismatch')` — the app clears both codes there). */
19
+ * and it differs, the Pin SHAKES with red edges and its digits in sight, the step shows `formatError` under it, then the Pin
20
+ * empties itself (`onChange('', false)`), ready for a new try — it stays on its step. Going back to set the first code again
21
+ * is the member's choice: the app puts a `Link` under it (Lh 2026-09-25; it used to step back on its own after 3 s). */
19
22
  match?: string;
20
23
  /** the words when a check fails: the code does not match, or the step's button was pressed with cells still empty */
21
24
  formatError?: ReactNode;
@@ -28,4 +31,4 @@ export interface PinProps extends Omit<ComponentProps<'div'>, 'onChange' | 'defa
28
31
  * arrows walk the cells, a pasted code fills them from the one it landed in; only digits stay. The first cell takes the SMS
29
32
  * one-time-code autofill. The code is a VALUE, controlled like an input: `value` + `onChange`, or `defaultValue`.
30
33
  */
31
- export declare function Pin({ length, value, defaultValue, label, onChange, autoFocus, error, match, formatError, className, ...rest }: PinProps): import("react").JSX.Element;
34
+ export declare function Pin({ length, value, defaultValue, label, onChange, autoFocus, error, match, formatError, className, onAnimationEnd, ...rest }: PinProps): import("react").JSX.Element;
package/dist/forms/Pin.js CHANGED
@@ -2,28 +2,49 @@ import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { useEffect, useRef, useState } from 'react';
3
3
  import { cx } from '../cx.js';
4
4
  import { useCheck } from './check.js';
5
+ const RESET_MS = 900; // a code that does not match: red + shake this long, then the cells empty for a new try
5
6
  /**
6
7
  * Pin — big code entry, one digit per pill; a filled cell turns accent. Lives in a Sheet.
7
8
  * Its own behaviour, inside it: typing a digit moves to the next cell, Backspace in an empty cell moves back and clears, the
8
9
  * arrows walk the cells, a pasted code fills them from the one it landed in; only digits stay. The first cell takes the SMS
9
10
  * one-time-code autofill. The code is a VALUE, controlled like an input: `value` + `onChange`, or `defaultValue`.
10
11
  */
11
- export function Pin({ length = 6, value, defaultValue = '', label = 'Digit', onChange, autoFocus, error, match, formatError, className, ...rest }) {
12
+ export function Pin({ length = 6, value, defaultValue = '', label = 'Digit', onChange, autoFocus, error, match, formatError, className, onAnimationEnd, ...rest }) {
12
13
  const ref = useRef(null);
13
14
  const [inner, setInner] = useState(defaultValue);
14
15
  const code = (value ?? inner).replace(/\D/g, '').slice(0, length);
15
16
  const cell = (i) => ref.current?.querySelectorAll('.pin__cell')[i];
17
+ /* a miss — a match that failed, or the app's error: shake, red, then the cells empty for a new try on the same step */
18
+ const latest = useRef({ value, onChange, error });
19
+ latest.current = { value, onChange, error };
20
+ const miss = () => {
21
+ restart.current = true;
22
+ setShake(true);
23
+ clearTimeout(reset.current);
24
+ reset.current = setTimeout(() => {
25
+ setBad(false);
26
+ setCleared(latest.current.error);
27
+ restart.current = false;
28
+ if (latest.current.value === undefined)
29
+ setInner('');
30
+ latest.current.onChange?.('', false);
31
+ cell(0)?.focus();
32
+ }, RESET_MS);
33
+ };
16
34
  const set = (next) => {
17
35
  next = next.slice(0, length);
18
- if (bad) {
36
+ if (bad || said.current) {
19
37
  setBad(false);
20
38
  say(null);
39
+ said.current = false;
21
40
  }
41
+ clearTimeout(reset.current);
42
+ setCleared(error); // typed before the cells emptied: the new try has begun, the red goes
22
43
  if (match != null && next.length === length && next !== match) {
23
44
  setBad(true);
24
45
  say(words);
25
- back();
26
- restart.current = true;
46
+ said.current = true;
47
+ miss();
27
48
  }
28
49
  if (value === undefined)
29
50
  setInner(next);
@@ -36,8 +57,12 @@ export function Pin({ length = 6, value, defaultValue = '', label = 'Digit', onC
36
57
  // eslint-disable-next-line react-hooks/exhaustive-deps -- on mount only
37
58
  }, [autoFocus]);
38
59
  const [bad, setBad] = useState(false); // a format check failed, and nothing was typed since
60
+ const [shake, setShake] = useState(false); // plays once per miss; off again at its animationend
61
+ const said = useRef(false); // the mismatch words are still shown (the cells were emptied, the words wait for the next digit)
62
+ const reset = useRef(undefined);
63
+ useEffect(() => () => clearTimeout(reset.current), []);
39
64
  const words = formatError ?? '';
40
- const { say, back } = useCheck((strict) => {
65
+ const { say } = useCheck((strict) => {
41
66
  if (strict && code.length < length) {
42
67
  setBad(true);
43
68
  return words;
@@ -48,15 +73,21 @@ export function Pin({ length = 6, value, defaultValue = '', label = 'Digit', onC
48
73
  }
49
74
  return null;
50
75
  }, () => cell(Math.min(code.length, length - 1))?.focus());
51
- const wrong = (error != null && error !== false && error !== '') || bad;
76
+ const errOn = error != null && error !== false && error !== '';
77
+ const [cleared, setCleared] = useState(undefined); // the app's error whose miss has played out (its cells emptied)
78
+ const wrong = (errOn && error !== cleared) || bad;
52
79
  const restart = useRef(false);
53
80
  useEffect(() => {
54
- if (!wrong)
81
+ if (!errOn) {
82
+ setCleared(undefined);
83
+ return;
84
+ }
85
+ if (error === cleared)
55
86
  return;
56
- restart.current = true; // the wrong code stays in sight (red); the next digit typed starts a new one from the first cell
57
87
  cell(0)?.focus();
88
+ miss(); // each new error, once
58
89
  // eslint-disable-next-line react-hooks/exhaustive-deps -- each new error, once
59
- }, [wrong, error]);
90
+ }, [error]);
60
91
  /* the app emptied the code (a new try: it went back after a mismatch): the red goes with it */
61
92
  useEffect(() => {
62
93
  if (code || !bad)
@@ -68,14 +99,18 @@ export function Pin({ length = 6, value, defaultValue = '', label = 'Digit', onC
68
99
  }, [code]);
69
100
  const input = (i) => (e) => {
70
101
  const digits = e.target.value.replace(/\D/g, '');
71
- if (restart.current) {
102
+ if (restart.current) { // a new try after a wrong code: what arrives starts a new code from the first cell
72
103
  restart.current = false;
73
- if (digits) {
74
- set(digits.slice(-1));
75
- cell(1)?.focus();
76
- }
104
+ if (!digits)
105
+ return;
106
+ const old = code[i] ?? '';
107
+ // the cell still held its red digit (not selected, so it stays visible): what came in is the value minus that digit — one
108
+ // typed digit, or a whole pasted / SMS-autofilled code (then it fills every cell, never just the last digit: timebank 142)
109
+ const fresh = digits.length >= length ? digits.slice(-length) : old && digits.length > 1 && digits.startsWith(old) ? digits.slice(old.length) : old && digits.length > 1 && digits.endsWith(old) ? digits.slice(0, -old.length) : digits;
110
+ set(fresh);
111
+ cell(Math.min(fresh.length, length - 1))?.focus();
77
112
  return;
78
- } // a new try
113
+ }
79
114
  const at = Math.min(i, code.length); // a code has no gaps: a digit typed further along lands in the first empty cell
80
115
  if (!digits) {
81
116
  set(code.slice(0, at) + code.slice(at + 1));
@@ -84,6 +119,18 @@ export function Pin({ length = 6, value, defaultValue = '', label = 'Digit', onC
84
119
  set(code.slice(0, at) + digits + code.slice(at + digits.length)); // a paste (or a typed run) spills over the cells that follow
85
120
  cell(Math.min(at + digits.length, length - 1))?.focus();
86
121
  };
122
+ /* a paste after a wrong code: the clipboard's digits ARE the new code — read them straight, whatever the cell held */
123
+ const paste = (e) => {
124
+ if (!restart.current)
125
+ return; // otherwise the change handler spills it over the cells as always
126
+ const digits = e.clipboardData.getData('text').replace(/\D/g, '').slice(0, length);
127
+ if (!digits)
128
+ return;
129
+ e.preventDefault();
130
+ restart.current = false;
131
+ set(digits);
132
+ cell(Math.min(digits.length, length - 1))?.focus();
133
+ };
87
134
  const key = (i) => (e) => {
88
135
  if (e.key === 'Backspace' && !e.currentTarget.value && i > 0) {
89
136
  e.preventDefault();
@@ -99,9 +146,10 @@ export function Pin({ length = 6, value, defaultValue = '', label = 'Digit', onC
99
146
  cell(i + 1)?.focus();
100
147
  }
101
148
  };
102
- return (_jsx("div", { ref: ref, className: cx('pin', wrong && 'pin--error', className), ...rest, children: Array.from({ length }, (_, i) => (
149
+ return (_jsx("div", { ref: ref, className: cx('pin', wrong && 'pin--error', shake && 'pin--shake', className), ...rest, onAnimationEnd: (e) => { if (e.animationName === 'pin-shake')
150
+ setShake(false); onAnimationEnd?.(e); }, children: Array.from({ length }, (_, i) => (
103
151
  // no maxLength: the browser would cut a pasted code before this ever saw it. A cell holds one digit — typing into a full one
104
152
  // replaces it, so its digit is selected whenever the cell takes the focus
105
- _jsx("input", { className: "pin__cell", inputMode: "numeric", pattern: "[0-9]*", placeholder: " ", value: code[i] ?? '', "aria-label": `${label} ${i + 1}`, "aria-invalid": wrong || undefined, autoComplete: i === 0 ? 'one-time-code' : 'off', onChange: input(i), onKeyDown: key(i), onFocus: (e) => { if (!restart.current)
153
+ _jsx("input", { className: "pin__cell", inputMode: "numeric", pattern: "[0-9]*", placeholder: " ", value: code[i] ?? '', "aria-label": `${label} ${i + 1}`, "aria-invalid": wrong || undefined, autoComplete: i === 0 ? 'one-time-code' : 'off', onChange: input(i), onPaste: paste, onKeyDown: key(i), onFocus: (e) => { if (!restart.current)
106
154
  e.currentTarget.select(); } }, i))) }));
107
155
  }
@@ -9,15 +9,11 @@ export type CheckGroup = {
9
9
  add: (c: Check) => () => void;
10
10
  /** a control's own verdict changed (it was left, or it was fixed): its words, or null */
11
11
  say: (c: Check, words: ReactNode | null) => void;
12
- /** a check that can only be failed by going back (the second PIN does not match the first): the group shows the words for a
13
- * moment, then steps back */
14
- back: () => void;
15
12
  };
16
13
  export declare const CheckContext: import("react").Context<CheckGroup | null>;
17
14
  /** join the group around (if any) with this control's check; the latest check function is always the one asked */
18
15
  export declare function useCheck(check: Check['check'], focus: Check['focus']): {
19
16
  say: (words: ReactNode | null) => void | undefined;
20
- back: () => void | undefined;
21
17
  };
22
18
  /** a Field's format: `email`, `tel` (digits, spaces, - + ( ) only) and/or an exact count of `digits` */
23
19
  export declare function formatOk(value: string, format?: 'email' | 'tel', digits?: number): boolean;
@@ -8,7 +8,7 @@ export function useCheck(check, focus) {
8
8
  latest.current.focus = focus;
9
9
  const self = useRef({ check: (s) => latest.current.check(s), focus: () => latest.current.focus() }).current;
10
10
  useEffect(() => group?.add(self), [group, self]);
11
- return { say: (words) => group?.say(self, words), back: () => group?.back() };
11
+ return { say: (words) => group?.say(self, words) };
12
12
  }
13
13
  const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
14
14
  /** a Field's format: `email`, `tel` (digits, spaces, - + ( ) only) and/or an exact count of `digits` */
package/dist/index.d.ts CHANGED
@@ -38,6 +38,7 @@ export * from './actions/Segment.js';
38
38
  export * from './actions/Fab.js';
39
39
  export * from './actions/BtnRow.js';
40
40
  export * from './actions/Link.js';
41
+ export * from './actions/WaitLink.js';
41
42
  export * from './forms/Field.js';
42
43
  export * from './forms/FileBtn.js';
43
44
  export * from './forms/AddRow.js';
package/dist/index.js CHANGED
@@ -42,6 +42,7 @@ export * from './actions/Segment.js';
42
42
  export * from './actions/Fab.js';
43
43
  export * from './actions/BtnRow.js';
44
44
  export * from './actions/Link.js';
45
+ export * from './actions/WaitLink.js';
45
46
  export * from './forms/Field.js';
46
47
  export * from './forms/FileBtn.js';
47
48
  export * from './forms/AddRow.js';
@@ -1,7 +1,6 @@
1
1
  import type { ComponentProps, ReactNode } from 'react';
2
- /** what asked for the move: a `tap` on another step, a `swipe` across the steps, the step's `skip` link, a `mismatch` (a Pin's
3
- * `match` failed: back one step — clear the codes there) */
4
- export type SheetStepsReason = 'tap' | 'swipe' | 'skip' | 'mismatch';
2
+ /** what asked for the move: a `tap` on another step, a `swipe` across the steps, the step's `skip` link */
3
+ export type SheetStepsReason = 'tap' | 'swipe' | 'skip';
5
4
  export interface SheetStepsProps extends ComponentProps<'div'> {
6
5
  /** optional: a `Btn` that finishes, under the steps — always in sight, `disabled` until EVERY step is done (its work, or
7
6
  * skipped: `at` has reached `count`). None = the task ends when `at` reaches `count` — the last step's own action, or its skip. */
@@ -10,6 +9,12 @@ export interface SheetStepsProps extends ComponentProps<'div'> {
10
9
  * work is really finished (the code checked by the server, the two PINs match), `setAt(at + 1)`; after the last step the app sends. `at = count` = every step done —
11
10
  * the app then closes its Sheet (`state="away"`, a beat after the fold) and says so on the page: the steps never close it themselves. */
12
11
  at?: number;
12
+ /** CONTROLLED progress: how many steps are DONE (0-based: the steps before this one show ✓). Without it the steps remember the
13
+ * furthest step reached — a step once passed stays done when `at` goes back. Set it when that progress is GONE: the server
14
+ * refused the code at the end, the app sends `at` back to the code step and clears the PINs → `reached={1}`, and the steps
15
+ * after it are "next" again, no ✓, no summary. Once set, the APP owns it: raise it as the member goes on (keep
16
+ * `reached = max(reached, at)` beside `at`), or a tap back in a `reversible` walk forgets what is after. Read as at least `at`. Lh 2026-09-25 */
17
+ reached?: number;
13
18
  /** UNCONTROLLED: the step it starts on (default 0) */
14
19
  defaultAt?: number;
15
20
  /** the steps ASK to move — a tap on another step, a swipe, a skip. Controlled: set `at` to it (or don't). */
@@ -25,7 +30,7 @@ export interface SheetStepsProps extends ComponentProps<'div'> {
25
30
  * steps on the last one. Goes in a `SheetBody`.
26
31
  * The sheet may be dragged down to peek and back — the body keeps where it was scrolled.
27
32
  */
28
- export declare function SheetSteps({ at, defaultAt, onAtChange, reversible, finish, className, style, children, onFocus, onBlur, onPointerDown, onPointerUp, onPointerCancel, ...rest }: SheetStepsProps): import("react").JSX.Element;
33
+ export declare function SheetSteps({ at, reached, defaultAt, onAtChange, reversible, finish, className, style, children, onFocus, onBlur, onPointerDown, onPointerUp, onPointerCancel, ...rest }: SheetStepsProps): import("react").JSX.Element;
29
34
  export interface SheetStepProps extends Omit<ComponentProps<'li'>, 'title'> {
30
35
  /** the step's name — always shown: over the open step, on the folded line of a done one */
31
36
  title: ReactNode;
@@ -5,7 +5,6 @@ import { Icon } from '../type/Icon.js';
5
5
  import { Link } from '../actions/Link.js';
6
6
  import { FormNote } from '../forms/FormNote.js';
7
7
  import { CheckContext } from '../forms/check.js';
8
- const BACK_MS = 3000; // a PIN that does not match: its red digits and words stay this long, then the step goes back
9
8
  const MOVE_MS = 350; // = --motion-sheet: the accordion's beat
10
9
  const GLIDE_MS = 220; // the pile's slide out of the keyboard's way and back: quick, but the eye follows it
11
10
  const SWIPE_REM = 3; // an up/down move longer than this (and mostly up/down) is a swipe
@@ -17,7 +16,7 @@ const StepCtx = createContext(null);
17
16
  * steps on the last one. Goes in a `SheetBody`.
18
17
  * The sheet may be dragged down to peek and back — the body keeps where it was scrolled.
19
18
  */
20
- export function SheetSteps({ at, defaultAt = 0, onAtChange, reversible = false, finish, className, style, children, onFocus, onBlur, onPointerDown, onPointerUp, onPointerCancel, ...rest }) {
19
+ export function SheetSteps({ at, reached, defaultAt = 0, onAtChange, reversible = false, finish, className, style, children, onFocus, onBlur, onPointerDown, onPointerUp, onPointerCancel, ...rest }) {
21
20
  const [inner, setInner] = useState(defaultAt);
22
21
  const current = at ?? inner;
23
22
  const steps = Children.toArray(children).filter(isValidElement);
@@ -26,7 +25,9 @@ export function SheetSteps({ at, defaultAt = 0, onAtChange, reversible = false,
26
25
  const [furthest, setFurthest] = useState(current);
27
26
  if (current > furthest)
28
27
  setFurthest(current);
29
- const reach = Math.max(furthest, current);
28
+ if (reached != null && furthest !== Math.max(reached, current))
29
+ setFurthest(Math.max(reached, current)); // the app's word wins, and is remembered from there
30
+ const reach = reached != null ? Math.max(reached, current) : Math.max(furthest, current);
30
31
  const ref = useRef(null);
31
32
  const first = useRef(true);
32
33
  const holderRef = useRef(null);
@@ -197,9 +198,7 @@ export function SheetStep({ title, summary, skippable, skipLabel = 'Skip', error
197
198
  const off = phase !== 'now';
198
199
  /* the step is the FORM GROUP of the format checks inside it (Field format/digits/required, Pin match): the words of the first
199
200
  control that fails show under the button; a press on a button of the step checks them all first and holds the press back
200
- when one fails (the app's onClick never hears it); a Pin that does not match shows its words for three seconds, then steps back */
201
- const goRef = useRef(go);
202
- goRef.current = go;
201
+ when one fails (the app's onClick never hears it); a Pin that does not match shakes, shows its words and empties itself — the step stays */
203
202
  const [note, setNote] = useState(null);
204
203
  const checks = useRef(new Map());
205
204
  const first = () => { for (const w of checks.current.values())
@@ -208,18 +207,17 @@ export function SheetStep({ title, summary, skippable, skipLabel = 'Skip', error
208
207
  const group = useMemo(() => ({
209
208
  add: (c) => { checks.current.set(c, null); return () => { checks.current.delete(c); setNote(first()); }; },
210
209
  say: (c, w) => { checks.current.set(c, w); setNote(first()); },
211
- back: () => setTimeout(() => goRef.current(i - 1, 'mismatch'), BACK_MS),
212
- }), [i]);
210
+ }), []);
213
211
  useEffect(() => { if (off) {
214
212
  for (const c of checks.current.keys())
215
213
  checks.current.set(c, null);
216
214
  setNote(null);
217
215
  } }, [off]);
218
- /* a press on the step's button keeps the cursor where it is: the button still clicks, but the field keeps the focus, so a
216
+ /* a press on the step's button or link keeps the cursor where it is: the button still clicks, but the field keeps the focus, so a
219
217
  phone's keyboard does not drop and come back (the pile jumped up and down with it — Lh 2026-09-24) */
220
218
  const keepCursor = (e) => {
221
219
  const at = document.activeElement;
222
- if (e.target.closest('.btn') && at instanceof HTMLElement && at.matches('input, textarea') && e.currentTarget.contains(at))
220
+ if (e.target.closest('.btn, .link') && at instanceof HTMLElement && at.matches('input, textarea') && e.currentTarget.contains(at))
223
221
  e.preventDefault();
224
222
  };
225
223
  const press = (e) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usableapp/cardds",
3
- "version": "0.7.6",
3
+ "version": "0.7.8",
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.",