@usableapp/cardds 0.7.8 → 0.7.10
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 +22 -0
- package/README.md +2 -0
- package/css/base.css +5 -0
- package/css/card.css +8 -2
- package/css/choice.css +1 -0
- package/css/stack.css +1 -1
- package/css/step.css +3 -1
- package/dist/cardds.css +18 -4
- package/dist/forms/Pin.d.ts +4 -1
- package/dist/forms/Pin.js +3 -3
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/scaffold/slideScreen.d.ts +15 -0
- package/dist/scaffold/slideScreen.js +65 -0
- package/dist/type/Text.d.ts +4 -1
- package/dist/type/Text.js +2 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,28 @@
|
|
|
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.10 — 2026-09-25
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
- A fill screen's bottom layer (a Walkthrough's dots · controls · CTA) has room above its first row: `padding-top`
|
|
9
|
+
`--sp-3` (knob `--action-bar-fill-pad-top`; was 0 — the dots touched the words scrolling under the layer).
|
|
10
|
+
|
|
11
|
+
## 0.7.9 — 2026-09-25
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
- A `Screen` reads like a native app: words are no longer highlighted by a long press or a drag, no Copy / Web search
|
|
15
|
+
callout. Fields (input, textarea, contenteditable) stay selectable. The open sheet of a stack is not selectable any more
|
|
16
|
+
either (it was). `Text selectable` opens the few words a member may copy (a code, an address). Knob `--screen-select: text`
|
|
17
|
+
= the old way everywhere. Story `Text · Selectable`.
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
- `slideScreen(update, 'next' | 'back')` + `installScreenSlide()` — one screen to the next slides WHOLE (top · content ·
|
|
21
|
+
bottom), the way a walk's pages do; the default for every route change (Lh 2026-09-25). Before: a route change just
|
|
22
|
+
swapped the screen. Wrap every navigate in `slideScreen`; call `installScreenSlide()` once at start for the back button.
|
|
23
|
+
`data-slide="off"` on a Screen opts it out. Story `Step · WalksOn`.
|
|
24
|
+
- `Pin secret` — a PIN: a dot per typed digit (`-webkit-text-security`), the number pad kept, no SMS autofill. An OTP stays
|
|
25
|
+
in sight (no `secret`). Story `Pin · Secret`; the PIN steps of the SheetSteps stories use it.
|
|
26
|
+
|
|
5
27
|
## 0.7.8 — 2026-09-25
|
|
6
28
|
|
|
7
29
|
### Changed
|
package/README.md
CHANGED
|
@@ -322,6 +322,8 @@ between at 40% down the screen (see *Anatomy*, "Where the centre is"):
|
|
|
322
322
|
- **Page that moves** (Lh 2026-09-20) — two ways, never both on one screen:
|
|
323
323
|
- *Pages of words:* `.step > .pages > .page` (one `aria-current="step"`, the rest `inert`) — every page
|
|
324
324
|
one height, the round pager turns them (`slideTo()`), the words slide like a page. No sheet there.
|
|
325
|
+
ONE SCREEN TO THE NEXT (a route change) slides the WHOLE screen the same way: `slideScreen(() => navigate(to), 'next' | 'back')`,
|
|
326
|
+
and `installScreenSlide()` once at app start for the back button (story `Step · WalksOn`). `data-slide="off"` on a Screen = no slide.
|
|
325
327
|
- *Sheets the app calls:* the base offers WAYS IN — sign in · new phone · join — as buttons (`.cta-pack`); each
|
|
326
328
|
way has a sheet that holds its WHOLE form and its own submit on its foot, starting `.sheet--away` (not on the
|
|
327
329
|
screen: slid out, hidden once gone, `inert`). **The wiring is the app's, not cardds's**: a `useState` of which
|
package/css/base.css
CHANGED
|
@@ -107,6 +107,11 @@ button { font: inherit; cursor: pointer; }
|
|
|
107
107
|
.grow { flex: 1; min-width: 0; } /* the child that takes the remaining row space */
|
|
108
108
|
/* read, not shown (Lh 2026-09-23): words a screen reader needs — a page's h1 the design leaves out — kept in the page, taking no room.
|
|
109
109
|
The 1px is the one the platform needs to keep it in the accessibility tree, not a length of the design. */
|
|
110
|
+
/* a screen reads like a native app (Lh 2026-09-25): words are not highlighted by a long press or a drag, no copy / web-search
|
|
111
|
+
callout — only what is typed into, and words marked selectable (`Text selectable` → .t-select: a code, an address).
|
|
112
|
+
Knob --screen-select: `text` = the web page's old way everywhere. */
|
|
113
|
+
.screen { -webkit-user-select: var(--screen-select, none); user-select: var(--screen-select, none); -webkit-touch-callout: none; }
|
|
114
|
+
.screen :is(input, textarea, [contenteditable="true"]), .screen .t-select { -webkit-user-select: text; user-select: text; -webkit-touch-callout: default; }
|
|
110
115
|
.t-sr { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
|
|
111
116
|
|
|
112
117
|
/* ---- base content: words straight on the base, no card around them — an
|
package/css/card.css
CHANGED
|
@@ -240,7 +240,8 @@
|
|
|
240
240
|
<section class="page" inert>…</section>
|
|
241
241
|
</div></section> <div class="action-bar action-bar--pager">← →</div> */
|
|
242
242
|
::view-transition-old(root), ::view-transition-new(root) { animation: none; }
|
|
243
|
-
::view-transition-old(centre-card-out), ::view-transition-new(centre-card-in)
|
|
243
|
+
::view-transition-old(centre-card-out), ::view-transition-new(centre-card-in),
|
|
244
|
+
::view-transition-old(screen-out), ::view-transition-new(screen-in) { /* screen-*: slideScreen — one whole screen to the next (Lh 2026-09-25) */
|
|
244
245
|
animation-duration: var(--motion-slide);
|
|
245
246
|
animation-timing-function: ease-out;
|
|
246
247
|
mix-blend-mode: normal;
|
|
@@ -249,12 +250,17 @@
|
|
|
249
250
|
:root[data-slide="next"]::view-transition-new(centre-card-in) { animation-name: centre-card-in-right; }
|
|
250
251
|
:root[data-slide="back"]::view-transition-old(centre-card-out) { animation-name: centre-card-out-right; }
|
|
251
252
|
:root[data-slide="back"]::view-transition-new(centre-card-in) { animation-name: centre-card-in-left; }
|
|
253
|
+
:root[data-slide="next"]::view-transition-old(screen-out) { animation-name: centre-card-out-left; }
|
|
254
|
+
:root[data-slide="next"]::view-transition-new(screen-in) { animation-name: centre-card-in-right; }
|
|
255
|
+
:root[data-slide="back"]::view-transition-old(screen-out) { animation-name: centre-card-out-right; }
|
|
256
|
+
:root[data-slide="back"]::view-transition-new(screen-in) { animation-name: centre-card-in-left; }
|
|
252
257
|
@keyframes centre-card-out-left { to { translate: -100% 0; opacity: 0; } }
|
|
253
258
|
@keyframes centre-card-out-right { to { translate: 100% 0; opacity: 0; } }
|
|
254
259
|
@keyframes centre-card-in-right { from { translate: 100% 0; opacity: 0; } }
|
|
255
260
|
@keyframes centre-card-in-left { from { translate: -100% 0; opacity: 0; } }
|
|
256
261
|
@media (prefers-reduced-motion: reduce) {
|
|
257
|
-
::view-transition-old(centre-card-out), ::view-transition-new(centre-card-in)
|
|
262
|
+
::view-transition-old(centre-card-out), ::view-transition-new(centre-card-in),
|
|
263
|
+
::view-transition-old(screen-out), ::view-transition-new(screen-in) { animation: none; }
|
|
258
264
|
}
|
|
259
265
|
/* the page dots under the card sit centred under it — the indicator belongs
|
|
260
266
|
with the page that changes, never in the bar (Lh, 2026-09-12) */
|
package/css/choice.css
CHANGED
|
@@ -210,6 +210,7 @@
|
|
|
210
210
|
edge. The sign is INSIDE the pill: a full-ink border and a blinking bar drawn where the digit will land (the native caret
|
|
211
211
|
is hidden — its height and place are the browser's; the pill's bar is one rule). A filled cell selects its digit instead. */
|
|
212
212
|
.pin__cell { caret-color: transparent; }
|
|
213
|
+
.pin--secret .pin__cell { -webkit-text-security: disc; text-security: disc; } /* a PIN: a dot per digit, the number pad kept (Lh 2026-09-25) */
|
|
213
214
|
.pin__cell:focus { outline: none; border-color: var(--pin-cell-active-border, currentColor); }
|
|
214
215
|
.pin__cell:focus:placeholder-shown {
|
|
215
216
|
background-image: linear-gradient(currentColor, currentColor);
|
package/css/stack.css
CHANGED
|
@@ -328,4 +328,4 @@
|
|
|
328
328
|
/* a stacked sheet is a control until it is open — tapped, never read into: no text selection (a drag, a double tap, a long press),
|
|
329
329
|
no grey flash, no iOS callout; its head stays one when open. Only the OPEN sheet's body is text to select (Lh 2026-09-23) */
|
|
330
330
|
.sheet-stack--tap > .card, .sheet-stack > .card > .card__head { -webkit-user-select: none; user-select: none; -webkit-tap-highlight-color: transparent; -webkit-touch-callout: none; }
|
|
331
|
-
.sheet-stack--tap > .card.is-open > :not(.card__head) { -webkit-user-select:
|
|
331
|
+
.sheet-stack--tap > .card.is-open > :not(.card__head) { -webkit-user-select: var(--screen-select, none); user-select: var(--screen-select, none); } /* the open sheet reads as the rest of the screen: not selectable (Lh 2026-09-25; it was text) */
|
package/css/step.css
CHANGED
|
@@ -120,7 +120,9 @@
|
|
|
120
120
|
position: absolute; inset-inline: 0; top: auto; bottom: var(--kb, 0rem);
|
|
121
121
|
max-width: none;
|
|
122
122
|
margin: 0;
|
|
123
|
-
|
|
123
|
+
/* room between its top edge and its first row (the walk's dots): since it has a ground, a 0 here put the dots against the words
|
|
124
|
+
running under it (Lh 2026-09-25) — the bar's own top padding */
|
|
125
|
+
padding-top: var(--action-bar-fill-pad-top, var(--sp-3));
|
|
124
126
|
/* its GROUND (Lh 2026-09-24, timebank card 141 — was "no ground of its own"): words that run under the layer mid-scroll
|
|
125
127
|
showed through the dots, the controls and the buttons. The layer lies on the screen's own surface now; a project sets
|
|
126
128
|
another (or `transparent`, the old see-through) with the knob */
|
package/dist/cardds.css
CHANGED
|
@@ -488,6 +488,11 @@ button { font: inherit; cursor: pointer; }
|
|
|
488
488
|
.grow { flex: 1; min-width: 0; } /* the child that takes the remaining row space */
|
|
489
489
|
/* read, not shown (Lh 2026-09-23): words a screen reader needs — a page's h1 the design leaves out — kept in the page, taking no room.
|
|
490
490
|
The 1px is the one the platform needs to keep it in the accessibility tree, not a length of the design. */
|
|
491
|
+
/* a screen reads like a native app (Lh 2026-09-25): words are not highlighted by a long press or a drag, no copy / web-search
|
|
492
|
+
callout — only what is typed into, and words marked selectable (`Text selectable` → .t-select: a code, an address).
|
|
493
|
+
Knob --screen-select: `text` = the web page's old way everywhere. */
|
|
494
|
+
.screen { -webkit-user-select: var(--screen-select, none); user-select: var(--screen-select, none); -webkit-touch-callout: none; }
|
|
495
|
+
.screen :is(input, textarea, [contenteditable="true"]), .screen .t-select { -webkit-user-select: text; user-select: text; -webkit-touch-callout: default; }
|
|
491
496
|
.t-sr { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
|
|
492
497
|
|
|
493
498
|
/* ---- base content: words straight on the base, no card around them — an
|
|
@@ -773,7 +778,8 @@ button { font: inherit; cursor: pointer; }
|
|
|
773
778
|
<section class="page" inert>…</section>
|
|
774
779
|
</div></section> <div class="action-bar action-bar--pager">← →</div> */
|
|
775
780
|
::view-transition-old(root), ::view-transition-new(root) { animation: none; }
|
|
776
|
-
::view-transition-old(centre-card-out), ::view-transition-new(centre-card-in)
|
|
781
|
+
::view-transition-old(centre-card-out), ::view-transition-new(centre-card-in),
|
|
782
|
+
::view-transition-old(screen-out), ::view-transition-new(screen-in) { /* screen-*: slideScreen — one whole screen to the next (Lh 2026-09-25) */
|
|
777
783
|
animation-duration: var(--motion-slide);
|
|
778
784
|
animation-timing-function: ease-out;
|
|
779
785
|
mix-blend-mode: normal;
|
|
@@ -782,12 +788,17 @@ button { font: inherit; cursor: pointer; }
|
|
|
782
788
|
:root[data-slide="next"]::view-transition-new(centre-card-in) { animation-name: centre-card-in-right; }
|
|
783
789
|
:root[data-slide="back"]::view-transition-old(centre-card-out) { animation-name: centre-card-out-right; }
|
|
784
790
|
:root[data-slide="back"]::view-transition-new(centre-card-in) { animation-name: centre-card-in-left; }
|
|
791
|
+
:root[data-slide="next"]::view-transition-old(screen-out) { animation-name: centre-card-out-left; }
|
|
792
|
+
:root[data-slide="next"]::view-transition-new(screen-in) { animation-name: centre-card-in-right; }
|
|
793
|
+
:root[data-slide="back"]::view-transition-old(screen-out) { animation-name: centre-card-out-right; }
|
|
794
|
+
:root[data-slide="back"]::view-transition-new(screen-in) { animation-name: centre-card-in-left; }
|
|
785
795
|
@keyframes centre-card-out-left { to { translate: -100% 0; opacity: 0; } }
|
|
786
796
|
@keyframes centre-card-out-right { to { translate: 100% 0; opacity: 0; } }
|
|
787
797
|
@keyframes centre-card-in-right { from { translate: 100% 0; opacity: 0; } }
|
|
788
798
|
@keyframes centre-card-in-left { from { translate: -100% 0; opacity: 0; } }
|
|
789
799
|
@media (prefers-reduced-motion: reduce) {
|
|
790
|
-
::view-transition-old(centre-card-out), ::view-transition-new(centre-card-in)
|
|
800
|
+
::view-transition-old(centre-card-out), ::view-transition-new(centre-card-in),
|
|
801
|
+
::view-transition-old(screen-out), ::view-transition-new(screen-in) { animation: none; }
|
|
791
802
|
}
|
|
792
803
|
/* the page dots under the card sit centred under it — the indicator belongs
|
|
793
804
|
with the page that changes, never in the bar (Lh, 2026-09-12) */
|
|
@@ -1172,7 +1183,7 @@ button { font: inherit; cursor: pointer; }
|
|
|
1172
1183
|
/* a stacked sheet is a control until it is open — tapped, never read into: no text selection (a drag, a double tap, a long press),
|
|
1173
1184
|
no grey flash, no iOS callout; its head stays one when open. Only the OPEN sheet's body is text to select (Lh 2026-09-23) */
|
|
1174
1185
|
.sheet-stack--tap > .card, .sheet-stack > .card > .card__head { -webkit-user-select: none; user-select: none; -webkit-tap-highlight-color: transparent; -webkit-touch-callout: none; }
|
|
1175
|
-
.sheet-stack--tap > .card.is-open > :not(.card__head) { -webkit-user-select:
|
|
1186
|
+
.sheet-stack--tap > .card.is-open > :not(.card__head) { -webkit-user-select: var(--screen-select, none); user-select: var(--screen-select, none); } /* the open sheet reads as the rest of the screen: not selectable (Lh 2026-09-25; it was text) */
|
|
1176
1187
|
|
|
1177
1188
|
/* ---- css/sheet.css — sheet single: .sheet — one card as a bottom sheet, peek / half / full ---- */
|
|
1178
1189
|
/* ============================================================
|
|
@@ -1712,7 +1723,9 @@ button { font: inherit; cursor: pointer; }
|
|
|
1712
1723
|
position: absolute; inset-inline: 0; top: auto; bottom: var(--kb, 0rem);
|
|
1713
1724
|
max-width: none;
|
|
1714
1725
|
margin: 0;
|
|
1715
|
-
|
|
1726
|
+
/* room between its top edge and its first row (the walk's dots): since it has a ground, a 0 here put the dots against the words
|
|
1727
|
+
running under it (Lh 2026-09-25) — the bar's own top padding */
|
|
1728
|
+
padding-top: var(--action-bar-fill-pad-top, var(--sp-3));
|
|
1716
1729
|
/* its GROUND (Lh 2026-09-24, timebank card 141 — was "no ground of its own"): words that run under the layer mid-scroll
|
|
1717
1730
|
showed through the dots, the controls and the buttons. The layer lies on the screen's own surface now; a project sets
|
|
1718
1731
|
another (or `transparent`, the old see-through) with the knob */
|
|
@@ -2809,6 +2822,7 @@ button { font: inherit; cursor: pointer; }
|
|
|
2809
2822
|
edge. The sign is INSIDE the pill: a full-ink border and a blinking bar drawn where the digit will land (the native caret
|
|
2810
2823
|
is hidden — its height and place are the browser's; the pill's bar is one rule). A filled cell selects its digit instead. */
|
|
2811
2824
|
.pin__cell { caret-color: transparent; }
|
|
2825
|
+
.pin--secret .pin__cell { -webkit-text-security: disc; text-security: disc; } /* a PIN: a dot per digit, the number pad kept (Lh 2026-09-25) */
|
|
2812
2826
|
.pin__cell:focus { outline: none; border-color: var(--pin-cell-active-border, currentColor); }
|
|
2813
2827
|
.pin__cell:focus:placeholder-shown {
|
|
2814
2828
|
background-image: linear-gradient(currentColor, currentColor);
|
package/dist/forms/Pin.d.ts
CHANGED
|
@@ -22,6 +22,9 @@ export interface PinProps extends Omit<ComponentProps<'div'>, 'onChange' | 'defa
|
|
|
22
22
|
match?: string;
|
|
23
23
|
/** the words when a check fails: the code does not match, or the step's button was pressed with cells still empty */
|
|
24
24
|
formatError?: ReactNode;
|
|
25
|
+
/** a SECRET code (a PIN, not an OTP): each typed digit shows as a dot — `-webkit-text-security`, so the keyboard stays the
|
|
26
|
+
* number pad (a password field brings a full keyboard on some phones); no SMS autofill, no password manager (Lh 2026-09-25) */
|
|
27
|
+
secret?: boolean;
|
|
25
28
|
/** focus the first empty cell on mount */
|
|
26
29
|
autoFocus?: boolean;
|
|
27
30
|
}
|
|
@@ -31,4 +34,4 @@ export interface PinProps extends Omit<ComponentProps<'div'>, 'onChange' | 'defa
|
|
|
31
34
|
* arrows walk the cells, a pasted code fills them from the one it landed in; only digits stay. The first cell takes the SMS
|
|
32
35
|
* one-time-code autofill. The code is a VALUE, controlled like an input: `value` + `onChange`, or `defaultValue`.
|
|
33
36
|
*/
|
|
34
|
-
export declare function Pin({ length, value, defaultValue, label, onChange, autoFocus, error, match, formatError, className, onAnimationEnd, ...rest }: PinProps): import("react").JSX.Element;
|
|
37
|
+
export declare function Pin({ length, value, defaultValue, label, onChange, autoFocus, error, match, formatError, secret, className, onAnimationEnd, ...rest }: PinProps): import("react").JSX.Element;
|
package/dist/forms/Pin.js
CHANGED
|
@@ -9,7 +9,7 @@ const RESET_MS = 900; // a code that does not match: red + shake this long, then
|
|
|
9
9
|
* arrows walk the cells, a pasted code fills them from the one it landed in; only digits stay. The first cell takes the SMS
|
|
10
10
|
* one-time-code autofill. The code is a VALUE, controlled like an input: `value` + `onChange`, or `defaultValue`.
|
|
11
11
|
*/
|
|
12
|
-
export function Pin({ length = 6, value, defaultValue = '', label = 'Digit', onChange, autoFocus, error, match, formatError, className, onAnimationEnd, ...rest }) {
|
|
12
|
+
export function Pin({ length = 6, value, defaultValue = '', label = 'Digit', onChange, autoFocus, error, match, formatError, secret, className, onAnimationEnd, ...rest }) {
|
|
13
13
|
const ref = useRef(null);
|
|
14
14
|
const [inner, setInner] = useState(defaultValue);
|
|
15
15
|
const code = (value ?? inner).replace(/\D/g, '').slice(0, length);
|
|
@@ -146,10 +146,10 @@ export function Pin({ length = 6, value, defaultValue = '', label = 'Digit', onC
|
|
|
146
146
|
cell(i + 1)?.focus();
|
|
147
147
|
}
|
|
148
148
|
};
|
|
149
|
-
return (_jsx("div", { ref: ref, className: cx('pin', wrong && 'pin--error', shake && 'pin--shake', className), ...rest, onAnimationEnd: (e) => { if (e.animationName === 'pin-shake')
|
|
149
|
+
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')
|
|
150
150
|
setShake(false); onAnimationEnd?.(e); }, children: Array.from({ length }, (_, i) => (
|
|
151
151
|
// no maxLength: the browser would cut a pasted code before this ever saw it. A cell holds one digit — typing into a full one
|
|
152
152
|
// replaces it, so its digit is selected whenever the cell takes the focus
|
|
153
|
-
_jsx("input", { className: "pin__cell", inputMode: "numeric", pattern: "[0-9]*", placeholder: " ", value: code[i] ?? '', "aria-label": `${label} ${i + 1}`, "aria-invalid": wrong || undefined, autoComplete: i === 0 ? 'one-time-code' : 'off', onChange: input(i), onPaste: paste, onKeyDown: key(i), onFocus: (e) => { if (!restart.current)
|
|
153
|
+
_jsx("input", { className: "pin__cell", inputMode: "numeric", pattern: "[0-9]*", placeholder: " ", value: code[i] ?? '', "aria-label": `${label} ${i + 1}`, "aria-invalid": wrong || undefined, autoComplete: i === 0 && !secret ? 'one-time-code' : 'off', onChange: input(i), onPaste: paste, onKeyDown: key(i), onFocus: (e) => { if (!restart.current)
|
|
154
154
|
e.currentTarget.select(); } }, i))) }));
|
|
155
155
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -14,6 +14,7 @@ export * from './scaffold/FilterRow.js';
|
|
|
14
14
|
export * from './scaffold/Page.js';
|
|
15
15
|
export * from './scaffold/EmptyState.js';
|
|
16
16
|
export * from './scaffold/Centre.js';
|
|
17
|
+
export * from './scaffold/slideScreen.js';
|
|
17
18
|
export * from './cards/Card.js';
|
|
18
19
|
export * from './cards/CardHead.js';
|
|
19
20
|
export * from './cards/CardFoot.js';
|
package/dist/index.js
CHANGED
|
@@ -18,6 +18,7 @@ export * from './scaffold/FilterRow.js';
|
|
|
18
18
|
export * from './scaffold/Page.js';
|
|
19
19
|
export * from './scaffold/EmptyState.js';
|
|
20
20
|
export * from './scaffold/Centre.js';
|
|
21
|
+
export * from './scaffold/slideScreen.js';
|
|
21
22
|
export * from './cards/Card.js';
|
|
22
23
|
export * from './cards/CardHead.js';
|
|
23
24
|
export * from './cards/CardFoot.js';
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { SlideDirection } from './Centre.js';
|
|
2
|
+
/**
|
|
3
|
+
* slideScreen — go to the NEXT SCREEN (a route change) with the whole screen sliding, as a walk's pages do. Wrap the app's
|
|
4
|
+
* navigate in it: `slideScreen(() => navigate('/download'))` — `back` for a way back: `slideScreen(() => navigate(-1), 'back')`.
|
|
5
|
+
* The update may render at once or later (a route's chunk loading): the slide waits for the new Screen (at most 0.8 s).
|
|
6
|
+
* A Screen with `data-slide="off"` (or none at all) is not slid. `scope`: where the one Screen is (default the document — an
|
|
7
|
+
* app has one). Without view transitions (the browser floor) it just runs the update.
|
|
8
|
+
*/
|
|
9
|
+
export declare function slideScreen(update: () => unknown, direction?: SlideDirection, scope?: ParentNode): void;
|
|
10
|
+
/**
|
|
11
|
+
* installScreenSlide — call ONCE when the app starts: the phone's / browser's BACK (and forward) buttons slide the screen too,
|
|
12
|
+
* the way `back` (and `next`) does. It listens to `popstate` before the router renders; the direction is read from the history
|
|
13
|
+
* entry's index (`history.state.idx`, which React Router keeps; without it: back). Returns the uninstall.
|
|
14
|
+
*/
|
|
15
|
+
export declare function installScreenSlide(): () => void;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/* ONE SCREEN TO THE NEXT (Lh 2026-09-25): the whole screen slides — top, content, bottom — the way a walk's pages do (`next`: the
|
|
2
|
+
old one out to the left, the new one in from the right; `back`: the reverse; `--motion-slide`, card.css). It is a view
|
|
3
|
+
transition named for the move only: the leaving `.screen` is `screen-out`, the arriving one `screen-in`. */
|
|
4
|
+
const screenIn = (scope) => scope.querySelector('.screen:not(.screen .screen)');
|
|
5
|
+
/** wait until the screen in `scope` CHANGED — another element, or new content in the same one (React may keep the element when
|
|
6
|
+
both routes render a Screen in the same place) — a route rendered, sync or not; at most `ms` */
|
|
7
|
+
function newScreen(scope, old, ms = 800) {
|
|
8
|
+
return new Promise((done) => {
|
|
9
|
+
const now = screenIn(scope);
|
|
10
|
+
if (now !== old)
|
|
11
|
+
return done(now);
|
|
12
|
+
const root = scope instanceof Document ? scope.documentElement : scope;
|
|
13
|
+
const mo = new MutationObserver(() => { mo.disconnect(); clearTimeout(t); done(screenIn(scope)); });
|
|
14
|
+
mo.observe(root, { childList: true, subtree: true });
|
|
15
|
+
const t = setTimeout(() => { mo.disconnect(); done(screenIn(scope)); }, ms);
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
let running = false;
|
|
19
|
+
/**
|
|
20
|
+
* slideScreen — go to the NEXT SCREEN (a route change) with the whole screen sliding, as a walk's pages do. Wrap the app's
|
|
21
|
+
* navigate in it: `slideScreen(() => navigate('/download'))` — `back` for a way back: `slideScreen(() => navigate(-1), 'back')`.
|
|
22
|
+
* The update may render at once or later (a route's chunk loading): the slide waits for the new Screen (at most 0.8 s).
|
|
23
|
+
* A Screen with `data-slide="off"` (or none at all) is not slid. `scope`: where the one Screen is (default the document — an
|
|
24
|
+
* app has one). Without view transitions (the browser floor) it just runs the update.
|
|
25
|
+
*/
|
|
26
|
+
export function slideScreen(update, direction = 'next', scope = document) {
|
|
27
|
+
const root = document.documentElement;
|
|
28
|
+
const before = screenIn(scope);
|
|
29
|
+
if (typeof document.startViewTransition !== 'function' || running || !before || before.dataset.slide === 'off') {
|
|
30
|
+
update();
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
running = true;
|
|
34
|
+
before.style.viewTransitionName = 'screen-out';
|
|
35
|
+
root.dataset.slide = direction;
|
|
36
|
+
let after = null;
|
|
37
|
+
const t = document.startViewTransition(async () => {
|
|
38
|
+
await update();
|
|
39
|
+
after = await newScreen(scope, before);
|
|
40
|
+
before.style.viewTransitionName = '';
|
|
41
|
+
if (after && after.dataset.slide !== 'off')
|
|
42
|
+
after.style.viewTransitionName = 'screen-in'; // the same element too: out, then in
|
|
43
|
+
});
|
|
44
|
+
void t.finished.finally(() => { running = false; delete root.dataset.slide; if (after)
|
|
45
|
+
after.style.viewTransitionName = ''; });
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* installScreenSlide — call ONCE when the app starts: the phone's / browser's BACK (and forward) buttons slide the screen too,
|
|
49
|
+
* the way `back` (and `next`) does. It listens to `popstate` before the router renders; the direction is read from the history
|
|
50
|
+
* entry's index (`history.state.idx`, which React Router keeps; without it: back). Returns the uninstall.
|
|
51
|
+
*/
|
|
52
|
+
export function installScreenSlide() {
|
|
53
|
+
let idx = history.state?.idx;
|
|
54
|
+
const push = history.pushState.bind(history), replace = history.replaceState.bind(history);
|
|
55
|
+
history.pushState = (...a) => { push(...a); idx = history.state?.idx; };
|
|
56
|
+
history.replaceState = (...a) => { replace(...a); idx = history.state?.idx; };
|
|
57
|
+
const pop = () => {
|
|
58
|
+
const to = history.state?.idx;
|
|
59
|
+
const dir = to != null && idx != null && to > idx ? 'next' : 'back';
|
|
60
|
+
idx = to;
|
|
61
|
+
slideScreen(() => undefined, dir); // the router renders on its own, right after this: the slide waits for its new Screen
|
|
62
|
+
};
|
|
63
|
+
addEventListener('popstate', pop, { capture: true });
|
|
64
|
+
return () => { removeEventListener('popstate', pop, { capture: true }); history.pushState = push; history.replaceState = replace; };
|
|
65
|
+
}
|
package/dist/type/Text.d.ts
CHANGED
|
@@ -17,6 +17,9 @@ export interface TextProps extends React.HTMLAttributes<HTMLElement> {
|
|
|
17
17
|
/** the element; defaults per level (h2 for display/h1/h2, p for title/body/stat, span for the rest) */
|
|
18
18
|
/** READ, NOT SHOWN: the words stay in the page for a screen reader (a page's h1 that the design does not show), and take no room */
|
|
19
19
|
sr?: boolean;
|
|
20
|
+
/** the member may select and copy these words (a code, an address, a reference number) — everything else in a Screen is not
|
|
21
|
+
* selectable, like a native app (Lh 2026-09-25) */
|
|
22
|
+
selectable?: boolean;
|
|
20
23
|
as?: 'h1' | 'h2' | 'h3' | 'p' | 'span' | 'strong' | 'div';
|
|
21
24
|
}
|
|
22
25
|
/**
|
|
@@ -24,7 +27,7 @@ export interface TextProps extends React.HTMLAttributes<HTMLElement> {
|
|
|
24
27
|
* Headings inside a card head are h2 by default; a screen title in a TopBar is h1.
|
|
25
28
|
* Everything scales from the root font-size, so there are no pixel sizes to pick.
|
|
26
29
|
*/
|
|
27
|
-
export declare function Text({ level, xl, mono, muted, caps, grow, sr, as, className, ...rest }: TextProps): import("react").DetailedReactHTMLElement<{
|
|
30
|
+
export declare function Text({ level, xl, mono, muted, caps, grow, sr, selectable, as, className, ...rest }: TextProps): import("react").DetailedReactHTMLElement<{
|
|
28
31
|
/** the element it renders (React 19 passes `ref` as a prop) */
|
|
29
32
|
ref?: React.Ref<HTMLElement>;
|
|
30
33
|
defaultChecked?: boolean | undefined;
|
package/dist/type/Text.js
CHANGED
|
@@ -8,9 +8,9 @@ const DEFAULT_AS = {
|
|
|
8
8
|
* Headings inside a card head are h2 by default; a screen title in a TopBar is h1.
|
|
9
9
|
* Everything scales from the root font-size, so there are no pixel sizes to pick.
|
|
10
10
|
*/
|
|
11
|
-
export function Text({ level, xl, mono, muted, caps, grow, sr, as, className, ...rest }) {
|
|
11
|
+
export function Text({ level, xl, mono, muted, caps, grow, sr, selectable, as, className, ...rest }) {
|
|
12
12
|
return createElement(as ?? DEFAULT_AS[level] ?? 'span', {
|
|
13
|
-
className: cx(`t-${level}`, level === 'stat' && xl && 't-stat--xl', level === 'stat' && mono && 't-stat--mono', muted && 't-muted', caps && 't-caps', grow && 'grow', sr && 't-sr', className),
|
|
13
|
+
className: cx(`t-${level}`, level === 'stat' && xl && 't-stat--xl', level === 'stat' && mono && 't-stat--mono', muted && 't-muted', caps && 't-caps', grow && 'grow', sr && 't-sr', selectable && 't-select', className),
|
|
14
14
|
...rest,
|
|
15
15
|
});
|
|
16
16
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@usableapp/cardds",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.10",
|
|
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.",
|