@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 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) { animation: none; }
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: text; user-select: text; -webkit-touch-callout: default; }
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
- padding-top: 0;
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) { animation: none; }
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: text; user-select: text; -webkit-touch-callout: default; }
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
- padding-top: 0;
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);
@@ -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
+ }
@@ -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.8",
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.",