@usableapp/cardds 0.7.58 → 0.7.59

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.59 — 2026-10-06
6
+
7
+ ### Fixed
8
+ - **`SheetStack` / `Home`: a tap right after a fold opens the head you aimed at** (Lh 2026-10-06, reported by timebank). For about half a second after a sheet folded, the pile was still coming back: a tap where another head rests reopened the sheet that had just folded, did nothing, or opened a third sheet. While the pile comes back, a tap is now answered by where each head RESTS — the head that will stand under the finger opens. Nothing to change in a screen. A test that waited for `is-before` / `is-after` to leave before tapping still passes; it no longer has to wait. Still as before: a tap on a tab's place during that moment opens the sheet on its current tab, not that tab.
9
+
10
+ ### Added
11
+ - **`SheetStack` and `Home` take `mark`** — the grip at the middle of every sheet's top edge: `dots` (default, the four dots) or `line` (one line, the single `Sheet`'s, at the same place — the title does not move). While a sheet is `refreshing`, the line changes colour the same way the dots do. One mark for the whole stack: `<Home mark="line" …>`. Omit it and nothing changes.
12
+
5
13
  ## 0.7.58 — 2026-10-06
6
14
 
7
15
  ### Changed
package/README.md CHANGED
@@ -183,7 +183,11 @@ the card is the material, the sheet is what it is in the stack.
183
183
  content, already at the top, asks `onRefresh` — omit it and that pull only
184
184
  scrolls. A tab in the head stays a click. Every sheet wears a grip — four
185
185
  small dots at the middle of its top edge, a mark that it slides, not a
186
- control.
186
+ control. `mark="line"` on the `SheetStack` (or `Home`) draws one line
187
+ there instead, as a single `Sheet`'s; while a sheet is `refreshing`, either
188
+ mark changes colour the same way. Right after a fold, while the
189
+ pile is still coming back, a tap is answered by where each head RESTS: the
190
+ head that will stand under the finger opens, whatever is passing there.
187
191
  - **Five sheets max.** Slots are declared per `:nth-child` in `stack.css`; a
188
192
  sixth would render at slot 0, on top of the first.
189
193
  - **Every head needs its own `.sheet-stack__back`** if the stack is `--tap`. It stays
package/css/stack.css CHANGED
@@ -210,6 +210,18 @@
210
210
  animation: stack-grip-dissolve 0.4s ease-in-out infinite alternate;
211
211
  }
212
212
  @keyframes stack-grip-dissolve { from { --_grip: 0; } to { --_grip: 1; } }
213
+ /* the grip as ONE LINE (`mark="line"`, Lh 2026-10-06): the single Sheet's line, at the same place and the same length. It reads the
214
+ same --_grip-ink, so while its sheet reloads it changes colour exactly as the dots do. A bar and two round ends; the resting
215
+ ink is mixed into the card's own colour (opaque), so the ends do not show darker where they lie on the bar. */
216
+ .stack-card--line {
217
+ --_grip-w: calc(var(--sp-base) * 18);
218
+ --_grip-ink: color-mix(in srgb, currentColor 30%, var(--card-bg));
219
+ --_grip-bar: linear-gradient(var(--_grip-ink), var(--_grip-ink));
220
+ --_grip-end: calc((var(--_grip-w) - var(--_grip-dot)) / 2);
221
+ background-image: var(--_grip-img), var(--_grip-bar), var(--_grip-img);
222
+ background-size: var(--_grip-dot) var(--_grip-dot), calc(var(--_grip-w) - var(--_grip-dot)) var(--_grip-dot), var(--_grip-dot) var(--_grip-dot);
223
+ background-position: calc(50% - var(--_grip-end)) var(--_grip-y), 50% var(--_grip-y), calc(50% + var(--_grip-end)) var(--_grip-y);
224
+ }
213
225
  @media (prefers-reduced-motion: reduce) {
214
226
  .stack-card.is-open.is-loading { animation: none; --_grip-ink: currentColor; }
215
227
  }
package/dist/cardds.css CHANGED
@@ -1211,6 +1211,18 @@ button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
1211
1211
  animation: stack-grip-dissolve 0.4s ease-in-out infinite alternate;
