@usableapp/cardds 0.7.47 → 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,14 @@
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
+
5
13
  ## 0.7.47 — 2026-10-03
6
14
 
7
15
  ### Changed
package/css/step.css CHANGED
@@ -150,6 +150,17 @@
150
150
  margin-top: var(--track-top-lead, var(--sp-4));
151
151
  margin-bottom: calc(var(--track-top-gap, var(--sp-4)) - var(--screen-gap));
152
152
  }
153
+ /* …and over a STAGE (a map behind a sheet, Lh 2026-10-03): the stage is the whole screen, so the track is a LAYER on it — the map
154
+ shows around the pill — and the stage keeps the track's line free itself: a sheet at full stops --track-top-gap under the
155
+ track, as in a step. --top-h is the track's own height, measured by Screen (a numbered track grows with the text). */
156
+ .screen--fill > .track:first-child:has(+ .sheet-stage) {
157
+ position: absolute; z-index: 1;
158
+ inset: calc(var(--safe-top) + var(--track-top-lead, var(--sp-4))) var(--screen-pad) auto;
159
+ margin: 0;
160
+ }
161
+ .screen--fill > .track:first-child + .sheet-stage {
162
+ --_top: calc(var(--track-top-lead, var(--sp-4)) + var(--top-h, var(--header-h)) + var(--track-top-gap, var(--sp-4)));
163
+ }
153
164
  /* a walk page that OPENS on a picture (Lh 2026-09-23): the picture takes the lead's room — as wide as the content (edge to edge), from the top edge, as tall as
154
165
  the lead + the picture's own height (--page-media-h), square — so the words start where they always did */
155
166
  .step > .pages > .page:has(> :is(img, .ph):first-child) { margin-top: 0; }
package/dist/cardds.css CHANGED
@@ -1911,6 +1911,17 @@ button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
1911
1911
  margin-top: var(--track-top-lead, var(--sp-4));
1912
1912
  margin-bottom: calc(var(--track-top-gap, var(--sp-4)) - var(--screen-gap));
1913
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
+ }
1914
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
1915
1926
  the lead + the picture's own height (--page-media-h), square — so the words start where they always did */
1916
1927
  .step > .pages > .page:has(> :is(img, .ph):first-child) { margin-top: 0; }
@@ -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) */
@@ -38,10 +39,10 @@ export interface SheetProps extends Omit<ComponentProps<'article'>, 'onChange'>
38
39
  * `{open && <Sheet state="full">}` slides in. Leaving is the app's beat: set `away`, and unmount on `onSettled('away')` — unmount it
39
40
  * while it is still up and it can only vanish (a dev warning says so).
40
41
  *
41
- * 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.
42
+ * GOING AWAY HAS A REST: a sheet that slides out is settled a beat after it landed (`--motion-sheet-rest`, the base is seen) — a
43
+ * fixed habit of every sheet. A sheet asked up in the same box meanwhile waits for it: "one left, another came", never two sheets
44
+ * crossing and never one continuous move, whatever the app's wiring (both states set in one render, or the next raised from the
45
+ * first's `onSettled`) — the app adds no timer.
45
46
  */
46
47
  export declare function Sheet({ state, defaultState, onStateChange, onSettled, states, handle, className, children, onFocus, ...rest }: SheetProps): import("react").JSX.Element;
47
48
  export {};
@@ -6,8 +6,14 @@ import { word } from '../words.js';
6
6
  const ORDER = ['peek', 'half', '3q', 'full']; // low → high; `away` is below them all and outside them
7
7
  const RANK = { away: 0, peek: 1, half: 2, '3q': 3, full: 4 };
8
8
  const TAP_REM = 0.5; // = --sp-2: a move shorter than this is a tap, not a drag
9
- /* 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
+ /* A SHEET THAT GOES AWAY IS NOT DONE WHEN IT LANDS (Lh 2026-10-03): it holds its box for a beat more (--motion-sheet-rest) — the base
10
+ is seen — and only then is it settled. `out` = the sheets still on their way out, per box; a sheet asked up in that box waits for it to empty. */
11
+ const out = new WeakMap();
12
+ const waiting = new WeakMap();
13
+ const leave = (box, key) => { if (!out.has(box))
14
+ out.set(box, new Set()); out.get(box).add(key); };
15
+ const gone = (box, key) => { const set = out.get(box); if (set?.delete(key) && !set.size)
16
+ [...(waiting.get(box) ?? [])].forEach((go) => go()); };
11
17
  const ms = (t) => Math.max(0, ...t.split(',').map((v) => parseFloat(v) * (v.trim().endsWith('ms') ? 1 : 1000) || 0)); // a computed `0.35s, 0s` list → its longest, in ms
12
18
  /**
13
19
  * Sheet — the SINGLE sheet: one card as a bottom sheet holding what the member ACTS on. Always the full height of its
@@ -25,10 +31,10 @@ const ms = (t) => Math.max(0, ...t.split(',').map((v) => parseFloat(v) * (v.trim
25
31
  * `{open && <Sheet state="full">}` slides in. Leaving is the app's beat: set `away`, and unmount on `onSettled('away')` — unmount it
26
32
  * while it is still up and it can only vanish (a dev warning says so).
27
33
  *
28
- * 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.
34
+ * GOING AWAY HAS A REST: a sheet that slides out is settled a beat after it landed (`--motion-sheet-rest`, the base is seen) — a
35
+ * fixed habit of every sheet. A sheet asked up in the same box meanwhile waits for it: "one left, another came", never two sheets
36
+ * crossing and never one continuous move, whatever the app's wiring (both states set in one render, or the next raised from the
37
+ * first's `onSettled`) — the app adds no timer.
32
38
  */
