@usableapp/cardds 0.7.45 → 0.7.47

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,23 @@
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.47 — 2026-10-03
6
+
7
+ ### Changed
8
+ - **One sheet follows another: the first goes all the way down, the base is seen, then the next comes up** (Lh 2026-10-03). It is the `Sheet`'s own habit now: a sheet asked up while another sheet of the same box (`Step`, `SheetStage`) is leaving waits at `away` until that one has landed, the base shows for `--motion-sheet-rest` (0.2s), then it rises — 0.35s down · 0.2s base · 0.35s up, never two sheets in the box. Before, two sheets set in one render crossed (`CallSheet ways` going from one way to another, `Step · Calls`), and a sheet raised from the first one's `onSettled('away')` started in the very frame the first landed — one continuous move. Nothing to change in a screen: both wirings now play the same, and an app adds no timer of its own (remove one if you had it). `onSettled` of the sheet coming up fires later by the wait. With reduced motion there is no wait. New root token `--motion-sheet-rest`.
9
+
10
+ ### Fixed
11
+ - **`TrackStep num`: the number sits in the middle of its round at every text size** — the round is cut from its own number (`1.877em`, 32px at the default text as before) and the digit is lifted onto the middle, as the SheetSteps' number. Knobs `--track-dot-size` · `--track-dot-lift`. At a large text a NUMBERED track's pill grows with its rounds and is taller than the top bar (160% text: 88px beside a 78px bar); an icon track keeps the bar's height.
12
+
13
+ ## 0.7.46 — 2026-10-03
14
+
15
+ ### Changed
16
+ - **A larger text pulls the controls with it, half as fast** (Lh 2026-10-03): `setTextScale(percent)` now writes `--tap-base` as well as `--fs-base` — 115% text → 107.5% controls · 130% → 115% · 145% → 122.5% · 160% → 130%; at 100% and under, the controls keep the theme's size. Before, only the words grew and outgrew what holds them (a number on its round's bottom edge, a title tight in the top bar). Nothing to change in an app that calls `setTextScale`: buttons, bars, rounds and icons grow by themselves — look at a dense screen at 160%, it has less room for content than before. An app that sets `--fs-base` on `<html>` by hand gets none of this: call `setTextScale`.
17
+ - New export **`controlsFor(percent)`** — the controls' share for a text size, for an app that wants to show or store the pair.
18
+
19
+ ### Fixed
20
+ - **`SheetSteps numbered`: the number sits in the middle of its disc at every text size** — the disc is cut from its own number (`1.408em`, 24px at the default text as before) instead of a fixed 24px, and the digit is lifted onto the middle. Knobs `--sheet-step-num-size` (now listed) · `--sheet-step-num-lift`. If your theme set `--sheet-step-num-size` in rem, it still wins — but then it no longer follows the text.
21
+
5
22
  ## 0.7.45 — 2026-10-03
6
23
 
7
24
  ### Changed
package/README.md CHANGED
@@ -653,7 +653,7 @@ Rules:
653
653
  any role can be set outright.
654
654
  - The member's text-size setting is the same `--fs-base`, written on `<html>`
655
655
  at run time by `setTextScale(percent)` (a share of the theme's base; 100 = the
656
- theme's own): the words grow, the things keep their size. (`--text-scale`, which multiplied the root and zoomed the UI too, is gone.)
656
+ theme's own): the words grow, and the things grow HALF as much (`--tap-base`; `controlsFor(percent)` — 130% text → 115% controls, 160% → 130%), so a larger text still fits what holds it. (`--text-scale`, which multiplied the root and zoomed the UI too, is gone.)
657
657
 
658
658
  Try it live: the gallery's nav (`npm run dev`) has a slider per size base, and
659
659
  palette `own` shows a colour well per colour base.
@@ -684,8 +684,8 @@ default) and steps narrow phones (≤360px) to `93.75%`, which keeps the
684
684
  column, its 48px taps and its type in proportion on 320–360 screens. Only