1212
1212
  }
1213
1213
  @keyframes stack-grip-dissolve { from { --_grip: 0; } to { --_grip: 1; } }
1214
+ /* the grip as ONE LINE (`mark="line"`, Lh 2026-10-06): the single Sheet's line, at the same place and the same length. It reads the
1215
+ same --_grip-ink, so while its sheet reloads it changes colour exactly as the dots do. A bar and two round ends; the resting
1216
+ ink is mixed into the card's own colour (opaque), so the ends do not show darker where they lie on the bar. */
1217
+ .stack-card--line {
1218
+ --_grip-w: calc(var(--sp-base) * 18);
1219
+ --_grip-ink: color-mix(in srgb, currentColor 30%, var(--card-bg));
1220
+ --_grip-bar: linear-gradient(var(--_grip-ink), var(--_grip-ink));
1221
+ --_grip-end: calc((var(--_grip-w) - var(--_grip-dot)) / 2);
1222
+ background-image: var(--_grip-img), var(--_grip-bar), var(--_grip-img);
1223
+ background-size: var(--_grip-dot) var(--_grip-dot), calc(var(--_grip-w) - var(--_grip-dot)) var(--_grip-dot), var(--_grip-dot) var(--_grip-dot);
1224
+ background-position: calc(50% - var(--_grip-end)) var(--_grip-y), 50% var(--_grip-y), calc(50% + var(--_grip-end)) var(--_grip-y);
1225
+ }
1214
1226
  @media (prefers-reduced-motion: reduce) {
1215
1227
  .stack-card.is-open.is-loading { animation: none; --_grip-ink: currentColor; }
1216
1228
  }
