@usableapp/cardds 0.7.7 → 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,22 @@
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
+
5
21
  ## 0.7.7 — 2026-09-25
6
22
 
7
23
  ### Changed (breaking for a confirm PIN)
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); }
@@ -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
@@ -3123,6 +3123,8 @@ button { font: inherit; cursor: pointer; }
3123
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)); }
3124
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; }
3125
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 */
3126
3128
  .card--3 .link--muted { color: var(--card-3-muted); }
3127
3129
 
3128
3130
  /* ---- css/media.css — cover card, quote, tile grid, fold, mosaic, wave ---- */
@@ -10,8 +10,10 @@ 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
19
  * and it differs, the Pin SHAKES with red edges and its digits in sight, the step shows `formatError` under it, then the Pin
package/dist/forms/Pin.js CHANGED
@@ -14,6 +14,23 @@ export function Pin({ length = 6, value, defaultValue = '', label = 'Digit', onC
14
14
  const [inner, setInner] = useState(defaultValue);
15
15
  const code = (value ?? inner).replace(/\D/g, '').slice(0, length);
16
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
+ };
17
34
  const set = (next) => {
18
35
  next = next.slice(0, length);
19
36
  if (bad || said.current) {
@@ -22,20 +39,12 @@ export function Pin({ length = 6, value, defaultValue = '', label = 'Digit', onC
22
39
  said.current = false;
23
40
  }
24
41
  clearTimeout(reset.current);
42
+ setCleared(error); // typed before the cells emptied: the new try has begun, the red goes
25
43
  if (match != null && next.length === length && next !== match) {
26
44
  setBad(true);
27
45
  say(words);
28
46
  said.current = true;
29
- restart.current = true;
30
- setShake(true);
31
- reset.current = setTimeout(() => {
32
- setBad(false);
33
- restart.current = false;
34
- if (value === undefined)
35
- setInner('');
36
- onChange?.('', false);
37
- cell(0)?.focus();
38
- }, RESET_MS);
47
+ miss();
39
48
  }
40
49
  if (value === undefined)
41
50
  setInner(next);
@@ -64,15 +73,21 @@ export function Pin({ length = 6, value, defaultValue = '', label = 'Digit', onC
64
73
  }
65
74
  return null;
66
75
  }, () => cell(Math.min(code.length, length - 1))?.focus());
67
- 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;
68
79
  const restart = useRef(false);
69
80
  useEffect(() => {
70
- if (!wrong)
81
+ if (!errOn) {
82
+ setCleared(undefined);
83
+ return;
84
+ }
85
+ if (error === cleared)
71
86
  return;
72
- restart.current = true; // the wrong code stays in sight (red); the next digit typed starts a new one from the first cell
73
87
  cell(0)?.focus();
88
+ miss(); // each new error, once
74
89
  // eslint-disable-next-line react-hooks/exhaustive-deps -- each new error, once
75
- }, [wrong, error]);
90
+ }, [error]);
76
91
  /* the app emptied the code (a new try: it went back after a mismatch): the red goes with it */
77
92
  useEffect(() => {
78
93
  if (code || !bad)
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';
@@ -9,6 +9,12 @@ export interface SheetStepsProps extends ComponentProps<'div'> {
9
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 —
10
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. */
11
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;
12
18
  /** UNCONTROLLED: the step it starts on (default 0) */
13
19
  defaultAt?: number;
14
20
  /** the steps ASK to move — a tap on another step, a swipe, a skip. Controlled: set `at` to it (or don't). */
@@ -24,7 +30,7 @@ export interface SheetStepsProps extends ComponentProps<'div'> {
24
30
  * steps on the last one. Goes in a `SheetBody`.
25
31
  * The sheet may be dragged down to peek and back — the body keeps where it was scrolled.
26
32
  */
27
- 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;
28
34
  export interface SheetStepProps extends Omit<ComponentProps<'li'>, 'title'> {
29
35
  /** the step's name — always shown: over the open step, on the folded line of a done one */
30
36
  title: ReactNode;
@@ -16,7 +16,7 @@ const StepCtx = createContext(null);
16
16
  * steps on the last one. Goes in a `SheetBody`.
17
17
  * The sheet may be dragged down to peek and back — the body keeps where it was scrolled.
18
18
  */
19
- 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 }) {
20
20
  const [inner, setInner] = useState(defaultAt);
21
21
  const current = at ?? inner;
22
22
  const steps = Children.toArray(children).filter(isValidElement);
@@ -25,7 +25,9 @@ export function SheetSteps({ at, defaultAt = 0, onAtChange, reversible = false,
25
25
  const [furthest, setFurthest] = useState(current);
26
26
  if (current > furthest)
27
27
  setFurthest(current);
28
- 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);
29
31
  const ref = useRef(null);
30
32
  const first = useRef(true);
31
33
  const holderRef = useRef(null);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usableapp/cardds",
3
- "version": "0.7.7",
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.",