@usableapp/cardds 0.7.47 → 0.7.49

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,25 @@
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.49 — 2026-10-03
6
+
7
+ ### Fixed
8
+ - **`Pin` on an iPhone: the digits can be read** (Lh 2026-10-03, timebank): iOS gives a text field half an em of padding each side — more than a Pin cell is wide — so a typed digit had no room, started at the cell's middle and ran out of it (a few slivers of ink). The cell now has no padding and no platform look; it is also 2:3 on iOS as everywhere (it stood taller). Nothing to change in a screen.
9
+ - **`Pin`: a cell that cannot be typed into yet does not look ready** — a phone opens its keyboard on a tap, not when a script sets the cursor (a sheet arriving, a step opening). On a touch screen, a cell that got the cursor that way with no keyboard up shows the resting border and no caret, until a finger taps a cell, the keyboard is up, or a digit arrives. A desktop is unchanged.
10
+ - **`SheetSteps`: a digit typed while a step is opening is not lost** — it used to be dropped (the cursor waits in a hidden holder while the step folds, and on an iPhone the keyboard could stay there); it now lands in the open step's first field, and the cursor with it.
11
+
12
+ ### Changed
13
+ - **One look on every phone** (Lh 2026-10-03): the browser's own padding, margin, corners, field look and button colour are taken off every `button`, text `input` and `textarea` once, at zero specificity — iOS, Android and a desktop each gave their own numbers (iOS: 0.5em a side on a field, 1em on a button, system-blue words on an unstyled button). cardds's own rules are untouched; on a desktop nothing changes size (the words in a `Field` start 2px further left). **If your app has its own `<button>` or `<input>` with no styling inside a cardds page, it loses the browser's padding and look too — give it a cardds component or its own rule.** `html` also gets `text-size-adjust: 100%` (an iPhone turned sideways no longer enlarges the words).
14
+ - **A `Sheet`'s long title runs sideways** like a song title, instead of being cut with `…` (Lh 2026-10-03): still one line; it waits, runs to its end, waits, starts again. With reduced motion it is cut with `…` as before. Knob `--sheet-title-run-time`. Nothing to change in a screen. Story `Sheet · LongTitle`.
15
+
16
+ ## 0.7.48 — 2026-10-03
17
+
18
+ ### Changed
19
+ - **A sheet going away has a rest of its own** (Lh 2026-10-03): when a `Sheet` slides to `away`, it is settled `--motion-sheet-rest` (0.2s) AFTER it landed — every time, whether or not another sheet follows. So `onSettled('away')` now fires 0.2s later than in 0.7.47 (slide 0.35s + rest 0.2s); an app that unmounts the sheet or moves on from `onSettled('away')` does so a beat later. A sheet asked up in the same box meanwhile waits until the one leaving is settled. What a member sees when one sheet follows another is as in 0.7.47 (down · the base · up); what changed is whose timeout it is — the sheet that slid out, not the one coming up. Only going away rests: peek ↔ full is as before. Nothing to change in a screen; add no timer of your own.
20
+
21
+ ### Added
22
+ - **A `Track` as the top of a map screen**: `<Screen fill top={<Track>…</Track>}>` over a `SheetStage` — the track is a layer on the stage (the map shows around it, to the screen's top), and the sheet at full stops 16px under it. Story `MapArea · TrackOnTop`.
23
+
5
24
  ## 0.7.47 — 2026-10-03
6
25
 
7
26
  ### Changed
package/css/base.css CHANGED
@@ -39,6 +39,16 @@ body {
39
39
 
40
40
  img, svg { display: block; max-width: 100%; }
41
41
  button { font: inherit; }
42
+ /* ONE LOOK ON EVERY PHONE (Lh 2026-10-03, after the Pin on an iPhone): a browser dresses a button and a text field in its
43
+ platform's own clothes — iOS gives a field 0.5em of padding a side, rounded corners, its own ground; a button 1em a side and
44
+ the system's blue words; Android and a desktop give other numbers. cardds draws every control WHOLE, so none of that may show
45
+ through: the platform's padding, margin, corners, look and colour are taken off here, once, at zero specificity — a
46
+ component's own rule always wins, this only fills in where it says nothing. What a control looks like is then cardds's
47
+ alone, the same on iOS, Android and a desktop. (Checkboxes, radios, ranges and file inputs are drawn by their own rules.) */
48
+ html { -webkit-text-size-adjust: 100%; text-size-adjust: 100%; } /* an iPhone turned on its side must not blow the words up */
49
+ :where(button, input, textarea, select) { margin: 0; border-radius: 0; }
50
+ :where(button, input:not([type="checkbox"], [type="radio"], [type="range"], [type="file"]), textarea) { padding: 0; -webkit-appearance: none; appearance: none; }
51
+ :where(button) { color: inherit; }
42
52
  button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
43
53
 
44
54
  /* zero-specificity reset for controls that draw nothing of their own (2026-09-27 review):
package/css/choice.css CHANGED
@@ -186,6 +186,10 @@
186
186
  min-width: 0;
187
187
  max-width: var(--pin-cell-max-w, var(--fab));
188
188
  aspect-ratio: 2 / 3;
189
+ padding: 0; /* iPhone (Lh 2026-10-03): WebKit on iOS gives a text field 0.5em of padding each side — of a 40px digit that is more
190
+ than the pill is wide, so the field had NO room for its text: the digit started at the pill's middle and ran out of it, a few
191
+ slivers of ink at the edge. The pill centres its digit itself; it needs no padding on any platform. */
192
+ -webkit-appearance: none; appearance: none; /* the pill is drawn here, whole — no platform field look under it */
189
193
  border: var(--border-w) solid var(--pin-cell-border, var(--outline));
190
194
  border-radius: var(--pin-cell-radius, var(--r-chip));
191
195
  background: var(--pin-cell-bg, var(--action-bg));
@@ -215,6 +219,10 @@
215
219
  @keyframes pin-flash { 0% { -webkit-text-fill-color: currentColor; background-size: 0 0; } }
216
220
  .pin--secret .pin__cell::selection { background: transparent; } /* its digit is selected when the cell takes the cursor: nothing to show of it */
217
221
  .pin__cell:focus { outline: none; border-color: var(--pin-cell-active-border, currentColor); }
222
+ /* asleep (Pin sets it): the cursor was put here by a script on a phone and no keyboard is up — nothing can be typed until a tap, so
223
+ the cell does not say "ready": the resting border, no caret */
224
+ .pin--asleep .pin__cell:focus { border-color: var(--pin-cell-border, var(--outline)); }
225
+ .pin--asleep .pin__cell:focus:placeholder-shown { background-image: none; animation: none; }
218
226
  .pin__cell:focus:placeholder-shown {
219
227
  background-image: linear-gradient(currentColor, currentColor);
220
228
  background-size: var(--pin-caret-w, 2px) var(--pin-caret-h, 1em);
package/css/sheet.css CHANGED
@@ -211,10 +211,22 @@
211
211
  whole ask is that switch: peek shows it (a row is --tap tall plus its own
212
212
  padding; the strip cuts in that padding) — card #67's "ไม่อยู่บ้าน" */
213
213
  .sheet > .card__head { min-height: var(--sheet-head-h); }
214
- /* a sheet's title is ONE line, cut with … when the phone is too narrow (Lh 2026-09-28, timebank #153: "ข้อกำหนดและเงื่อนไขการใช้งาน"
215
- wrapped to two lines at 390px) — the thing at its end keeps its place beside it; a caption still takes its own row */
214
+ /* a sheet's title is ONE line, always (Lh 2026-09-28, timebank #153: "ข้อกำหนดและเงื่อนไขการใช้งาน" wrapped to two lines at 390px) — the
215
+ thing at its end keeps its place beside it; a caption still takes its own row. A title too long for the line RUNS sideways
216
+ like a song title (Lh 2026-10-03, as the StackSheet's — it was cut with …, and the end of it could never be read): the Sheet
217
+ measures --_over, the width it overflows by, and sets .is-long; one way — it waits, runs to its end, waits, and starts again
218
+ from the first letter. The move is text-indent: the heading is the app's own element, there is no inner box to slide.
219
+ Before the Sheet has measured, and where motion is off, it is cut with …. */
216
220
  .sheet > .card__head:not(:has(> :is(.t-caption, .t-body))) { flex-wrap: nowrap; }
217
- .sheet > .card__head > :is(h1, h2, h3):first-child, .sheet__title { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
221
+ .sheet > .card__head > :is(h1, h2, h3):first-child, .sheet__title { white-space: nowrap; overflow-x: clip; overflow-y: visible; text-overflow: ellipsis; min-width: 0; } /* clip SIDEWAYS only: hidden cut the Thai tone marks over the line */
222
+ .sheet > .card__head > :is(h1, h2, h3):first-child.is-long, .sheet__title.is-long {
223
+ text-overflow: clip;
224
+ animation: sheet-title-run var(--sheet-title-run-time, var(--_run, 6s)) ease-in-out infinite; /* --_run: from the overflow, 30px a second + the pauses */
225
+ }
226
+ @keyframes sheet-title-run { 0%, 20% { text-indent: 0; } 85%, 100% { text-indent: calc(-1 * var(--_over)); } }
227
+ @media (prefers-reduced-motion: reduce) {
228
+ .sheet > .card__head > :is(h1, h2, h3):first-child.is-long, .sheet__title.is-long { animation: none; text-overflow: ellipsis; }
229
+ }
218
230
  .sheet__head {
219
231
  display: grid;
220
232
  grid-template-columns: var(--tap-sm) 1fr var(--tap-sm);
package/css/step.css CHANGED
@@ -150,6 +150,17 @@
150
150
  margin-top: var(--track-top-lead, var(--sp-4));
151
151
  margin-bottom: calc(var(--track-top-gap, var(--sp-4)) - var(--screen-gap));
152
152
  }
153
+ /* …and over a STAGE (a map behind a sheet, Lh 2026-10-03): the stage is the whole screen, so the track is a LAYER on it — the map
154
+ shows around the pill — and the stage keeps the track's line free itself: a sheet at full stops --track-top-gap under the
155
+ track, as in a step. --top-h is the track's own height, measured by Screen (a numbered track grows with the text). */
156
+ .screen--fill > .track:first-child:has(+ .sheet-stage) {
157
+ position: absolute; z-index: 1;
158
+ inset: calc(var(--safe-top) + var(--track-top-lead, var(--sp-4))) var(--screen-pad) auto;
159
+ margin: 0;
160
+ }
161
+ .screen--fill > .track:first-child + .sheet-stage {
162
+ --_top: calc(var(--track-top-lead, var(--sp-4)) + var(--top-h, var(--header-h)) + var(--track-top-gap, var(--sp-4)));
163
+ }
153
164
  /* a walk page that OPENS on a picture (Lh 2026-09-23): the picture takes the lead's room — as wide as the content (edge to edge), from the top edge, as tall as
154
165
  the lead + the picture's own height (--page-media-h), square — so the words start where they always did */
155
166
  .step > .pages > .page:has(> :is(img, .ph):first-child) { margin-top: 0; }
package/dist/cardds.css CHANGED
@@ -508,6 +508,16 @@ body {
508
508
 
509
509
  img, svg { display: block; max-width: 100%; }
510
510
  button { font: inherit; }
511
+ /* ONE LOOK ON EVERY PHONE (Lh 2026-10-03, after the Pin on an iPhone): a browser dresses a button and a text field in its
512
+ platform's own clothes — iOS gives a field 0.5em of padding a side, rounded corners, its own ground; a button 1em a side and
513
+ the system's blue words; Android and a desktop give other numbers. cardds draws every control WHOLE, so none of that may show
514
+ through: the platform's padding, margin, corners, look and colour are taken off here, once, at zero specificity — a
515
+ component's own rule always wins, this only fills in where it says nothing. What a control looks like is then cardds's
516
+ alone, the same on iOS, Android and a desktop. (Checkboxes, radios, ranges and file inputs are drawn by their own rules.) */
517
+ html { -webkit-text-size-adjust: 100%; text-size-adjust: 100%; } /* an iPhone turned on its side must not blow the words up */
518
+ :where(button, input, textarea, select) { margin: 0; border-radius: 0; }
519
+ :where(button, input:not([type="checkbox"], [type="radio"], [type="range"], [type="file"]), textarea) { padding: 0; -webkit-appearance: none; appearance: none; }
520
+ :where(button) { color: inherit; }
511
521
  button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
512
522
 
513
523
  /* zero-specificity reset for controls that draw nothing of their own (2026-09-27 review):
@@ -1536,10 +1546,22 @@ button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
1536
1546
  whole ask is that switch: peek shows it (a row is --tap tall plus its own
1537
1547
  padding; the strip cuts in that padding) — card #67's "ไม่อยู่บ้าน" */
1538
1548
  .sheet > .card__head { min-height: var(--sheet-head-h); }
1539
- /* a sheet's title is ONE line, cut with … when the phone is too narrow (Lh 2026-09-28, timebank #153: "ข้อกำหนดและเงื่อนไขการใช้งาน"
1540
- wrapped to two lines at 390px) — the thing at its end keeps its place beside it; a caption still takes its own row */
1549
+ /* a sheet's title is ONE line, always (Lh 2026-09-28, timebank #153: "ข้อกำหนดและเงื่อนไขการใช้งาน" wrapped to two lines at 390px) — the
1550
+ thing at its end keeps its place beside it; a caption still takes its own row. A title too long for the line RUNS sideways
1551
+ like a song title (Lh 2026-10-03, as the StackSheet's — it was cut with …, and the end of it could never be read): the Sheet
1552
+ measures --_over, the width it overflows by, and sets .is-long; one way — it waits, runs to its end, waits, and starts again
1553
+ from the first letter. The move is text-indent: the heading is the app's own element, there is no inner box to slide.
1554
+ Before the Sheet has measured, and where motion is off, it is cut with …. */
1541
1555
  .sheet > .card__head:not(:has(> :is(.t-caption, .t-body))) { flex-wrap: nowrap; }
1542
- .sheet > .card__head > :is(h1, h2, h3):first-child, .sheet__title { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
1556
+ .sheet > .card__head > :is(h1, h2, h3):first-child, .sheet__title { white-space: nowrap; overflow-x: clip; overflow-y: visible; text-overflow: ellipsis; min-width: 0; } /* clip SIDEWAYS only: hidden cut the Thai tone marks over the line */
1557
+ .sheet > .card__head > :is(h1, h2, h3):first-child.is-long, .sheet__title.is-long {
1558
+ text-overflow: clip;
1559
+ animation: sheet-title-run var(--sheet-title-run-time, var(--_run, 6s)) ease-in-out infinite; /* --_run: from the overflow, 30px a second + the pauses */
1560
+ }
1561
+ @keyframes sheet-title-run { 0%, 20% { text-indent: 0; } 85%, 100% { text-indent: calc(-1 * var(--_over)); } }
1562
+ @media (prefers-reduced-motion: reduce) {
1563
+ .sheet > .card__head > :is(h1, h2, h3):first-child.is-long, .sheet__title.is-long { animation: none; text-overflow: ellipsis; }
1564
+ }
1543
1565
  .sheet__head {
1544
1566
  display: grid;
1545
1567
  grid-template-columns: var(--tap-sm) 1fr var(--tap-sm);
@@ -1911,6 +1933,17 @@ button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
1911
1933
  margin-top: var(--track-top-lead, var(--sp-4));
1912
1934
  margin-bottom: calc(var(--track-top-gap, var(--sp-4)) - var(--screen-gap));
1913
1935
  }
1936
+ /* …and over a STAGE (a map behind a sheet, Lh 2026-10-03): the stage is the whole screen, so the track is a LAYER on it — the map
1937
+ shows around the pill — and the stage keeps the track's line free itself: a sheet at full stops --track-top-gap under the
1938
+ track, as in a step. --top-h is the track's own height, measured by Screen (a numbered track grows with the text). */
1939
+ .screen--fill > .track:first-child:has(+ .sheet-stage) {
1940
+ position: absolute; z-index: 1;
1941
+ inset: calc(var(--safe-top) + var(--track-top-lead, var(--sp-4))) var(--screen-pad) auto;
1942
+ margin: 0;
1943
+ }
1944
+ .screen--fill > .track:first-child + .sheet-stage {
1945
+ --_top: calc(var(--track-top-lead, var(--sp-4)) + var(--top-h, var(--header-h)) + var(--track-top-gap, var(--sp-4)));
1946
+ }
1914
1947
  /* a walk page that OPENS on a picture (Lh 2026-09-23): the picture takes the lead's room — as wide as the content (edge to edge), from the top edge, as tall as
1915
1948
  the lead + the picture's own height (--page-media-h), square — so the words start where they always did */
1916
1949
  .step > .pages > .page:has(> :is(img, .ph):first-child) { margin-top: 0; }
@@ -3029,6 +3062,10 @@ button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
3029
3062
  min-width: 0;
3030
3063
  max-width: var(--pin-cell-max-w, var(--fab));
3031
3064
  aspect-ratio: 2 / 3;
3065
+ padding: 0; /* iPhone (Lh 2026-10-03): WebKit on iOS gives a text field 0.5em of padding each side — of a 40px digit that is more
3066
+ than the pill is wide, so the field had NO room for its text: the digit started at the pill's middle and ran out of it, a few
3067
+ slivers of ink at the edge. The pill centres its digit itself; it needs no padding on any platform. */
3068
+ -webkit-appearance: none; appearance: none; /* the pill is drawn here, whole — no platform field look under it */
3032
3069
  border: var(--border-w) solid var(--pin-cell-border, var(--outline));
3033
3070
  border-radius: var(--pin-cell-radius, var(--r-chip));
3034
3071
  background: var(--pin-cell-bg, var(--action-bg));
@@ -3058,6 +3095,10 @@ button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
3058
3095
  @keyframes pin-flash { 0% { -webkit-text-fill-color: currentColor; background-size: 0 0; } }
3059
3096
  .pin--secret .pin__cell::selection { background: transparent; } /* its digit is selected when the cell takes the cursor: nothing to show of it */
3060
3097
  .pin__cell:focus { outline: none; border-color: var(--pin-cell-active-border, currentColor); }
3098
+ /* asleep (Pin sets it): the cursor was put here by a script on a phone and no keyboard is up — nothing can be typed until a tap, so
3099
+ the cell does not say "ready": the resting border, no caret */
3100
+ .pin--asleep .pin__cell:focus { border-color: var(--pin-cell-border, var(--outline)); }
3101
+ .pin--asleep .pin__cell:focus:placeholder-shown { background-image: none; animation: none; }
3061
3102
  .pin__cell:focus:placeholder-shown {
3062
3103
  background-image: linear-gradient(currentColor, currentColor);
3063
3104
  background-size: var(--pin-caret-w, 2px) var(--pin-caret-h, 1em);
package/dist/forms/Pin.js CHANGED
@@ -73,6 +73,21 @@ export function Pin({ length = 6, value, defaultValue = '', label = word('digit'
73
73
  const [cleared, setCleared] = useState(undefined); // the app's error whose miss has played out (its cells emptied)
74
74
  const wrong = (errOn && error !== cleared) || bad;
75
75
  const restart = useRef(false);
76
+ /* ASLEEP (iPhone, Lh 2026-10-03): a phone opens its keyboard on a TAP, not when a script sets the cursor (a sheet arriving, a step
77
+ opening). A cell that got the cursor that way with no keyboard up cannot be typed into yet — it must not look ready: no full
78
+ border, no caret, until a finger taps a cell, the keyboard is up, or a digit arrives. Never on a desktop (a key is at hand). */
79
+ const [asleep, setAsleep] = useState(false);
80
+ const tapped = useRef(0);
81
+ const keyboardUp = () => { const vv = window.visualViewport; return !!vv && window.innerHeight - vv.height > 120; };
82
+ const wake = () => { tapped.current = performance.now(); setAsleep(false); };
83
+ useEffect(() => {
84
+ if (!asleep)
85
+ return;
86
+ const vv = window.visualViewport, check = () => { if (keyboardUp())
87
+ setAsleep(false); };
88
+ vv?.addEventListener('resize', check);
89
+ return () => vv?.removeEventListener('resize', check);
90
+ }, [asleep]);
76
91
  const [fresh, setFresh] = useState(null); // the cell whose digit was just typed: a secret Pin shows it for a moment
77
92
  useEffect(() => {
78
93
  if (!errOn) {
@@ -95,6 +110,7 @@ export function Pin({ length = 6, value, defaultValue = '', label = word('digit'
95
110
  // eslint-disable-next-line react-hooks/exhaustive-deps -- when the code is emptied from outside
96
111
  }, [code]);
97
112
  const input = (i) => (e) => {
113
+ wake(); // a digit arrived: the keyboard is there — and the cell the Pin moves on to is ready too
98
114
  const digits = e.target.value.replace(/\D/g, '');
99
115
  if (restart.current) { // a new try after a wrong code: what arrives starts a new code from the first cell
100
116
  restart.current = false;
@@ -133,6 +149,7 @@ export function Pin({ length = 6, value, defaultValue = '', label = word('digit'
133
149
  cell(Math.min(digits.length, length - 1))?.focus();
134
150
  };
135
151
  const key = (i) => (e) => {
152
+ wake();
136
153
  if (e.key === 'Backspace' && !e.currentTarget.value && i > 0) {
137
154
  e.preventDefault();
138
155
  setFresh(null);
@@ -148,11 +165,16 @@ export function Pin({ length = 6, value, defaultValue = '', label = word('digit'
148
165
  cell(i + 1)?.focus();
149
166
  }
150
167
  };
151
- return (_jsx("div", { ref: ref, className: cx('pin', secret && 'pin--secret', wrong && 'pin--error', shake && 'pin--shake', className), ...rest, onAnimationEnd: (e) => { if (e.animationName === 'pin-shake')
168
+ return (_jsx("div", { ref: ref, className: cx('pin', secret && 'pin--secret', wrong && 'pin--error', shake && 'pin--shake', asleep && 'pin--asleep', className), ...rest, onPointerDown: (e) => { wake(); rest.onPointerDown?.(e); }, onBlur: (e) => { if (!e.currentTarget.contains(e.relatedTarget))
169
+ setAsleep(false); rest.onBlur?.(e); }, onAnimationEnd: (e) => { if (e.animationName === 'pin-shake')
152
170
  setShake(false); if (e.animationName === 'pin-flash')
153
171
  setFresh(null); onAnimationEnd?.(e); }, children: Array.from({ length }, (_, i) => (
154
172
  // no maxLength: the browser would cut a pasted code before this ever saw it. A cell holds one digit — typing into a full one
155
173
  // replaces it, so its digit is selected whenever the cell takes the focus
156
- _jsx("input", { className: cx('pin__cell', secret && fresh === i && 'is-fresh'), inputMode: "numeric", pattern: "[0-9]*", placeholder: " ", value: code[i] ?? '', "aria-label": `${label} ${i + 1}`, "aria-invalid": wrong || undefined, autoComplete: i === 0 && !secret ? 'one-time-code' : 'off', onChange: input(i), onPaste: paste, onKeyDown: key(i), onFocus: (e) => { if (!restart.current)
157
- e.currentTarget.select(); } }, i))) }));
174
+ _jsx("input", { className: cx('pin__cell', secret && fresh === i && 'is-fresh'), inputMode: "numeric", pattern: "[0-9]*", placeholder: " ", value: code[i] ?? '', "aria-label": `${label} ${i + 1}`, "aria-invalid": wrong || undefined, autoComplete: i === 0 && !secret ? 'one-time-code' : 'off', onChange: input(i), onPaste: paste, onKeyDown: key(i), onFocus: (e) => {
175
+ if (!restart.current)
176
+ e.currentTarget.select();
177
+ if (performance.now() - tapped.current > 700 && window.matchMedia('(pointer: coarse)').matches && !keyboardUp())
178
+ setAsleep(true); // set by a script, on a phone, no keyboard: not ready
179
+ } }, i))) }));
158
180
  }
@@ -28,6 +28,15 @@ export function Screen({ fill, palette, as = 'div', top, bottom, ready = true, c
28
28
  }
29
29
  return watchSize([bar], () => el.style.setProperty('--bottom-h', `${bar.offsetHeight}px`));
30
30
  }, [bottom, ready]);
31
+ /* a TRACK as the top, over a stage: its height published as --top-h — the stage keeps that line free for it (step.css) */
32
+ useEffect(() => {
33
+ const el = own.current, track = el?.querySelector(':scope > .track:first-child');
34
+ if (!el || !track) {
35
+ el?.style.removeProperty('--top-h');
36
+ return;
37
+ }
38
+ return watchSize([track], () => el.style.setProperty('--top-h', `${track.offsetHeight}px`));
39
+ }, [top, ready]);
31
40
  const setRef = (node) => {
32
41
  own.current = node;
33
42
  if (typeof ref === 'function')
@@ -14,7 +14,8 @@ export interface SheetProps extends Omit<ComponentProps<'article'>, 'onChange'>
14
14
  /** the sheet ASKS to move — the handle was tapped or dragged, a field in a peeked sheet took focus, the keyboard moved the cut.
15
15
  * Controlled: set your state to it (or don't — the sheet stays). This is where an app ties a sheet to its router / back button. */
16
16
  onStateChange?: (state: SheetState, reason: SheetChangeReason) => void;
17
- /** the sheet has FINISHED sliding to `state` (its transition ended; at once where motion is off). To leave the screen: set `away`, and take the
17
+ /** the sheet has FINISHED its move to `state` (its transition ended; at once where motion is off). Going `away` it is finished a beat
18
+ * AFTER it landed (`--motion-sheet-rest`) — its own timeout, every time. To leave the screen: set `away`, and take the
18
19
  * sheet out of the tree here — `onSettled={(s) => s === 'away' && setMounted(false)}` — so it slides out instead of vanishing */
19
20
  onSettled?: (state: SheetState) => void;
20
21
  /** the states a drag snaps to and a reveal may climb through — "peek half full", or an array (default all four) */
@@ -38,10 +39,10 @@ export interface SheetProps extends Omit<ComponentProps<'article'>, 'onChange'>
38
39
  * `{open && <Sheet state="full">}` slides in. Leaving is the app's beat: set `away`, and unmount on `onSettled('away')` — unmount it
39
40
  * while it is still up and it can only vanish (a dev warning says so).
40
41
  *
41
- * ONE FOLLOWS ANOTHER: a sheet that comes up while another sheet of the same box is on its way out waits — the leaving one goes
42
- * ALL the way down, the base is seen for a beat (`--motion-sheet-rest`), and only then it rises: "one left, another came", never two
43
- * sheets crossing and never one continuous move. It is the Sheet's own habit, whatever the app's wiring (both states set in one
44
- * render, or the next raised from the first's `onSettled`) — the app adds no timer.
42
+ * GOING AWAY HAS A REST: a sheet that slides out is settled a beat after it landed (`--motion-sheet-rest`, the base is seen) — a
43
+ * fixed habit of every sheet. A sheet asked up in the same box meanwhile waits for it: "one left, another came", never two sheets
44
+ * crossing and never one continuous move, whatever the app's wiring (both states set in one render, or the next raised from the
45
+ * first's `onSettled`) — the app adds no timer.
45
46
  */
46
47
  export declare function Sheet({ state, defaultState, onStateChange, onSettled, states, handle, className, children, onFocus, ...rest }: SheetProps): import("react").JSX.Element;
47
48
  export {};
@@ -6,8 +6,14 @@ import { word } from '../words.js';
6
6
  const ORDER = ['peek', 'half', '3q', 'full']; // low → high; `away` is below them all and outside them
7
7
  const RANK = { away: 0, peek: 1, half: 2, '3q': 3, full: 4 };
8
8
  const TAP_REM = 0.5; // = --sp-2: a move shorter than this is a tap, not a drag
9
- /* ONE SHEET FOLLOWS ANOTHER (Lh 2026-10-03): when a box's sheet landed away, per box — a sheet coming up reads it to give the base its beat */
10
- const landed = new WeakMap();
9
+ /* A SHEET THAT GOES AWAY IS NOT DONE WHEN IT LANDS (Lh 2026-10-03): it holds its box for a beat more (--motion-sheet-rest) — the base
10
+ is seen — and only then is it settled. `out` = the sheets still on their way out, per box; a sheet asked up in that box waits for it to empty. */
11
+ const out = new WeakMap();
12
+ const waiting = new WeakMap();
13
+ const leave = (box, key) => { if (!out.has(box))
14
+ out.set(box, new Set()); out.get(box).add(key); };
15
+ const gone = (box, key) => { const set = out.get(box); if (set?.delete(key) && !set.size)
16
+ [...(waiting.get(box) ?? [])].forEach((go) => go()); };
11
17
  const ms = (t) => Math.max(0, ...t.split(',').map((v) => parseFloat(v) * (v.trim().endsWith('ms') ? 1 : 1000) || 0)); // a computed `0.35s, 0s` list → its longest, in ms
12
18
  /**
13
19
  * Sheet — the SINGLE sheet: one card as a bottom sheet holding what the member ACTS on. Always the full height of its
@@ -25,10 +31,10 @@ const ms = (t) => Math.max(0, ...t.split(',').map((v) => parseFloat(v) * (v.trim
25
31
  * `{open && <Sheet state="full">}` slides in. Leaving is the app's beat: set `away`, and unmount on `onSettled('away')` — unmount it
26
32
  * while it is still up and it can only vanish (a dev warning says so).
27
33
  *
28
- * ONE FOLLOWS ANOTHER: a sheet that comes up while another sheet of the same box is on its way out waits — the leaving one goes
29
- * ALL the way down, the base is seen for a beat (`--motion-sheet-rest`), and only then it rises: "one left, another came", never two
30
- * sheets crossing and never one continuous move. It is the Sheet's own habit, whatever the app's wiring (both states set in one
31
- * render, or the next raised from the first's `onSettled`) — the app adds no timer.
34
+ * GOING AWAY HAS A REST: a sheet that slides out is settled a beat after it landed (`--motion-sheet-rest`, the base is seen) — a
35
+ * fixed habit of every sheet. A sheet asked up in the same box meanwhile waits for it: "one left, another came", never two sheets
36
+ * crossing and never one continuous move, whatever the app's wiring (both states set in one render, or the next raised from the
37
+ * first's `onSettled`) — the app adds no timer.
32
38
  */
33
39
  export function Sheet({ state, defaultState = 'half', onStateChange, onSettled, states, handle = true, className, children, onFocus, ...rest }) {
34
40
  const [current, request] = useControlled(state, defaultState, onStateChange);
@@ -38,10 +44,17 @@ export function Sheet({ state, defaultState = 'half', onStateChange, onSettled,
38
44
  const was = useRef('away'); // mounted up = it came onto the screen: it rises from away like any arrival
39
45
  const skipClick = useRef(false);
40
46
  const [entered, setEntered] = useState(false); // false for the first frame: the class says `away`, so a sheet mounted up has somewhere to slide from
41
- const [held, setHeld] = useState(false); // true while it waits for the sheet before it to land and the base to be seen
47
+ const me = useRef({}).current; // this sheet, in its box's list of sheets on their way out
48
+ const [held, setHeld] = useState(false); // true while it waits for the sheet going away before it to be settled
49
+ const [prev, setPrev] = useState('away'); // asked up from `away` (or mounted up): it holds first, and rises once its box is free
50
+ if (prev !== current) {
51
+ setPrev(current);
52
+ setHeld(prev === 'away' && current !== 'away');
53
+ }
42
54
  const shown = entered && !held ? current : 'away';
43
55
  const live = held ? 'away' : current; // where it is as far as its own habits go: a held sheet has not come up yet
44
56
  const latest = useLatest({ current, onSettled });
57
+ const last = useRef('away'); // where it was last SHOWN (the settle effect keeps it)
45
58
  const drag = useRef(null);
46
59
  const allowed = useCallback(() => {
47
60
  const list = (typeof states === 'string' ? states.split(/\s+/) : [...(states ?? [])]).filter((s) => ORDER.includes(s));
@@ -51,72 +64,54 @@ export function Sheet({ state, defaultState = 'half', onStateChange, onSettled,
51
64
  void ref.current?.offsetHeight; // the browser styles the `away` frame first, or there is no transition to run
52
65
  setEntered(true);
53
66
  }, []);
54
- /* ONE FOLLOWS ANOTHER: it is asked up from `away` (or mounted up) while a sibling sheet is still leaving — or landed less than the
55
- rest ago: it holds at `away` until that one's slide has ended and the base has been seen for --motion-sheet-rest. Read from the
56
- DOM in the commit itself (a leaving sibling already says `away` but still stands in the box), so the order of the two sheets
57
- in the tree, and whether the app set both in one render or one after the other, make no difference. */
58
- const before = useRef('away');
67
+ /* it starts on its way out: said in the commit itself, before any sheet of the box decides to rise */
59
68
  useLayoutEffect(() => {
60
- const el = ref.current, box = el?.parentElement, from = before.current;
61
- before.current = current;
62
- if (!el || !box || from !== 'away' || current === 'away' || reducedMotion()) {
63
- setHeld(false);
69
+ const box = ref.current?.parentElement;
70
+ if (box && current === 'away' && last.current !== 'away' && !reducedMotion())
71
+ leave(box, me);
72
+ }, [current, me]);
73
+ /* held: it rises when no sheet of its box is on its way out — at once if none is */
74
+ useEffect(() => {
75
+ const box = ref.current?.parentElement;
76
+ if (!held)
64
77
  return;
65
- }
66
- const rest = ms(getComputedStyle(el).getPropertyValue('--motion-sheet-rest') || '0s');
67
- const floor = box.getBoundingClientRect().bottom - 1;
68
- const leaving = [...box.querySelectorAll(':scope > .sheet--away')].filter((s) => s !== el && s.getBoundingClientRect().top < floor);
69
- const since = performance.now() - (landed.get(box) ?? -Infinity);
70
- if (!leaving.length && since >= rest) {
78
+ if (!box || !out.get(box)?.size) {
71
79
  setHeld(false);
72
80
  return;
73
81
  }
74
- setHeld(true);
75
- let timer = 0, left = leaving.length;
76
- const go = (after) => { timer = window.setTimeout(() => setHeld(false), after); };
77
- if (!left) {
78
- go(rest - since);
79
- return () => clearTimeout(timer);
80
- }
81
- const stops = leaving.map((s) => {
82
- let done = false;
83
- const end = () => { if (done)
84
- return; done = true; clearTimeout(t); s.removeEventListener('transitionend', onEnd); if (--left === 0)
85
- go(rest); };
86
- const onEnd = (e) => { if (e.target === s && e.propertyName === 'translate')
87
- end(); };
88
- const cs = getComputedStyle(s);
89
- s.addEventListener('transitionend', onEnd);
90
- const t = setTimeout(end, ms(cs.transitionDuration) + ms(cs.transitionDelay) + 100);
91
- return () => { done = true; clearTimeout(t); s.removeEventListener('transitionend', onEnd); };
92
- });
93
- return () => { stops.forEach((stop) => stop()); clearTimeout(timer); };
94
- }, [current]);
82
+ const go = () => setHeld(false);
83
+ if (!waiting.has(box))
84
+ waiting.set(box, new Set());
85
+ waiting.get(box).add(go);
86
+ return () => { waiting.get(box)?.delete(go); };
87
+ }, [held]);
95
88
  /* it has finished moving: the transition ended (or at once where there is none) — say so once per move; a move cut short by another says nothing */
96
- const last = useRef(shown);
97
89
  useEffect(() => {
98
- const el = ref.current;
90
+ const el = ref.current, box = el?.parentElement;
99
91
  if (!el || last.current === shown)
100
92
  return;
101
93
  last.current = shown;
102
94
  const cs = getComputedStyle(el);
103
- let done = false;
95
+ let done = false, rest = 0;
96
+ const settled = () => { if (box)
97
+ gone(box, me); latest.current.onSettled?.(latest.current.current); };
104
98
  const end = () => {
105
99
  if (done)
106
100
  return;
107
101
  done = true;
108
102
  clearTimeout(t);
109
103
  el.removeEventListener('transitionend', onEnd);
110
- if (shown === 'away' && el.parentElement)
111
- landed.set(el.parentElement, performance.now()); // the sheet after it counts its rest from here
112
- latest.current.onSettled?.(latest.current.current);
104
+ if (shown !== 'away' || reducedMotion())
105
+ return settled();
106
+ rest = window.setTimeout(settled, ms(cs.getPropertyValue('--motion-sheet-rest') || '0s')); // landed: the base is seen for the rest, THEN it is settled
113
107
  };
114
108
  const onEnd = (e) => { if (e.target === el && e.propertyName === 'translate')
115
109
  end(); };
116
110
  el.addEventListener('transitionend', onEnd);
117
111
  const t = setTimeout(end, ms(cs.transitionDuration) + ms(cs.transitionDelay) + 100); // no transition (motion off) or none came: settled anyway
118
- return () => { done = true; clearTimeout(t); el.removeEventListener('transitionend', onEnd); };
119
- }, [shown, latest]);
112
+ return () => { done = true; clearTimeout(t); clearTimeout(rest); el.removeEventListener('transitionend', onEnd); if (box && shown === 'away')
113
+ gone(box, me); }; // cut short on its way out (raised again, unmounted): the box is free
114
+ }, [shown, latest, me]);
120
115
  /* taken out of the tree while it is still up — the box stays, the sheet goes: nothing to slide, so it vanishes. Said once in dev.
121
116
  (A whole screen leaving takes the box too; StrictMode's trial unmount is over by the time the timer looks.) */
122
117
  const alive = useRef(false);
@@ -225,6 +220,26 @@ export function Sheet({ state, defaultState = 'half', onStateChange, onSettled,
225
220
  const stop = watchSize([head], publish);
226
221
  return () => { stop(); box.style.removeProperty('--sheet-peek'); };
227
222
  }, []);
223
+ /* 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
224
+ words overflow the line and hands it to the CSS (--_over, --_run, .is-long — sheet.css). Measured on the words themselves
225
+ (a range), so the running indent never feeds back into the number. */
226
+ useEffect(() => {
227
+ const el = ref.current, title = el?.querySelector(':scope > .card__head > :is(h1, h2, h3):first-child, :scope > .sheet__head .sheet__title');
228
+ if (!el || !title)
229
+ return;
230
+ const measure = () => {
231
+ const words = document.createRange();
232
+ words.selectNodeContents(title);
233
+ const over = Math.max(0, Math.ceil(words.getBoundingClientRect().width - title.clientWidth));
234
+ title.style.setProperty('--_over', `${over}px`);
235
+ title.style.setProperty('--_run', `${(over / 30 + 3).toFixed(1)}s`);
236
+ title.classList.toggle('is-long', over > 0);
237
+ };
238
+ const stop = watchSize([title], measure);
239
+ const mo = new MutationObserver(measure);
240
+ mo.observe(title, { childList: true, characterData: true, subtree: true }); // the app changed the words
241
+ return () => { stop(); mo.disconnect(); title.classList.remove('is-long'); title.style.removeProperty('--_over'); title.style.removeProperty('--_run'); };
242
+ });
228
243
  /* the FOOT's room follows what the foot really holds (timebank, Lh 2026-10-01): a button grows with the member's text size, a
229
244
  line may stand over it (an error) — the CSS strip was a fixed sum (--btn-h), so the body's words ran under the button. The
230
245
  sheet measures its foot's rows and publishes --sheet-foot-h; the strip, its ground and the body's stop all read it. */
@@ -180,7 +180,20 @@ export function SheetSteps({ at, reached, defaultAt = 0, onAtChange, reversible
180
180
  else if (dy < 0 && ref.current?.querySelector('.sheet-step--now[data-skippable]'))
181
181
  go(to, 'skip'); // forward only, and only past a step that may be skipped
182
182
  };
183
- return (_jsxs("div", { ref: ref, className: cx('sheet-steps', !reversible && 'sheet-steps--locked', className), style: style, onFocus: focusIn, onBlur: focusOut, onPointerDown: down, onPointerUp: up, onPointerCancel: (e) => { onPointerCancel?.(e); press.current = null; }, ...rest, children: [_jsx("input", { ref: holderRef, className: "sheet-steps__holder", "aria-hidden": true, tabIndex: -1, autoComplete: "off", onInput: (e) => { e.currentTarget.value = ''; } }), _jsx("ol", { className: "sheet-steps__track", children: steps.map((step, i) => (_jsx(StepCtx.Provider, { value: { numbered, i, at: current, furthest: reach, count, reversible, go }, children: step }, step.key ?? i))) }), finish != null && ( // always in sight; it takes taps only once every step is done (or skipped)
183
+ return (_jsxs("div", { ref: ref, className: cx('sheet-steps', !reversible && 'sheet-steps--locked', className), style: style, onFocus: focusIn, onBlur: focusOut, onPointerDown: down, onPointerUp: up, onPointerCancel: (e) => { onPointerCancel?.(e); press.current = null; }, ...rest, children: [_jsx("input", { ref: holderRef, className: "sheet-steps__holder", "aria-hidden": true, tabIndex: -1, autoComplete: "off", onInput: (e) => {
184
+ /* typed while the cursor waits here (the fold is playing — or, on an iPhone, the keyboard never followed the cursor into the
185
+ new step: a focus set by script does not always take the keyboard with it, and every digit landed here and was dropped —
186
+ timebank, Lh 2026-10-03): what was typed belongs to the open step's first field. Hand it over, and the cursor with it —
187
+ from inside the key's own event, which the phone does follow. */
188
+ const typed = e.currentTarget.value;
189
+ e.currentTarget.value = '';
190
+ const field = ref.current?.querySelector('.sheet-step--now .sheet-step__inner input:not([type=checkbox], [type=radio], [type=hidden])');
191
+ if (!typed || !field)
192
+ return;
193
+ field.focus({ preventScroll: true });
194
+ Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set.call(field, field.value + typed);
195
+ field.dispatchEvent(new Event('input', { bubbles: true }));
196
+ } }), _jsx("ol", { className: "sheet-steps__track", children: steps.map((step, i) => (_jsx(StepCtx.Provider, { value: { numbered, i, at: current, furthest: reach, count, reversible, go }, children: step }, step.key ?? i))) }), finish != null && ( // always in sight; it takes taps only once every step is done (or skipped)
184
197
  _jsx("div", { className: "sheet-steps__finish", children: isValidElement(finish) ? cloneElement(finish, { disabled: reach < count || finish.props.disabled }) : finish }))] }));
185
198
  }
186
199
  /** SheetStep — one step of a SheetSteps: its title, then what the member does (a Field, a Pin, a Toggle, the step's own Btn). */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usableapp/cardds",
3
- "version": "0.7.47",
3
+ "version": "0.7.49",
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.",