@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 +19 -0
- package/css/base.css +10 -0
- package/css/choice.css +8 -0
- package/css/sheet.css +15 -3
- package/css/step.css +11 -0
- package/dist/cardds.css +44 -3
- package/dist/forms/Pin.js +25 -3
- package/dist/scaffold/Screen.js +9 -0
- package/dist/sheets/Sheet.d.ts +6 -5
- package/dist/sheets/Sheet.js +66 -51
- package/dist/sheets/SheetSteps.js +14 -1
- package/package.json +1 -1
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,
|
|
215
|
-
|
|
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:
|
|
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,
|
|
1540
|
-
|
|
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:
|
|
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,
|
|
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) => {
|
|
157
|
-
|
|
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
|
}
|
package/dist/scaffold/Screen.js
CHANGED
|
@@ -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')
|
package/dist/sheets/Sheet.d.ts
CHANGED
|
@@ -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
|
|
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
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
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 {};
|
package/dist/sheets/Sheet.js
CHANGED
|
@@ -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
|
-
/*
|
|
10
|
-
|
|
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
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
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
|
|
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
|
-
/*
|
|
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
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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(
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
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) => {
|
|
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.
|
|
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.",
|