@@ -13,6 +13,8 @@ export interface SheetStackProps extends ComponentProps<'div'> {
13
13
  onRefresh?: (index: number) => void;
14
14
  /** the sheet the app is reloading (its index), or `null`: while it is the open one its four grip dots fade up and down, at rest again when the app clears it, and a pull there does not ask again. The app sets it in `onRefresh` and clears it when its data is in */
15
15
  refreshing?: number | null;
16
+ /** the grip every sheet wears at the middle of its top edge: `dots` (default, four small dots) or `line` (one line, as a `Sheet`'s). Either one changes colour the same way while its sheet is `refreshing` */
17
+ mark?: 'dots' | 'line';
16
18
  /** wider peeks so more of each title shows (display stacks) */
17
19
  fanned?: boolean;
18
20
  /** nothing open on arrival (tap mode): every sheet, the front one too, peeks at the stack's bottom; the room above is the base's (`base`). A tap opens any sheet over it */
@@ -34,4 +36,4 @@ export interface SheetStackProps extends ComponentProps<'div'> {
34
36
  * the place classes, the grip, the pull. The stack only says which index. Sheets are rigid — the open one slides up, the ones after it leave
35
37
  * below, the ones before tuck under nearest-first; closing reverses in two beats. Only `translate` (stack.css); never a size.
36
38
  */
37
- export declare function SheetStack({ mode, open, defaultOpen, onOpenChange, onRefresh, refreshing, fanned, closed, base, className, children, ...rest }: SheetStackProps): import("react").JSX.Element;
39
+ export declare function SheetStack({ mode, open, defaultOpen, onOpenChange, onRefresh, refreshing, mark, fanned, closed, base, className, children, onClickCapture, onPointerDownCapture, ref: passed, ...rest }: SheetStackProps): import("react").JSX.Element;
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Children, cloneElement, isValidElement, useEffect, useRef, useState } from 'react';
2
+ import { Children, cloneElement, isValidElement, useEffect, useLayoutEffect, useRef, useState } from 'react';
3
3
  import { useControlled, warnOnce } from '../state.js';
4
4
  import { cx } from '../cx.js';
5
5
  import { Card } from '../cards/Card.js';
@@ -7,6 +7,24 @@ import { StackSheet } from './StackSheet.js';
7
7
  const PHASE = 380; // ms before the before-pack un-tucks on close, so the after-pack is clear first
8
8
  const UNTUCK = 700; // the stagger stays for the return: the delay is read when is-before leaves, and a later render must not take it away mid-move
9
9
  const SHEET = new Set([Card, StackSheet]);
10
+ const cardsIn = (stack) => [...stack.children]
11
+ .filter((c) => !c.classList.contains('sheet-stack__base'))
12
+ .map((c) => (c.classList.contains('card') ? c : c.querySelector(':scope > .card')))
13
+ .filter((c) => c instanceof HTMLElement);
14
+ function measureRests(stack) {
15
+ const box = stack.getBoundingClientRect();
16
+ const cards = cardsIn(stack);
17
+ const tops = cards.map((c) => c.getBoundingClientRect().top - box.top);
18
+ return {
19
+ height: box.height,
20
+ strips: cards.map((c, i) => {
21
+ const head = c.querySelector(':scope > .card__head')?.getBoundingClientRect();
22
+ const own = head ? head.bottom - box.top + (head.top - box.top - tops[i]) : tops[i];
23
+ return { top: tops[i], end: i < cards.length - 1 ? tops[i + 1] : own };
24
+ }),
25
+ };
26
+ }
27
+ const SLOP = 8; // px a pointer may travel and still be a tap
10
28
  /* one wrapper around a sheet is fine (a host's own box). The props land on the Card or StackSheet inside it, so the card renders them. */
11
29
  function stamp(child, place) {
12
30
  if (SHEET.has(child.type))
@@ -37,7 +55,7 @@ function stamp(child, place) {
37
55
  * the place classes, the grip, the pull. The stack only says which index. Sheets are rigid — the open one slides up, the ones after it leave
38
56
  * below, the ones before tuck under nearest-first; closing reverses in two beats. Only `translate` (stack.css); never a size.
39
57
  */
40
- export function SheetStack({ mode = 'tap', open, defaultOpen = null, onOpenChange, onRefresh, refreshing = null, fanned, closed, base, className, children, ...rest }) {
58
+ export function SheetStack({ mode = 'tap', open, defaultOpen = null, onOpenChange, onRefresh, refreshing = null, mark = 'dots', fanned, closed, base, className, children, onClickCapture, onPointerDownCapture, ref: passed, ...rest }) {
41
59
  const [current, request] = useControlled(open, defaultOpen, onOpenChange);
42
60
  const prev = useRef(current);
43
61
  const [closingFrom, setClosingFrom] = useState(null);
@@ -73,6 +91,51 @@ export function SheetStack({ mode = 'tap', open, defaultOpen = null, onOpenChang
73
91
  }, Math.max(0, held.until - performance.now()));
74
92
  return () => clearTimeout(t);
75
93
  });
94
+ /* a tap while the pile comes back from a fold (Lh 2026-10-06). The sheets are not where they rest yet — the folded one is passing,
95
+ the tucked ones stand a step low — so what is under the finger is not what the eye aimed at. The tap is answered by the RESTING
96
+ place: the head that will stand there opens. The places are measured while the pile is still. */
97
+ const ref = useRef(null);
98
+ const setRef = (node) => {
99
+ ref.current = node;
100
+ if (typeof passed === 'function')
101
+ passed(node);
102
+ else if (passed)
103
+ passed.current = node;
104
+ };
105
+ const rests = useRef(null);
106
+ const down = useRef(null);
107
+ const settling = mode === 'tap' && current == null && (closingFrom != null || back.current != null);
108
+ const still = mode === 'tap' && current == null && !settling;
109
+ useLayoutEffect(() => {
110
+ if (still && ref.current)
111
+ rests.current = measureRests(ref.current);
112
+ });
113
+ const pointerDownCapture = (e) => {
114
+ onPointerDownCapture?.(e);
115
+ down.current = { x: e.clientX, y: e.clientY };
116
+ };
117
+ const clickCapture = (e) => {
118
+ onClickCapture?.(e);
119
+ const stack = ref.current;
120
+ const at = rests.current;
121
+ const from = down.current;
122
+ if (!settling || !stack || !at || e.detail === 0)
123
+ return; // detail 0: the keyboard's click has no place
124
+ if (from && Math.hypot(e.clientX - from.x, e.clientY - from.y) > SLOP)
125
+ return; // a pull that sprang back, not a tap
126
+ const box = stack.getBoundingClientRect();
127
+ if (Math.abs(box.height - at.height) > 1)
128
+ return; // the stack changed size since it was still: the places are stale
129
+ const y = e.clientY - box.top;
130
+ const i = at.strips.findIndex((s) => y >= s.top && y < s.end);
131
+ if (i < 0)
132
+ return;
133
+ const cards = cardsIn(stack);
134
+ if (cards[i]?.contains(e.target) && !cards[i].classList.contains('is-before'))
135
+ return; // already home: its own tap, a tab's too
136
+ e.stopPropagation();
137
+ request(i);
138
+ };
76
139
  const closing = current == null && closingFrom != null;
77
140
  const anchor = current ?? closingFrom;
78
141
  const items = Children.toArray(children);
@@ -85,6 +148,7 @@ export function SheetStack({ mode = 'tap', open, defaultOpen = null, onOpenChang
85
148
  const place = {
86
149
  open: !closing && current === i,
87
150
  refreshing: refreshing === i,
151
+ mark,
88
152
  before,
89
153
  after: !closing && current != null && i > current,
90
154
  stagger: before && anchor != null ? anchor - 1 - i : returning != null && i < returning ? returning - 1 - i : 0,
@@ -94,5 +158,5 @@ export function SheetStack({ mode = 'tap', open, defaultOpen = null, onOpenChang
94
158
  };
95
159
  return stamp(child, place);
96
160
  }) : items;
97
- return (_jsxs("div", { className: cx('sheet-stack', mode !== 'static' && `sheet-stack--${mode}`, fanned && 'sheet-stack--fanned', closed && 'sheet-stack--closed', className), ...rest, children: [stamped, base != null && _jsx("div", { className: "sheet-stack__base", children: base })] }));
161
+ return (_jsxs("div", { className: cx('sheet-stack', mode !== 'static' && `sheet-stack--${mode}`, fanned && 'sheet-stack--fanned', closed && 'sheet-stack--closed', className), ...rest, ref: setRef, onClickCapture: clickCapture, onPointerDownCapture: pointerDownCapture, children: [stamped, base != null && _jsx("div", { className: "sheet-stack__base", children: base })] }));
98
162
  }
@@ -5,6 +5,8 @@ export interface StackPlace {
5
5
  open: boolean;
6
6
  /** the app is reloading this sheet */
7
7
  refreshing: boolean;
8
+ /** the grip at the card's top edge: four dots, or one line */
9
+ mark: 'dots' | 'line';
8
10
  /** tucked under the open sheet. Stays through the first beat of a close */
9
11
  before: boolean;
10
12
  /** in front of the open sheet, leaving below */
@@ -14,7 +14,7 @@ export function stackPaint(place, pulling = false) {
14
14
  if (!place)
15
15
  return {};
16
16
  const loading = place.open && place.refreshing;
17
- const className = ['stack-card', place.open && 'is-open', place.before && 'is-before', place.after && 'is-after', loading && 'is-loading', pulling && 'is-refreshing'].filter(Boolean).join(' ');
17
+ const className = ['stack-card', place.mark === 'line' && 'stack-card--line', place.open && 'is-open', place.before && 'is-before', place.after && 'is-after', loading && 'is-loading', pulling && 'is-refreshing'].filter(Boolean).join(' ');
18
18
  return {
19
19
  className,
20
20
  style: place.stagger > 0 ? { '--stagger': String(place.stagger) } : undefined,
@@ -35,6 +35,8 @@ export interface HomeProps extends Omit<ComponentProps<typeof Screen>, 'children
35
35
  onRefresh?: (index: number) => void;
36
36
  /** the sheet the app is reloading (its index), or `null` — as `SheetStack refreshing` */
37
37
  refreshing?: number | null;
38
+ /** the grip on every sheet: `dots` (default) or `line` — as `SheetStack mark` */
39
+ mark?: 'dots' | 'line';
38
40
  /** CONTENT above the sheet heads — one `Page`: a welcome, the news, a picture. It reads
39
41
  * from the top; a sheet coming up covers it */
40
42
  base?: ReactNode;
@@ -54,4 +56,4 @@ export interface HomeProps extends Omit<ComponentProps<typeof Screen>, 'children
54
56
  * at the bottom). A tap on a head brings that sheet up to fill everything under the top. With a LayerHistory above and no `open` prop, that open sheet is one history entry named `home`. Only the open sheet's body is rendered — three
55
57
  * sections on one home never cost three pages (`keep` holds a body once opened).
56
58
  */
57
- export declare function Home({ top, title, sheets, open: openProp, defaultOpen, onOpenChange, onRefresh, refreshing, base, keep, backLabel, backAt, children, ...rest }: HomeProps): import("react").JSX.Element;
59
+ export declare function Home({ top, title, sheets, open: openProp, defaultOpen, onOpenChange, onRefresh, refreshing, mark, base, keep, backLabel, backAt, children, ...rest }: HomeProps): import("react").JSX.Element;
@@ -16,7 +16,7 @@ const LEAVE = 800; // ms: a closing sheet keeps its body until it has slid home
16
16
  * at the bottom). A tap on a head brings that sheet up to fill everything under the top. With a LayerHistory above and no `open` prop, that open sheet is one history entry named `home`. Only the open sheet's body is rendered — three
17
17
  * sections on one home never cost three pages (`keep` holds a body once opened).
18
18
  */
19
- export function Home({ top, title, sheets, open: openProp, defaultOpen = null, onOpenChange, onRefresh, refreshing, base, keep, backLabel = word('back'), backAt, children, ...rest }) {
19
+ export function Home({ top, title, sheets, open: openProp, defaultOpen = null, onOpenChange, onRefresh, refreshing, mark, base, keep, backLabel = word('back'), backAt, children, ...rest }) {
20
20
  const layer = useOptionalLayer('home');
21
21
  const [local, change] = useControlled(openProp, defaultOpen, onOpenChange);
22
22
  // A passed `open` is the parent's. With no LayerHistory the index stays local, so a story without a router still opens sheets.
@@ -50,5 +50,5 @@ export function Home({ top, title, sheets, open: openProp, defaultOpen = null, o
50
50
  }, [leaving]);
51
51
  const [tabs, setTabs] = useState(() => Object.fromEntries(sheets.map((x, i) => [i, x.defaultTab ?? x.tabs?.[0]?.id])));
52
52
  const shows = (i) => i === shown || i === leaving || (keep && seen.has(i));
53
- return (_jsxs(Screen, { fill: true, top: top, ...rest, children: [_jsx(Text, { level: "h1", as: "h1", sr: true, children: title }), _jsx(SheetStack, { closed: true, open: shown, onOpenChange: request, onRefresh: onRefresh, refreshing: refreshing, base: base, children: sheets.map((s, i) => (_jsx(StackSheet, { tone: s.tone ?? TONES[i], paper: s.paper, title: s.title, icon: s.icon, tabs: s.tabs, tab: tabs[i], onTabChange: (t) => setTabs((v) => ({ ...v, [i]: t })), backLabel: backLabel, backAt: backAt, foot: typeof s.foot === 'function' ? s.foot(tabs[i]) : s.foot, children: shows(i) ? (typeof s.body === 'function' ? s.body(tabs[i]) : s.body) : null }, i))) }), children] }));
53
+ return (_jsxs(Screen, { fill: true, top: top, ...rest, children: [_jsx(Text, { level: "h1", as: "h1", sr: true, children: title }), _jsx(SheetStack, { closed: true, open: shown, onOpenChange: request, onRefresh: onRefresh, refreshing: refreshing, mark: mark, base: base, children: sheets.map((s, i) => (_jsx(StackSheet, { tone: s.tone ?? TONES[i], paper: s.paper, title: s.title, icon: s.icon, tabs: s.tabs, tab: tabs[i], onTabChange: (t) => setTabs((v) => ({ ...v, [i]: t })), backLabel: backLabel, backAt: backAt, foot: typeof s.foot === 'function' ? s.foot(tabs[i]) : s.foot, children: shows(i) ? (typeof s.body === 'function' ? s.body(tabs[i]) : s.body) : null }, i))) }), children] }));
54
54
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usableapp/cardds",
3
- "version": "0.7.58",
3
+ "version": "0.7.59",
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.",