685
685
  `--border-w`, `--rule-w` and the focus ring stay in px, so edges stay crisp.
686
686
  An app with its own text-size setting calls `setTextScale(percent)`, which writes
687
- `--fs-base` on `<html>` from the theme's base (100% = the theme's; 160% = 2.2rem by default): only the type and its holders (the count
688
- badge, the inline icon) grow; taps, spacing and radii keep their size.
687
+ `--fs-base` on `<html>` from the theme's base (100% = the theme's; 160% = 2.2rem by default): the type and its holders (the count
688
+ badge, the inline icon) grow, the controls grow half as much (`--tap-base`), spacing and radii keep their size.
689
689
  Components never write a raw length: spacing from `--sp-*`, control sizes from
690
690
  `--tap`, `--tap-lg`, `--btn-h`, `--bar-h`, `--header-h`, `--fab`, `--badge`, container
691
691
  padding from `--screen-pad` / `--card-pad`, radii from
package/css/numbers.css CHANGED
@@ -89,7 +89,13 @@
89
89
  background: var(--ink-rail);
90
90
  color: var(--_ink);
91
91
  }
92
- .track__step > .track__dot { display: grid; place-items: center; padding: 0; font: var(--track-dot-font, var(--type-caption)); font-variant-numeric: tabular-nums; } /* a number in the round (TrackStep num) */
92
+ .track__step > .track__dot { /* a number in the round (TrackStep num) */
93
+ display: grid; place-items: center; box-sizing: border-box;
94
+ font: var(--track-dot-font, var(--type-caption)); line-height: 1; font-variant-numeric: tabular-nums;
95
+ width: var(--track-dot-size, 1.877em); height: var(--track-dot-size, 1.877em); /* em, not rem (Lh 2026-10-03, as the SheetSteps' number and the StackSheet's count): the round is cut from ITS OWN
96
+ number's size — 1.877em of a 17px caption = the 32px of an icon's round; at a larger text it grows with its number, and the pill with it */
97
+ padding: 0 0 var(--track-dot-lift, 0.1em); /* the room UNDER the number lifts it onto the round's middle: a digit has no descender */
98
+ }
93
99
  .track__step.is-done > .icon,
94
100
  .track__step.is-done > .track__dot { background: var(--accent); color: var(--on-accent); }
95
101
 
package/css/sheet.css CHANGED
@@ -413,8 +413,10 @@
413
413
  }