33
39
  export function Sheet({ state, defaultState = 'half', onStateChange, onSettled, states, handle = true, className, children, onFocus, ...rest }) {
34
40
  const [current, request] = useControlled(state, defaultState, onStateChange);
@@ -38,10 +44,17 @@ export function Sheet({ state, defaultState = 'half', onStateChange, onSettled,
38
44
  const was = useRef('away'); // mounted up = it came onto the screen: it rises from away like any arrival
39
45
  const skipClick = useRef(false);
40
46
  const [entered, setEntered] = useState(false); // false for the first frame: the class says `away`, so a sheet mounted up has somewhere to slide from
41
- const [held, setHeld] = useState(false); // true while it waits for the sheet before it to land and the base to be seen
47
+ const me = useRef({}).current; // this sheet, in its box's list of sheets on their way out
48
+ const [held, setHeld] = useState(false); // true while it waits for the sheet going away before it to be settled
49
+ const [prev, setPrev] = useState('away'); // asked up from `away` (or mounted up): it holds first, and rises once its box is free
50
+ if (prev !== current) {
51
+ setPrev(current);
52
+ setHeld(prev === 'away' && current !== 'away');
53
+ }
42
54
  const shown = entered && !held ? current : 'away';
43
55
  const live = held ? 'away' : current; // where it is as far as its own habits go: a held sheet has not come up yet
44
56
  const latest = useLatest({ current, onSettled });
57
+ const last = useRef('away'); // where it was last SHOWN (the settle effect keeps it)
45
58
  const drag = useRef(null);
46
59
  const allowed = useCallback(() => {
47
60
  const list = (typeof states === 'string' ? states.split(/\s+/) : [...(states ?? [])]).filter((s) => ORDER.includes(s));
@@ -51,72 +64,54 @@ export function Sheet({ state, defaultState = 'half', onStateChange, onSettled,
51
64
  void ref.current?.offsetHeight; // the browser styles the `away` frame first, or there is no transition to run
52
65
  setEntered(true);
53
66
  }, []);
54
- /* 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');
67
+ /* it starts on its way out: said in the commit itself, before any sheet of the box decides to rise */
59
68
  useLayoutEffect(() => {
60
- const 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);
69
+ const box = ref.current?.parentElement;
70
+ if (box && current === 'away' && last.current !== 'away' && !reducedMotion())
71
+ leave(box, me);
72
+ }, [current, me]);
73
+ /* held: it rises when no sheet of its box is on its way out — at once if none is */
74
+ useEffect(() => {
75
+ const box = ref.current?.parentElement;
76
+ if (!held)
64
77
  return;
65
- }
66
- const rest = ms(getComputedStyle(el).getPropertyValue('--motion-sheet-rest') || '0s');
67
- const floor = box.getBoundingClientRect().bottom - 1;
68
- const leaving = [...box.querySelectorAll(':scope > .sheet--away')].filter((s) => s !== el && s.getBoundingClientRect().top < floor);
69
- const since = performance.now() - (landed.get(box) ?? -Infinity);
70
- if (!leaving.length && since >= rest) {
78
+ if (!box || !out.get(box)?.size) {
71
79
  setHeld(false);
72
80
  return;
73
81
  }
74
- setHeld(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]);
82
+ const go = () => setHeld(false);
83
+ if (!waiting.has(box))
84
+ waiting.set(box, new Set());
85
+ waiting.get(box).add(go);
86
+ return () => { waiting.get(box)?.delete(go); };
87
+ }, [held]);
95
88
  /* it has finished moving: the transition ended (or at once where there is none) — say so once per move; a move cut short by another says nothing */
96
- const last = useRef(shown);
97
89
  useEffect(() => {
98
- const el = ref.current;
90
+ const el = ref.current, box = el?.parentElement;
99
91
  if (!el || last.current === shown)
100
92
  return;
101
93
  last.current = shown;
102
94
  const cs = getComputedStyle(el);
103
- let done = false;
95
+ let done = false, rest = 0;
96
+ const settled = () => { if (box)
97
+ gone(box, me); latest.current.onSettled?.(latest.current.current); };
104
98
  const end = () => {
105
99
  if (done)
106
100
  return;
107
101
  done = true;
108
102
  clearTimeout(t);
109
103
  el.removeEventListener('transitionend', onEnd);
110
- if (shown === 'away' && el.parentElement)
111
- landed.set(el.parentElement, performance.now()); // the sheet after it counts its rest from here
112
- 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
113
107
  };
114
108
  const onEnd = (e) => { if (e.target === el && e.propertyName === 'translate')
115
109
  end(); };
116
110
  el.addEventListener('transitionend', onEnd);
117
111
  const t = setTimeout(end, ms(cs.transitionDuration) + ms(cs.transitionDelay) + 100); // no transition (motion off) or none came: settled anyway
118
- return () => { done = true; clearTimeout(t); el.removeEventListener('transitionend', onEnd); };
119
- }, [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]);
120
115
  /* taken out of the tree while it is still up — the box stays, the sheet goes: nothing to slide, so it vanishes. Said once in dev.
121
116
  (A whole screen leaving takes the box too; StrictMode's trial unmount is over by the time the timer looks.) */
122
117
  const alive = useRef(false);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usableapp/cardds",
3
- "version": "0.7.47",
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.",