@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 +8 -0
- package/css/step.css +11 -0
- package/dist/cardds.css +11 -0
- package/dist/scaffold/Screen.js +9 -0
- package/dist/sheets/Sheet.d.ts +6 -5
- package/dist/sheets/Sheet.js +46 -51
- package/package.json +1 -1
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; }
|
package/dist/scaffold/Screen.js
CHANGED
|
@@ -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')
|
package/dist/sheets/Sheet.d.ts
CHANGED
|
@@ -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
|
|
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
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
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 {};
|
package/dist/sheets/Sheet.js
CHANGED
|
@@ -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
|
-
/*
|
|
10
|
-
|
|
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
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
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
|
|
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
|
-
/*
|
|
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
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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(
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
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.
|
|
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.",
|