@usableapp/cardds 0.7.46 → 0.7.48

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,22 @@
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.48 — 2026-10-03
6
+
7
+ ### Changed
8
+ - **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.
9
+
10
+ ### Added
11
+ - **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`.
12
+
13
+ ## 0.7.47 — 2026-10-03
14
+
15
+ ### Changed
16
+ - **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`.
17
+
18
+ ### Fixed
19
+ - **`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.
20
+
5
21
  ## 0.7.46 — 2026-10-03
6
22
 
7
23
  ### Changed
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/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/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 */
@@ -1910,6 +1911,17 @@ button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
1910
1911
  margin-top: var(--track-top-lead, var(--sp-4));
1911
1912
  margin-bottom: calc(var(--track-top-gap, var(--sp-4)) - var(--screen-gap));
1912
1913
  }
1914
+ /* …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
1915
+ shows around the pill — and the stage keeps the track's line free itself: a sheet at full stops --track-top-gap under the
1916
+ track, as in a step. --top-h is the track's own height, measured by Screen (a numbered track grows with the text). */
1917
+ .screen--fill > .track:first-child:has(+ .sheet-stage) {
1918
+ position: absolute; z-index: 1;
1919
+ inset: calc(var(--safe-top) + var(--track-top-lead, var(--sp-4))) var(--screen-pad) auto;
1920
+ margin: 0;
1921
+ }
1922
+ .screen--fill > .track:first-child + .sheet-stage {
1923
+ --_top: calc(var(--track-top-lead, var(--sp-4)) + var(--top-h, var(--header-h)) + var(--track-top-gap, var(--sp-4)));
1924
+ }
1913
1925
  /* 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
1914
1926
  the lead + the picture's own height (--page-media-h), square — so the words start where they always did */
1915
1927
  .step > .pages > .page:has(> :is(img, .ph):first-child) { margin-top: 0; }
@@ -2577,7 +2589,13 @@ button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
2577
2589
  background: var(--ink-rail);
2578
2590
  color: var(--_ink);
2579
2591
  }
2580
- .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) */
2592
+ .track__step > .track__dot { /* a number in the round (TrackStep num) */
2593
+ display: grid; place-items: center; box-sizing: border-box;
2594
+ font: var(--track-dot-font, var(--type-caption)); line-height: 1; font-variant-numeric: tabular-nums;
2595
+ 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
2596
+ 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 */
2597
+ 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 */
2598
+ }
2581
2599
  .track__step.is-done > .icon,
2582
2600
  .track__step.is-done > .track__dot { background: var(--accent); color: var(--on-accent); }
2583
2601
 
@@ -28,6 +28,15 @@ export function Screen({ fill, palette, as = 'div', top, bottom, ready = true, c
28
28
  }
29
29
  return watchSize([bar], () => el.style.setProperty('--bottom-h', `${bar.offsetHeight}px`));
30
30
  }, [bottom, ready]);
31
+ /* a TRACK as the top, over a stage: its height published as --top-h — the stage keeps that line free for it (step.css) */
32
+ useEffect(() => {
33
+ const el = own.current, track = el?.querySelector(':scope > .track:first-child');
34
+ if (!el || !track) {
35
+ el?.style.removeProperty('--top-h');
36
+ return;
37
+ }
38
+ return watchSize([track], () => el.style.setProperty('--top-h', `${track.offsetHeight}px`));
39
+ }, [top, ready]);
31
40
  const setRef = (node) => {
32
41
  own.current = node;
33
42
  if (typeof ref === 'function')
@@ -14,7 +14,8 @@ export interface SheetProps extends Omit<ComponentProps<'article'>, 'onChange'>
14
14
  /** the sheet ASKS to move — the handle was tapped or dragged, a field in a peeked sheet took focus, the keyboard moved the cut.
15
15
  * Controlled: set your state to it (or don't — the sheet stays). This is where an app ties a sheet to its router / back button. */
16
16
  onStateChange?: (state: SheetState, reason: SheetChangeReason) => void;
17
- /** the sheet has FINISHED sliding to `state` (its transition ended; at once where motion is off). To leave the screen: set `away`, and take the
17
+ /** the sheet has FINISHED its move to `state` (its transition ended; at once where motion is off). Going `away` it is finished a beat
18
+ * AFTER it landed (`--motion-sheet-rest`) — its own timeout, every time. To leave the screen: set `away`, and take the
18
19
  * sheet out of the tree here — `onSettled={(s) => s === 'away' && setMounted(false)}` — so it slides out instead of vanishing */
19
20
  onSettled?: (state: SheetState) => void;
20
21
  /** the states a drag snaps to and a reveal may climb through — "peek half full", or an array (default all four) */
@@ -37,6 +38,11 @@ export interface SheetProps extends Omit<ComponentProps<'article'>, 'onChange'>
37
38
  * 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
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).
41
+ *
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.
40
46
  */
41
47
  export declare function Sheet({ state, defaultState, onStateChange, onSettled, states, handle, className, children, onFocus, ...rest }: SheetProps): import("react").JSX.Element;
42
48
  export {};
@@ -1,11 +1,19 @@
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
+ /* 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()); };
9
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
10
18
  /**
11
19
  * Sheet — the SINGLE sheet: one card as a bottom sheet holding what the member ACTS on. Always the full height of its
@@ -22,6 +30,11 @@ const ms = (t) => Math.max(0, ...t.split(',').map((v) => parseFloat(v) * (v.trim
22
30
  * 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
31
  * `{open && <Sheet state="full">}` slides in. Leaving is the app's beat: set `away`, and unmount on `onSettled('away')` — unmount it
24
32
  * while it is still up and it can only vanish (a dev warning says so).
33
+ *
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.
25
38
  */
26
39
  export function Sheet({ state, defaultState = 'half', onStateChange, onSettled, states, handle = true, className, children, onFocus, ...rest }) {
27
40
  const [current, request] = useControlled(state, defaultState, onStateChange);
@@ -31,8 +44,17 @@ export function Sheet({ state, defaultState = 'half', onStateChange, onSettled,
31
44
  const was = useRef('away'); // mounted up = it came onto the screen: it rises from away like any arrival
32
45
  const skipClick = useRef(false);
33
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
34
- const shown = entered ? current : 'away';
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
+ }
54
+ const shown = entered && !held ? current : 'away';
55
+ const live = held ? 'away' : current; // where it is as far as its own habits go: a held sheet has not come up yet
35
56
  const latest = useLatest({ current, onSettled });
57
+ const last = useRef('away'); // where it was last SHOWN (the settle effect keeps it)
36
58
  const drag = useRef(null);
37
59
  const allowed = useCallback(() => {
38
60
  const list = (typeof states === 'string' ? states.split(/\s+/) : [...(states ?? [])]).filter((s) => ORDER.includes(s));
@@ -42,29 +64,54 @@ export function Sheet({ state, defaultState = 'half', onStateChange, onSettled,
42
64
  void ref.current?.offsetHeight; // the browser styles the `away` frame first, or there is no transition to run
43
65
  setEntered(true);
44
66
  }, []);
67
+ /* it starts on its way out: said in the commit itself, before any sheet of the box decides to rise */
68
+ useLayoutEffect(() => {
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)
77
+ return;
78
+ if (!box || !out.get(box)?.size) {
79
+ setHeld(false);
80
+ return;
81
+ }
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]);
45
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 */
46
- const last = useRef(shown);
47
89
  useEffect(() => {
48
- const el = ref.current;
90
+ const el = ref.current, box = el?.parentElement;
49
91
  if (!el || last.current === shown)
50
92
  return;
51
93
  last.current = shown;
52
94
  const cs = getComputedStyle(el);
53
- let done = false;
95
+ let done = false, rest = 0;
96
+ const settled = () => { if (box)
97
+ gone(box, me); latest.current.onSettled?.(latest.current.current); };
54
98
  const end = () => {
55
99
  if (done)
56
100
  return;
57
101
  done = true;
58
102
  clearTimeout(t);
59
103
  el.removeEventListener('transitionend', onEnd);
60
- latest.current.onSettled?.(latest.current.current);
104
+ if (shown !== 'away' || reducedMotion())
105
+ return settled();
106
+ rest = window.setTimeout(settled, ms(cs.getPropertyValue('--motion-sheet-rest') || '0s')); // landed: the base is seen for the rest, THEN it is settled
61
107
  };
62
108
  const onEnd = (e) => { if (e.target === el && e.propertyName === 'translate')
63
109
  end(); };
64
110
  el.addEventListener('transitionend', onEnd);
65
111
  const t = setTimeout(end, ms(cs.transitionDuration) + ms(cs.transitionDelay) + 100); // no transition (motion off) or none came: settled anyway
66
- return () => { done = true; clearTimeout(t); el.removeEventListener('transitionend', onEnd); };
67
- }, [shown, latest]);
112
+ return () => { done = true; clearTimeout(t); clearTimeout(rest); el.removeEventListener('transitionend', onEnd); if (box && shown === 'away')
113
+ gone(box, me); }; // cut short on its way out (raised again, unmounted): the box is free
114
+ }, [shown, latest, me]);
68
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.
69
116
  (A whole screen leaving takes the box too; StrictMode's trial unmount is over by the time the timer looks.) */
70
117
  const alive = useRef(false);
@@ -85,17 +132,17 @@ export function Sheet({ state, defaultState = 'half', onStateChange, onSettled,
85
132
  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
133
  has the cursor keeps it. */
87
134
  useEffect(() => {
88
- if (current !== 'peek' && current !== 'away')
89
- home.current = current;
90
- const rose = current !== 'peek' && current !== 'away' && RANK[current] > RANK[was.current];
135
+ if (live !== 'peek' && live !== 'away')
136
+ home.current = live;
137
+ const rose = live !== 'peek' && live !== 'away' && RANK[live] > RANK[was.current];
91
138
  const from = was.current;
92
- was.current = current;
139
+ was.current = live;
93
140
  if (!rose)
94
141
  return;
95
142
  const cancel = focusWhenActive(ref.current, { scope: ':scope > .sheet__body', fallback: () => (from === 'away' ? handleRef.current : null) });
96
143
  return () => { if (!cancel())
97
144
  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]);
145
+ }, [live]);
99
146
  /* 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
147
  const slid = (el) => {
101
148
  const box = (el.offsetParent ?? el.parentElement);
@@ -246,5 +293,5 @@ export function Sheet({ state, defaultState = 'half', onStateChange, onSettled,
246
293
  skipClick.current = true;
247
294
  requestAnimationFrame(() => { skipClick.current = false; });
248
295
  };
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] }));
296
+ 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
297
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usableapp/cardds",
3
- "version": "0.7.46",
3
+ "version": "0.7.48",
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.",