414
414
  .sheet-step__title { font: var(--sheet-step-title-font, var(--type-label)); margin: 0; flex: 0 1 auto; min-width: 0; outline: none; /* the label size, not a body title: a pile of rows must stay small (Lh 2026-09-24) */ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
415
415
  .sheet-step__num { /* `numbered`: the step's number in a disc BEFORE its title (Lh 2026-09-25) — a white disc on the bar, the number in the ink */
416
- flex: none; display: grid; place-items: center; inline-size: var(--sheet-step-num-size, calc(var(--tap-base) / 2)); block-size: var(--sheet-step-num-size, calc(var(--tap-base) / 2));
417
- border-radius: 50%; font: var(--sheet-step-num-font, var(--type-caption)); font-variant-numeric: tabular-nums;
416
+ flex: none; display: grid; place-items: center; box-sizing: border-box; inline-size: var(--sheet-step-num-size, 1.408em); block-size: var(--sheet-step-num-size, 1.408em); /* em, not rem (Lh 2026-10-03, as the StackSheet's count): the disc is cut from ITS OWN number's size —
417
+ at a large text size the number grew and a 24px disc did not, so the digit sat on its bottom edge; 1.408em of a 17px caption = the 24px it always was */
418
+ padding-bottom: var(--sheet-step-num-lift, 0.1em); /* the room UNDER the number lifts it onto the disc's middle: a digit has no descender, so a centred line box sets it low */
419
+ border-radius: 50%; font: var(--sheet-step-num-font, var(--type-caption)); line-height: 1; font-variant-numeric: tabular-nums;
418
420
  background: var(--sheet-step-num-bg, var(--card-bg)); color: var(--sheet-step-num-ink, inherit);
419
421
  }
420
422
  .sheet-step__summary { color: inherit; opacity: 0.8; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
package/css/tokens.css CHANGED
@@ -95,6 +95,7 @@
95
95
 
96
96
  /* ---- motion ---- */
97
97
  --motion-sheet: 0.35s;
98
+ --motion-sheet-rest: 0.2s; /* one sheet follows another: how long the base is seen between the one that left and the one that comes (Sheet.tsx reads it) */
98
99
  --motion-quick: 0.2s; /* a small state flip that isn't a press: a chevron turning, a popover/banner opening */
99
100
  --motion-slide: 0.22s; /* a centred card sliding out and the next in (card.css) — quick, a page turn (Lh, 2026-09-12) */
100
101
  --motion-flip: 0.45s; /* a postcard turning over (media.css) — slow enough to read as a card in the hand */
package/dist/cardds.css CHANGED
@@ -136,6 +136,7 @@
136
136
 
137
137
  /* ---- motion ---- */
138
138
  --motion-sheet: 0.35s;
139
+ --motion-sheet-rest: 0.2s; /* one sheet follows another: how long the base is seen between the one that left and the one that comes (Sheet.tsx reads it) */
139
140
  --motion-quick: 0.2s; /* a small state flip that isn't a press: a chevron turning, a popover/banner opening */
140
141
  --motion-slide: 0.22s; /* a centred card sliding out and the next in (card.css) — quick, a page turn (Lh, 2026-09-12) */
141
142
  --motion-flip: 0.45s; /* a postcard turning over (media.css) — slow enough to read as a card in the hand */
@@ -1737,8 +1738,10 @@ button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
1737
1738
  }
1738
1739
  .sheet-step__title { font: var(--sheet-step-title-font, var(--type-label)); margin: 0; flex: 0 1 auto; min-width: 0; outline: none; /* the label size, not a body title: a pile of rows must stay small (Lh 2026-09-24) */ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1739
1740
  .sheet-step__num { /* `numbered`: the step's number in a disc BEFORE its title (Lh 2026-09-25) — a white disc on the bar, the number in the ink */
1740
- flex: none; display: grid; place-items: center; inline-size: var(--sheet-step-num-size, calc(var(--tap-base) / 2)); block-size: var(--sheet-step-num-size, calc(var(--tap-base) / 2));
1741
- border-radius: 50%; font: var(--sheet-step-num-font, var(--type-caption)); font-variant-numeric: tabular-nums;
1741
+ flex: none; display: grid; place-items: center; box-sizing: border-box; inline-size: var(--sheet-step-num-size, 1.408em); block-size: var(--sheet-step-num-size, 1.408em); /* em, not rem (Lh 2026-10-03, as the StackSheet's count): the disc is cut from ITS OWN number's size —
1742
+ at a large text size the number grew and a 24px disc did not, so the digit sat on its bottom edge; 1.408em of a 17px caption = the 24px it always was */
1743
+ padding-bottom: var(--sheet-step-num-lift, 0.1em); /* the room UNDER the number lifts it onto the disc's middle: a digit has no descender, so a centred line box sets it low */
1744
+ border-radius: 50%; font: var(--sheet-step-num-font, var(--type-caption)); line-height: 1; font-variant-numeric: tabular-nums;
1742
1745
  background: var(--sheet-step-num-bg, var(--card-bg)); color: var(--sheet-step-num-ink, inherit);
1743
1746
  }
1744
1747
  .sheet-step__summary { color: inherit; opacity: 0.8; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@@ -2575,7 +2578,13 @@ button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
2575
2578
  background: var(--ink-rail);
2576
2579
  color: var(--_ink);
2577
2580
  }
2578
- .track__step > .track__dot { display: grid; place-items: center; padding: 0; font: var(--track-dot-font, var(--type-caption)); font-variant-numeric: tabular-nums; } /* a number in the round (TrackStep num) */
2581
+ .track__step > .track__dot { /* a number in the round (TrackStep num) */
2582
+ display: grid; place-items: center; box-sizing: border-box;
2583
+ font: var(--track-dot-font, var(--type-caption)); line-height: 1; font-variant-numeric: tabular-nums;
2584
+ width: var(--track-dot-size, 1.877em); height: var(--track-dot-size, 1.877em); /* em, not rem (Lh 2026-10-03, as the SheetSteps' number and the StackSheet's count): the round is cut from ITS OWN
2585
+ number's size — 1.877em of a 17px caption = the 32px of an icon's round; at a larger text it grows with its number, and the pill with it */
2586
+ padding: 0 0 var(--track-dot-lift, 0.1em); /* the room UNDER the number lifts it onto the round's middle: a digit has no descender */
2587
+ }
2579
2588
  .track__step.is-done > .icon,
2580
2589
  .track__step.is-done > .track__dot { background: var(--accent); color: var(--on-accent); }
2581
2590
 
package/dist/index.d.ts CHANGED
@@ -4,7 +4,7 @@ export { cx } from './cx.js';
4
4
  export * from './type/Icon.js';
5
5
  export { ICON_NAMES } from './type/icons.js';
6
6
  export * from './type/Text.js';
7
- export { setTextScale } from './type/textScale.js';
7
+ export { setTextScale, controlsFor } from './type/textScale.js';
8
8
  export { setWords, word, type WordKey } from './words.js';
9
9
  export * from './scaffold/Screen.js';
10
10
  export * from './scaffold/AppBar.js';
package/dist/index.js CHANGED
@@ -8,7 +8,7 @@ export { cx } from './cx.js';
8
8
  export * from './type/Icon.js';
9
9
  export { ICON_NAMES } from './type/icons.js';
10
10
  export * from './type/Text.js';
11
- export { setTextScale } from './type/textScale.js';
11
+ export { setTextScale, controlsFor } from './type/textScale.js';
12
12
  export { setWords, word } from './words.js';
13
13
  export * from './scaffold/Screen.js';
14
14
  export * from './scaffold/AppBar.js';
@@ -37,6 +37,11 @@ export interface SheetProps extends Omit<ComponentProps<'article'>, 'onChange'>
37
37
  * It ARRIVES and LEAVES by sliding, always: a sheet that mounts already up starts `away` for one frame and slides to its state, so
38
38
  * `{open && <Sheet state="full">}` slides in. Leaving is the app's beat: set `away`, and unmount on `onSettled('away')` — unmount it
39
39
  * while it is still up and it can only vanish (a dev warning says so).
40
+ *
41
+ * ONE FOLLOWS ANOTHER: a sheet that comes up while another sheet of the same box is on its way out waits — the leaving one goes
42
+ * ALL the way down, the base is seen for a beat (`--motion-sheet-rest`), and only then it rises: "one left, another came", never two
43
+ * sheets crossing and never one continuous move. It is the Sheet's own habit, whatever the app's wiring (both states set in one
44
+ * render, or the next raised from the first's `onSettled`) — the app adds no timer.
40
45
  */
41
46
  export declare function Sheet({ state, defaultState, onStateChange, onSettled, states, handle, className, children, onFocus, ...rest }: SheetProps): import("react").JSX.Element;
42
47
  export {};
@@ -1,11 +1,13 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
3
- import { focusWhenActive, useControlled, useLatest, warnOnce, watchSize } from '../state.js';
3
+ import { focusWhenActive, reducedMotion, useControlled, useLatest, warnOnce, watchSize } from '../state.js';
4
4
  import { cx } from '../cx.js';
5
5
  import { word } from '../words.js';
6
6
  const ORDER = ['peek', 'half', '3q', 'full']; // low → high; `away` is below them all and outside them
7
7
  const RANK = { away: 0, peek: 1, half: 2, '3q': 3, full: 4 };
8
8
  const TAP_REM = 0.5; // = --sp-2: a move shorter than this is a tap, not a drag
9
+ /* ONE SHEET FOLLOWS ANOTHER (Lh 2026-10-03): when a box's sheet landed away, per box — a sheet coming up reads it to give the base its beat */
10
+ const landed = new WeakMap();
9
11
  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
10
12
  /**
11
13
  * Sheet — the SINGLE sheet: one card as a bottom sheet holding what the member ACTS on. Always the full height of its
@@ -22,6 +24,11 @@ const ms = (t) => Math.max(0, ...t.split(',').map((v) => parseFloat(v) * (v.trim
22
24
  * It ARRIVES and LEAVES by sliding, always: a sheet that mounts already up starts `away` for one frame and slides to its state, so
23
25
  * `{open && <Sheet state="full">}` slides in. Leaving is the app's beat: set `away`, and unmount on `onSettled('away')` — unmount it
24
26
  * while it is still up and it can only vanish (a dev warning says so).
27
+ *
28
+ * ONE FOLLOWS ANOTHER: a sheet that comes up while another sheet of the same box is on its way out waits — the leaving one goes
29
+ * ALL the way down, the base is seen for a beat (`--motion-sheet-rest`), and only then it rises: "one left, another came", never two
30
+ * sheets crossing and never one continuous move. It is the Sheet's own habit, whatever the app's wiring (both states set in one
31
+ * render, or the next raised from the first's `onSettled`) — the app adds no timer.
25
32
  */
26
33
  export function Sheet({ state, defaultState = 'half', onStateChange, onSettled, states, handle = true, className, children, onFocus, ...rest }) {
27
34
  const [current, request] = useControlled(state, defaultState, onStateChange);
@@ -31,7 +38,9 @@ export function Sheet({ state, defaultState = 'half', onStateChange, onSettled,
31
38
  const was = useRef('away'); // mounted up = it came onto the screen: it rises from away like any arrival
32
39
  const skipClick = useRef(false);
33
40
  const [entered, setEntered] = useState(false); // false for the first frame: the class says `away`, so a sheet mounted up has somewhere to slide from
34
- const shown = entered ? current : 'away';
41
+ const [held, setHeld] = useState(false); // true while it waits for the sheet before it to land and the base to be seen
42
+ const shown = entered && !held ? current : 'away';
43
+ const live = held ? 'away' : current; // where it is as far as its own habits go: a held sheet has not come up yet
35
44
  const latest = useLatest({ current, onSettled });
36
45
  const drag = useRef(null);
37
46
  const allowed = useCallback(() => {
@@ -42,6 +51,47 @@ export function Sheet({ state, defaultState = 'half', onStateChange, onSettled,
42
51
  void ref.current?.offsetHeight; // the browser styles the `away` frame first, or there is no transition to run
43
52
  setEntered(true);
44
53
  }, []);
54
+ /* ONE FOLLOWS ANOTHER: it is asked up from `away` (or mounted up) while a sibling sheet is still leaving — or landed less than the
55
+ rest ago: it holds at `away` until that one's slide has ended and the base has been seen for --motion-sheet-rest. Read from the
56
+ DOM in the commit itself (a leaving sibling already says `away` but still stands in the box), so the order of the two sheets
57
+ in the tree, and whether the app set both in one render or one after the other, make no difference. */
58
+ const before = useRef('away');
59
+ useLayoutEffect(() => {
60
+ const el = ref.current, box = el?.parentElement, from = before.current;
61
+ before.current = current;
62
+ if (!el || !box || from !== 'away' || current === 'away' || reducedMotion()) {
63
+ setHeld(false);
64
+ 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) {
71
+ setHeld(false);
72
+ return;
73
+ }
74
+ setHeld(true);
75
+ let timer = 0, left = leaving.length;
76
+ const go = (after) => { timer = window.setTimeout(() => setHeld(false), after); };
77
+ if (!left) {
78
+ go(rest - since);
79
+ return () => clearTimeout(timer);
80
+ }
81
+ const stops = leaving.map((s) => {
82
+ let done = false;
83
+ const end = () => { if (done)
84
+ return; done = true; clearTimeout(t); s.removeEventListener('transitionend', onEnd); if (--left === 0)
85
+ go(rest); };
86
+ const onEnd = (e) => { if (e.target === s && e.propertyName === 'translate')
87
+ end(); };
88
+ const cs = getComputedStyle(s);
89
+ s.addEventListener('transitionend', onEnd);
90
+ const t = setTimeout(end, ms(cs.transitionDuration) + ms(cs.transitionDelay) + 100);
91
+ return () => { done = true; clearTimeout(t); s.removeEventListener('transitionend', onEnd); };
92
+ });
93
+ return () => { stops.forEach((stop) => stop()); clearTimeout(timer); };
94
+ }, [current]);
45
95
  /* 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 */
46
96
  const last = useRef(shown);
47
97
  useEffect(() => {
@@ -57,6 +107,8 @@ export function Sheet({ state, defaultState = 'half', onStateChange, onSettled,
57
107
  done = true;
58
108
  clearTimeout(t);
59
109
  el.removeEventListener('transitionend', onEnd);
110
+ if (shown === 'away' && el.parentElement)
111
+ landed.set(el.parentElement, performance.now()); // the sheet after it counts its rest from here
60
112
  latest.current.onSettled?.(latest.current.current);
61
113
  };
62
114
  const onEnd = (e) => { if (e.target === el && e.propertyName === 'translate')
@@ -85,17 +137,17 @@ export function Sheet({ state, defaultState = 'half', onStateChange, onSettled,
85
137
  field), else the body's first field; with no field, a sheet that came onto the screen gives it to its handle. A field that already
86
138
  has the cursor keeps it. */
87
139
  useEffect(() => {
88
- if (current !== 'peek' && current !== 'away')
89
- home.current = current;
90
- const rose = current !== 'peek' && current !== 'away' && RANK[current] > RANK[was.current];
140
+ if (live !== 'peek' && live !== 'away')
141
+ home.current = live;
142
+ const rose = live !== 'peek' && live !== 'away' && RANK[live] > RANK[was.current];
91
143
  const from = was.current;
92
- was.current = current;
144
+ was.current = live;
93
145
  if (!rose)
94
146
  return;
95
147
  const cancel = focusWhenActive(ref.current, { scope: ':scope > .sheet__body', fallback: () => (from === 'away' ? handleRef.current : null) });
96
148
  return () => { if (!cancel())
97
149
  was.current = from; }; // cut short (StrictMode's trial run, or a new state mid-slide): the rise still counts from where it began
98
- }, [current]);
150
+ }, [live]);
99
151
  /* geometry, read once per gesture — never stored: how far it has slid, and where each state rests, in px from its top at full */
100
152
  const slid = (el) => {
101
153
  const box = (el.offsetParent ?? el.parentElement);
@@ -246,5 +298,5 @@ export function Sheet({ state, defaultState = 'half', onStateChange, onSettled,
246
298
  skipClick.current = true;
247
299
  requestAnimationFrame(() => { skipClick.current = false; });
248
300
  };
249
- return (_jsxs("article", { ref: ref, className: cx('card sheet', `sheet--${shown}`, className), inert: current === 'away' || undefined, onFocus: focus, ...rest, children: [handle !== false && (_jsx("button", { ref: handleRef, className: "sheet__handle", type: "button", "aria-label": typeof handle === 'string' ? handle : word('resize'), onClick: tap, onPointerDown: down, onPointerMove: move, onPointerUp: up, onPointerCancel: up })), children] }));
301
+ return (_jsxs("article", { ref: ref, className: cx('card sheet', `sheet--${shown}`, className), inert: current === 'away' || held || undefined, onFocus: focus, ...rest, children: [handle !== false && (_jsx("button", { ref: handleRef, className: "sheet__handle", type: "button", "aria-label": typeof handle === 'string' ? handle : word('resize'), onClick: tap, onPointerDown: down, onPointerMove: move, onPointerUp: up, onPointerCancel: up })), children] }));
250
302
  }
@@ -1,6 +1,14 @@
1
1
  /**
2
- * The member's text size: `--fs-base` on <html> as a share of the theme's own base (100 = as the theme set it).
3
- * Words grow, things keep their size. The base is read from the CSS (cardds's default or the project's theme.css) — never
4
- * copied into app code. Keeping the choice (localStorage, an account) is the app's.
2
+ * How much the CONTROLS grow for a text size (both a %, 100 = as the theme set it): half as fast as the words. A text-holder of
3
+ * a fixed size (a round with a number, a button, the top bar) is drawn for the theme's text — at a larger text the words outgrew
4
+ * it; growing the things at the same rate would zoom the whole UI and leave no room for content (Lh 2026-10-03, pairs set by
5
+ * measuring the gallery: 115 → 107.5 · 130 → 115 · 145 → 122.5 · 160 → 130). A smaller text never shrinks a control.
6
+ */
7
+ export declare const controlsFor: (percent: number) => number;
8
+ /**
9
+ * The member's text size: `--fs-base` on <html> as a share of the theme's own base (100 = as the theme set it), and with it
10
+ * `--tap-base` — the things grow half as much as the words (`controlsFor`), so a larger text still fits what holds it.
11
+ * The bases are read from the CSS (cardds's default or the project's theme.css) — never copied into app code. Keeping the choice
12
+ * (localStorage, an account) is the app's.
5
13
  */
6
14
  export declare function setTextScale(percent: number, root?: HTMLElement): void;
@@ -1,12 +1,25 @@
1
1
  /**
2
- * The member's text size: `--fs-base` on <html> as a share of the theme's own base (100 = as the theme set it).
3
- * Words grow, things keep their size. The base is read from the CSS (cardds's default or the project's theme.css) — never
4
- * copied into app code. Keeping the choice (localStorage, an account) is the app's.
2
+ * How much the CONTROLS grow for a text size (both a %, 100 = as the theme set it): half as fast as the words. A text-holder of
3
+ * a fixed size (a round with a number, a button, the top bar) is drawn for the theme's text — at a larger text the words outgrew
4
+ * it; growing the things at the same rate would zoom the whole UI and leave no room for content (Lh 2026-10-03, pairs set by
5
+ * measuring the gallery: 115 → 107.5 · 130 → 115 · 145 → 122.5 · 160 → 130). A smaller text never shrinks a control.
6
+ */
7
+ export const controlsFor = (percent) => 100 + Math.max(0, percent - 100) / 2;
8
+ /**
9
+ * The member's text size: `--fs-base` on <html> as a share of the theme's own base (100 = as the theme set it), and with it
10
+ * `--tap-base` — the things grow half as much as the words (`controlsFor`), so a larger text still fits what holds it.
11
+ * The bases are read from the CSS (cardds's default or the project's theme.css) — never copied into app code. Keeping the choice
12
+ * (localStorage, an account) is the app's.
5
13
  */
6
14
  export function setTextScale(percent, root = document.documentElement) {
7
15
  root.style.removeProperty('--fs-base');
16
+ root.style.removeProperty('--tap-base');
8
17
  if (percent === 100)
9
18
  return;
10
- const base = parseFloat(getComputedStyle(root).getPropertyValue('--fs-base')) || 1.375; // rem
11
- root.style.setProperty('--fs-base', `${(base * percent) / 100}rem`);
19
+ const cs = getComputedStyle(root);
20
+ const fs = parseFloat(cs.getPropertyValue('--fs-base')) || 1.375, tap = parseFloat(cs.getPropertyValue('--tap-base')) || 3; // rem
21
+ root.style.setProperty('--fs-base', `${(fs * percent) / 100}rem`);
22
+ const controls = controlsFor(percent);
23
+ if (controls !== 100)
24
+ root.style.setProperty('--tap-base', `${(tap * controls) / 100}rem`);
12
25
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usableapp/cardds",
3
- "version": "0.7.45",
3
+ "version": "0.7.47",
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.",