@usableapp/cardds 0.7.51 → 0.7.53

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.53 — 2026-10-05
6
+
7
+ ### Fixed
8
+ - **`SheetStack` / `Home`: the pull down that asks `onRefresh` works with a finger** (Lh 2026-10-05). In 0.7.51 it only worked with a mouse: on a phone the browser took the touch for its own scroll and the content only jerked. A pull down from the top of the open content now follows the finger and asks; any other move still scrolls. Nothing to change in a screen.
9
+
10
+ ### Added
11
+ - **`SheetStack` and `Home` take `refreshing`** — the index of the sheet you are reloading, or `null`. Set it in `onRefresh`, clear it when your data is in: `onRefresh={(i) => { setRefreshing(i); load(i).finally(() => setRefreshing(null)); }}` with `refreshing={refreshing}`. While it is the open sheet, its grip dots (below) fade up and down, the card is `aria-busy`, and a pull there does not ask again. Without `refreshing` nothing shows that a reload is running. Reload the pulled sheet alone or everything — that is yours; the stack does not fetch.
12
+
13
+ ### Changed
14
+ - **Every sheet of a tap stack wears a grip: four small grey dots at the middle of its top edge** (Lh 2026-10-05) — a sign that the card slides up and down. A mark, not a control; nothing is added to the markup. Every `SheetStack` (tap mode) and `Home` shows it after the update — nothing to change in a screen, and there is no knob for it yet: if your screen needs it tuned or off, report it.
15
+
16
+ ## 0.7.52 — 2026-10-05
17
+
18
+ ### Changed
19
+ - **A `StackSheet` tab's disc shows only while its sheet is open** (Lh 2026-10-04). On the home, every peeked sheet's tabs are the icon alone — none is filled. The chosen tab fills as the sheet rises (the one that was pressed, or the page the sheet opens on) and the fill leaves as the sheet folds. Which page is current is unchanged; only the paint waits. Nothing to change in a screen.
20
+
5
21
  ## 0.7.51 — 2026-10-04
6
22
 
7
23
  ### Added
package/README.md CHANGED
@@ -181,12 +181,14 @@ the card is the material, the sheet is what it is in the stack.
181
181
  A tap, or a pull up, opens any sheet over it. The open title or its back
182
182
  button, tapped or pulled down, drops it home. A pull down in the open
183
183
  content, already at the top, asks `onRefresh` — omit it and that pull only
184
- scrolls. A tab in the head stays a click.
184
+ scrolls. A tab in the head stays a click. Every sheet wears a grip — four
185
+ small dots at the middle of its top edge, a mark that it slides, not a
186
+ control.
185
187
  - **Five sheets max.** Slots are declared per `:nth-child` in `stack.css`; a
186
188
  sixth would render at slot 0, on top of the first.
187
189
  - **Every head needs its own `.sheet-stack__back`** if the stack is `--tap`. It stays
188
190
  hidden until that sheet is open, then slides into a reserved header slot.
189
- - **No ids, no wiring.** `SheetStack` holds which sheet is open (`open` + `onOpenChange`, or `defaultOpen`). A pull down in the open content asks `onRefresh(index)`. The stack does not fetch.
191
+ - **No ids, no wiring.** `SheetStack` holds which sheet is open (`open` + `onOpenChange`, or `defaultOpen`). A pull down in the open content asks `onRefresh(index)`. The stack does not fetch: the app reloads (that sheet alone, or more — its call) and says so with `refreshing` (that index, then `null` when the data is in) — the sheet's grip dots fade up and down meanwhile, and a pull there does not ask again.
190
192
  - **`.card-stack` is gone.** That was this component's first name and the wrong
191
193
  one — the members are sheets and the stack is the menu, not a list of cards.
192
194
  Never bring it back, not even as an alias.
package/css/finish.css CHANGED
@@ -65,6 +65,11 @@
65
65
  0 1px 2px color-mix(in srgb, var(--_shade) 20%, transparent),
66
66
  0 0.5rem 1rem -0.25rem color-mix(in srgb, var(--accent) 45%, transparent);
67
67
  }
68
+ /* a peeked stack tab is the icon alone: no sheen and no shadow until its sheet is open (the fill fades with the sheet) */
69
+ [data-finish="lit"] .sheet-stack--tap > .card:not(.is-open) > .card__head .stack-sheet__tab {
70
+ background-image: none;
71
+ box-shadow: none;
72
+ }
68
73
  [data-finish="lit"] :is(.btn:not(.btn--quiet), .icon-btn:not(.icon-btn-group > *, .stack-sheet__tab:not(.icon-btn--invert))):active:not(:disabled) {
69
74
  background-image: none;
70
75
  box-shadow: var(--finish-control-down);
package/css/stack.css CHANGED
@@ -183,6 +183,29 @@
183
183
  }
184
184
  .sheet-stack > .card > .sheet__body.is-refreshing,
185
185
  .sheet-stack--tap > .card.is-open.is-refreshing:not(:has(> .sheet__body)) { transition: none; }
186
+ /* the GRIP (Lh 2026-10-05): four small grey dots at the middle of every sheet's top edge — just enough of a sign that this card
187
+ slides up and down. A mark, not a control: nothing to press, nothing added to the sheet (the card's own background, in the
188
+ room above its head). While the app reloads the open sheet (.is-loading, from `refreshing`) the same four dots fade up and
189
+ down until it clears — nothing travels, nobody knows how long a reload takes. --_grip is their strength, private. */
190
+ @property --_grip { syntax: "<number>"; inherits: false; initial-value: 0.35; }
191
+ .sheet-stack--tap > .card {
192
+ --_grip-dot: var(--sp-1);
193
+ --_grip-step: calc(var(--sp-1) * 2.5);
194
+ --_grip-y: calc((var(--card-pad) - var(--_grip-dot)) / 2);
195
+ --_grip-ink: color-mix(in srgb, currentColor calc(var(--_grip) * 100%), transparent);
196
+ --_grip-img: radial-gradient(circle closest-side, var(--_grip-ink) 85%, transparent);
197
+ background-image: var(--_grip-img), var(--_grip-img), var(--_grip-img), var(--_grip-img);
198
+ background-repeat: no-repeat;
199
+ background-size: var(--_grip-dot) var(--_grip-dot);
200
+ background-position:
201
+ calc(50% - 1.5 * var(--_grip-step)) var(--_grip-y), calc(50% - 0.5 * var(--_grip-step)) var(--_grip-y),
202
+ calc(50% + 0.5 * var(--_grip-step)) var(--_grip-y), calc(50% + 1.5 * var(--_grip-step)) var(--_grip-y);
203
+ }
204
+ .sheet-stack--tap > .card.is-open.is-loading { animation: stack-grip-breath 0.8s ease-in-out infinite alternate; }
205
+ @keyframes stack-grip-breath { from { --_grip: 0.2; } to { --_grip: 1; } }
206
+ @media (prefers-reduced-motion: reduce) {
207
+ .sheet-stack--tap > .card.is-open.is-loading { animation: none; --_grip: 1; } /* no fade: the dots are simply full while it reloads */
208
+ }
186
209
  /* the front sheet — the one open on arrival — never travels up (it is not
187
210
  tappable), so its resting offset would leave its last n steps below the
188
211
  screen forever, unreachable by any scroll (timebank home, 2026-09-13). It
@@ -285,7 +308,7 @@
285
308
  .sheet-stack > .card > .sheet__body { --_cover: 0rem; } /* nothing slid under here: the stack's sheet is always whole */
286
309
 
287
310
  /* ---- StackSheet's tabs (Lh 2026-09-23): the head's end is a row of small icon buttons, the sheet's pages — the chosen one
288
- filled (.icon-btn--invert); a count is a small circle over the icon's top right corner ---- */
311
+ filled (.icon-btn--invert) only while the sheet is open; a count is a small circle over the icon's top right corner ---- */
289
312
  .stack-sheet__tabs { display: flex; gap: var(--stack-sheet-tabs-gap, var(--sp-2)); flex: none; } /* room between the tabs: with none, the chosen tab's disc touched its neighbour's (Lh 2026-09-24) */
290
313
  /* the head is ONE line, always (Lh 2026-09-24): the tabs keep their size, the TITLE gives — it never wraps, it runs sideways
291
314
  (StackSheet measures --_over, the width it overflows by, and sets .is-long), one way: it waits, runs to the end, waits, and starts again from the first letter — like a song title */
@@ -304,8 +327,19 @@
304
327
  @media (prefers-reduced-motion: reduce) {
305
328
  .stack-sheet__title.is-long .stack-sheet__run { animation: none; display: inline; } /* inline: an ellipsis cannot cut an inline-block */
306
329
  .stack-sheet__title.is-long { text-overflow: ellipsis; }
330
+ .stack-sheet__tab { transition: none; } /* the disc is simply there while open, and gone while closed */
331
+ }
332
+ .stack-sheet__tab {
333
+ position: relative;
334
+ /* the disc fades with the sheet: in as it rises, out as it folds, and across when another tab is pressed.
335
+ scale stays on the press (actions.css) so the give under a finger stays quick */
336
+ transition:
337
+ scale var(--motion-press) ease-out,
338
+ background-color var(--motion-sheet) ease,
339
+ color var(--motion-sheet) ease,
340
+ border-color var(--motion-sheet) ease,
341
+ box-shadow var(--motion-sheet) ease;
307
342
  }
308
- .stack-sheet__tab { position: relative; }
309
343
  .stack-sheet__tab > .stack-sheet__count { /* outranks .badge (numbers.css loads after this file) */
310
344
  position: absolute;
311
345
  top: calc(-1 * var(--stack-sheet-count-out, var(--sp-2))); /* how far it stands OUT of the tab's corner, up and to the right: at sp-1 it
@@ -323,6 +357,12 @@
323
357
  /* a tab not chosen is the icon alone — no ring, no ground (Lh 2026-09-23: "the circle around is not pretty"); the chosen one turns
324
358
  to the opposite colour (.icon-btn--invert). */
325
359
  .stack-sheet__tab:not(.icon-btn--invert) { border-color: transparent; background: transparent; box-shadow: none; color: inherit; } /* the sheet's own ink, on any paper */
360
+ /* a CLOSED sheet shows no chosen disc either (Lh 2026-10-04): arriving on the home, every tab of every sheet is the icon
361
+ alone. The fill fades in with the rise — the tab that was pressed, or the one the sheet opens on — and fades out as the
362
+ sheet folds. The class stays (which page it is); only the paint waits for .is-open. */
363
+ .sheet-stack--tap > .card:not(.is-open) > .card__head .stack-sheet__tab.icon-btn--invert {
364
+ border-color: transparent; background-color: transparent; box-shadow: none; color: inherit;
365
+ }
326
366
  /* the last tab's ICON lines up with the inset, not its button's edge: an unchosen tab shows the glyph only, so the button's own
327
367
  margin around it reaches into the card's padding — and an open head (back · title · three tabs) keeps one line (Lh 2026-09-23) */
328
368
  .card__head > .stack-sheet__tabs { margin-right: calc(-1 * var(--sp-2)); }
package/dist/cardds.css CHANGED
@@ -1184,6 +1184,29 @@ button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
1184
1184
  }
1185
1185
  .sheet-stack > .card > .sheet__body.is-refreshing,
1186
1186
  .sheet-stack--tap > .card.is-open.is-refreshing:not(:has(> .sheet__body)) { transition: none; }
1187
+ /* the GRIP (Lh 2026-10-05): four small grey dots at the middle of every sheet's top edge — just enough of a sign that this card
1188
+ slides up and down. A mark, not a control: nothing to press, nothing added to the sheet (the card's own background, in the
1189
+ room above its head). While the app reloads the open sheet (.is-loading, from `refreshing`) the same four dots fade up and
1190
+ down until it clears — nothing travels, nobody knows how long a reload takes. --_grip is their strength, private. */
1191
+ @property --_grip { syntax: "<number>"; inherits: false; initial-value: 0.35; }
1192
+ .sheet-stack--tap > .card {
1193
+ --_grip-dot: var(--sp-1);
1194
+ --_grip-step: calc(var(--sp-1) * 2.5);
1195
+ --_grip-y: calc((var(--card-pad) - var(--_grip-dot)) / 2);
1196
+ --_grip-ink: color-mix(in srgb, currentColor calc(var(--_grip) * 100%), transparent);
1197
+ --_grip-img: radial-gradient(circle closest-side, var(--_grip-ink) 85%, transparent);
1198
+ background-image: var(--_grip-img), var(--_grip-img), var(--_grip-img), var(--_grip-img);
1199
+ background-repeat: no-repeat;
1200
+ background-size: var(--_grip-dot) var(--_grip-dot);
1201
+ background-position:
1202
+ calc(50% - 1.5 * var(--_grip-step)) var(--_grip-y), calc(50% - 0.5 * var(--_grip-step)) var(--_grip-y),
1203
+ calc(50% + 0.5 * var(--_grip-step)) var(--_grip-y), calc(50% + 1.5 * var(--_grip-step)) var(--_grip-y);
1204
+ }
1205
+ .sheet-stack--tap > .card.is-open.is-loading { animation: stack-grip-breath 0.8s ease-in-out infinite alternate; }
1206
+ @keyframes stack-grip-breath { from { --_grip: 0.2; } to { --_grip: 1; } }
1207
+ @media (prefers-reduced-motion: reduce) {
1208
+ .sheet-stack--tap > .card.is-open.is-loading { animation: none; --_grip: 1; } /* no fade: the dots are simply full while it reloads */
1209
+ }
1187
1210
  /* the front sheet — the one open on arrival — never travels up (it is not
1188
1211
  tappable), so its resting offset would leave its last n steps below the
1189
1212
  screen forever, unreachable by any scroll (timebank home, 2026-09-13). It
@@ -1286,7 +1309,7 @@ button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
1286
1309
  .sheet-stack > .card > .sheet__body { --_cover: 0rem; } /* nothing slid under here: the stack's sheet is always whole */
1287
1310
 
1288
1311
  /* ---- StackSheet's tabs (Lh 2026-09-23): the head's end is a row of small icon buttons, the sheet's pages — the chosen one
1289
- filled (.icon-btn--invert); a count is a small circle over the icon's top right corner ---- */
1312
+ filled (.icon-btn--invert) only while the sheet is open; a count is a small circle over the icon's top right corner ---- */
1290
1313
  .stack-sheet__tabs { display: flex; gap: var(--stack-sheet-tabs-gap, var(--sp-2)); flex: none; } /* room between the tabs: with none, the chosen tab's disc touched its neighbour's (Lh 2026-09-24) */
1291
1314
  /* the head is ONE line, always (Lh 2026-09-24): the tabs keep their size, the TITLE gives — it never wraps, it runs sideways
1292
1315
  (StackSheet measures --_over, the width it overflows by, and sets .is-long), one way: it waits, runs to the end, waits, and starts again from the first letter — like a song title */
@@ -1305,8 +1328,19 @@ button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
1305
1328
  @media (prefers-reduced-motion: reduce) {
1306
1329
  .stack-sheet__title.is-long .stack-sheet__run { animation: none; display: inline; } /* inline: an ellipsis cannot cut an inline-block */
1307
1330
  .stack-sheet__title.is-long { text-overflow: ellipsis; }
1331
+ .stack-sheet__tab { transition: none; } /* the disc is simply there while open, and gone while closed */
1332
+ }
1333
+ .stack-sheet__tab {
1334
+ position: relative;
1335
+ /* the disc fades with the sheet: in as it rises, out as it folds, and across when another tab is pressed.
1336
+ scale stays on the press (actions.css) so the give under a finger stays quick */
1337
+ transition:
1338
+ scale var(--motion-press) ease-out,
1339
+ background-color var(--motion-sheet) ease,
1340
+ color var(--motion-sheet) ease,
1341
+ border-color var(--motion-sheet) ease,
1342
+ box-shadow var(--motion-sheet) ease;
1308
1343
  }
1309
- .stack-sheet__tab { position: relative; }
1310
1344
  .stack-sheet__tab > .stack-sheet__count { /* outranks .badge (numbers.css loads after this file) */
1311
1345
  position: absolute;
1312
1346
  top: calc(-1 * var(--stack-sheet-count-out, var(--sp-2))); /* how far it stands OUT of the tab's corner, up and to the right: at sp-1 it
@@ -1324,6 +1358,12 @@ button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
1324
1358
  /* a tab not chosen is the icon alone — no ring, no ground (Lh 2026-09-23: "the circle around is not pretty"); the chosen one turns
1325
1359
  to the opposite colour (.icon-btn--invert). */
1326
1360
  .stack-sheet__tab:not(.icon-btn--invert) { border-color: transparent; background: transparent; box-shadow: none; color: inherit; } /* the sheet's own ink, on any paper */
1361
+ /* a CLOSED sheet shows no chosen disc either (Lh 2026-10-04): arriving on the home, every tab of every sheet is the icon
1362
+ alone. The fill fades in with the rise — the tab that was pressed, or the one the sheet opens on — and fades out as the
1363
+ sheet folds. The class stays (which page it is); only the paint waits for .is-open. */
1364
+ .sheet-stack--tap > .card:not(.is-open) > .card__head .stack-sheet__tab.icon-btn--invert {
1365
+ border-color: transparent; background-color: transparent; box-shadow: none; color: inherit;
1366
+ }
1327
1367
  /* the last tab's ICON lines up with the inset, not its button's edge: an unchosen tab shows the glyph only, so the button's own
1328
1368
  margin around it reaches into the card's padding — and an open head (back · title · three tabs) keeps one line (Lh 2026-09-23) */
1329
1369
  .card__head > .stack-sheet__tabs { margin-right: calc(-1 * var(--sp-2)); }
@@ -4136,6 +4176,11 @@ button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
4136
4176
  0 1px 2px color-mix(in srgb, var(--_shade) 20%, transparent),
4137
4177
  0 0.5rem 1rem -0.25rem color-mix(in srgb, var(--accent) 45%, transparent);
4138
4178
  }
4179
+ /* a peeked stack tab is the icon alone: no sheen and no shadow until its sheet is open (the fill fades with the sheet) */
4180
+ [data-finish="lit"] .sheet-stack--tap > .card:not(.is-open) > .card__head .stack-sheet__tab {
4181
+ background-image: none;
4182
+ box-shadow: none;
4183
+ }
4139
4184
  [data-finish="lit"] :is(.btn:not(.btn--quiet), .icon-btn:not(.icon-btn-group > *, .stack-sheet__tab:not(.icon-btn--invert))):active:not(:disabled) {
4140
4185
  background-image: none;
4141
4186
  box-shadow: var(--finish-control-down);
@@ -11,6 +11,8 @@ export interface SheetStackProps extends ComponentProps<'div'> {
11
11
  onOpenChange?: (open: number | null) => void;
12
12
  /** the open sheet's scroller was already at the top and the finger pulled down past the commit. The stack asks with that index. It does not fetch. Omit it and a pull in the body only scrolls */
13
13
  onRefresh?: (index: number) => void;
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
+ refreshing?: number | null;
14
16
  /** wider peeks so more of each title shows (display stacks) */
15
17
  fanned?: boolean;
16
18
  /** 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 */
@@ -30,7 +32,7 @@ export interface SheetStackProps extends ComponentProps<'div'> {
30
32
  * A COMPONENT, not a wiring: WHICH sheet is open is the app's (`open` + `onOpenChange`, or `defaultOpen`) — so a button anywhere, a route or
31
33
  * the phone's back button can open and close a section. Its own behaviour stays inside and only asks: a tap on a peeked head, or a pull up on
32
34
  * that head (the title, the back button, or the head itself — not a tab), opens it. The open sheet's StackBack or its title, tapped or pulled
33
- * down, folds it. A pull down in the content, already at the top, asks `onRefresh` and does not fold. How the sheets MOVE is its own too: sheets are rigid — the tapped one slides up, the ones after it leave below, the ones
35
+ * down, folds it. A pull down in the content, already at the top, asks `onRefresh` and does not fold; `refreshing` fades that sheet's four grip dots up and down while the app reloads. How the sheets MOVE is its own too: sheets are rigid — the tapped one slides up, the ones after it leave below, the ones
34
36
  * before tuck under nearest-first; closing reverses in two beats. Only `translate` (stack.css); it sets classes, never a size.
35
37
  */
36
- export declare function SheetStack({ mode, open, defaultOpen, onOpenChange, onRefresh, fanned, closed, base, className, children, onClick, onKeyDown, ...rest }: SheetStackProps): import("react").JSX.Element;
38
+ export declare function SheetStack({ mode, open, defaultOpen, onOpenChange, onRefresh, refreshing, fanned, closed, base, className, children, onClick, onKeyDown, ...rest }: SheetStackProps): import("react").JSX.Element;
@@ -44,10 +44,10 @@ const pullsHead = (target, head) => {
44
44
  * A COMPONENT, not a wiring: WHICH sheet is open is the app's (`open` + `onOpenChange`, or `defaultOpen`) — so a button anywhere, a route or
45
45
  * the phone's back button can open and close a section. Its own behaviour stays inside and only asks: a tap on a peeked head, or a pull up on
46
46
  * that head (the title, the back button, or the head itself — not a tab), opens it. The open sheet's StackBack or its title, tapped or pulled
47
- * down, folds it. A pull down in the content, already at the top, asks `onRefresh` and does not fold. How the sheets MOVE is its own too: sheets are rigid — the tapped one slides up, the ones after it leave below, the ones
47
+ * down, folds it. A pull down in the content, already at the top, asks `onRefresh` and does not fold; `refreshing` fades that sheet's four grip dots up and down while the app reloads. How the sheets MOVE is its own too: sheets are rigid — the tapped one slides up, the ones after it leave below, the ones
48
48
  * before tuck under nearest-first; closing reverses in two beats. Only `translate` (stack.css); it sets classes, never a size.
49
49
  */
50
- export function SheetStack({ mode = 'tap', open, defaultOpen = null, onOpenChange, onRefresh, fanned, closed, base, className, children, onClick, onKeyDown, ...rest }) {
50
+ export function SheetStack({ mode = 'tap', open, defaultOpen = null, onOpenChange, onRefresh, refreshing = null, fanned, closed, base, className, children, onClick, onKeyDown, ...rest }) {
51
51
  const [current, request] = useControlled(open, defaultOpen, onOpenChange);
52
52
  const ref = useRef(null);
53
53
  const was = useRef(null);
@@ -56,6 +56,7 @@ export function SheetStack({ mode = 'tap', open, defaultOpen = null, onOpenChang
56
56
  const bodyPull = useRef(null);
57
57
  const swallow = useRef(false);
58
58
  const refreshRef = useLatest(onRefresh);
59
+ const loadingRef = useLatest(refreshing);
59
60
  /* paint the state onto the sheets — after every render, so a re-rendered Card never loses its place */
60
61
  useLayoutEffect(() => {
61
62
  const stack = ref.current;
@@ -74,6 +75,14 @@ export function SheetStack({ mode = 'tap', open, defaultOpen = null, onOpenChang
74
75
  if (title)
75
76
  title.tabIndex = current == null || i === current ? 0 : -1;
76
77
  });
78
+ sheets.forEach((c, i) => {
79
+ const loading = refreshing === i && current === i;
80
+ c.classList.toggle('is-loading', loading);
81
+ if (loading)
82
+ c.setAttribute('aria-busy', 'true');
83
+ else
84
+ c.removeAttribute('aria-busy');
85
+ });
77
86
  const prev = was.current;
78
87
  was.current = current;
79
88
  if (current != null) {
@@ -194,7 +203,7 @@ export function SheetStack({ mode = 'tap', open, defaultOpen = null, onOpenChang
194
203
  listen(onMove, onUp, onCancel);
195
204
  };
196
205
  const onBodyDown = (e) => {
197
- if (!primary(e) || headPull.current || bodyPull.current || !refreshRef.current)
206
+ if (!primary(e) || headPull.current || bodyPull.current || !refreshRef.current || loadingRef.current === index)
198
207
  return;
199
208
  if (scroller.scrollTop > 0)
200
209
  return;
@@ -262,9 +271,32 @@ export function SheetStack({ mode = 'tap', open, defaultOpen = null, onOpenChang
262
271
  };
263
272
  listen(onMove, onUp, onCancel);
264
273
  };
274
+ /* a FINGER: the browser takes a touch for its own scroll at the first move and cancels the pointer — unless that first move is
275
+ refused. So the first move decides, once: down and at the top = the pull's (refused, no scroll); anything else = the scroll's */
276
+ let touch = null;
277
+ const onTouchStart = (e) => {
278
+ const t = e.touches[0];
279
+ const inHead = e.target instanceof Element && !!e.target.closest('.card__head');
280
+ touch = e.touches.length === 1 && t && refreshRef.current && loadingRef.current !== index && scroller.scrollTop <= 0 && !inHead ? { x: t.clientX, y: t.clientY, ours: null } : null;
281
+ };
282
+ const onTouchMove = (e) => {
283
+ const t = e.touches[0];
284
+ if (!touch || !t)
285
+ return;
286
+ const dx = t.clientX - touch.x;
287
+ const dy = t.clientY - touch.y;
288
+ if (touch.ours == null)
289
+ touch.ours = dy > 0 && dy >= Math.abs(dx) && scroller.scrollTop <= 0;
290
+ if (touch.ours && e.cancelable)
291
+ e.preventDefault();
292
+ };
265
293
  head?.addEventListener('pointerdown', onHeadDown);
266
294
  scroller.addEventListener('pointerdown', onBodyDown);
295
+ scroller.addEventListener('touchstart', onTouchStart, { passive: true });
296
+ scroller.addEventListener('touchmove', onTouchMove, { passive: false });
267
297
  return () => {
298
+ scroller.removeEventListener('touchstart', onTouchStart);
299
+ scroller.removeEventListener('touchmove', onTouchMove);
268
300
  stopDoc();
269
301
  const held = headPull.current;
270
302
  headPull.current = null;
@@ -32,8 +32,9 @@ export interface StackSheetProps extends Omit<ComponentProps<'article'>, 'title'
32
32
  }
33
33
  /**
34
34
  * StackSheet — ONE sheet of a `SheetStack`, in the one sheet shape: SHEET HEAD (back · title · tabs or an icon) pinned at the top ·
35
- * SHEET BODY (the children; scrolls) · SHEET FOOT (`foot`, the CTA) at the bottom. Its tabs are icon buttons, the chosen one filled;
36
- * a count shows as a small circle over its icon. The body is the app's: render the page of the chosen tab (and nothing while the sheet
35
+ * SHEET BODY (the children; scrolls) · SHEET FOOT (`foot`, the CTA) at the bottom. Its tabs are icon buttons. The chosen one is filled
36
+ * only while this sheet is open — a closed home shows every icon plain; the disc fades in with the rise and out with the fold.
37
+ * A count shows as a small circle over its icon. The body is the app's: render the page of the chosen tab (and nothing while the sheet
37
38
  * is closed — see `Home`). A direct child of `SheetStack`.
38
39
  */
39
40
  export declare function StackSheet({ title, tone, icon, tabs, tab: tabProp, defaultTab, onTabChange, foot, backLabel, backAt, className, children, ...rest }: StackSheetProps): import("react").JSX.Element;
@@ -14,8 +14,9 @@ import { Badge } from '../numbers/Badge.js';
14
14
  import { word } from '../words.js';
15
15
  /**
16
16
  * StackSheet — ONE sheet of a `SheetStack`, in the one sheet shape: SHEET HEAD (back · title · tabs or an icon) pinned at the top ·
17
- * SHEET BODY (the children; scrolls) · SHEET FOOT (`foot`, the CTA) at the bottom. Its tabs are icon buttons, the chosen one filled;
18
- * a count shows as a small circle over its icon. The body is the app's: render the page of the chosen tab (and nothing while the sheet
17
+ * SHEET BODY (the children; scrolls) · SHEET FOOT (`foot`, the CTA) at the bottom. Its tabs are icon buttons. The chosen one is filled
18
+ * only while this sheet is open — a closed home shows every icon plain; the disc fades in with the rise and out with the fold.
19
+ * A count shows as a small circle over its icon. The body is the app's: render the page of the chosen tab (and nothing while the sheet
19
20
  * is closed — see `Home`). A direct child of `SheetStack`.
20
21
  */
21
22
  export function StackSheet({ title, tone, icon, tabs, tab: tabProp, defaultTab, onTabChange, foot, backLabel = word('back'), backAt = 'none', className, children, ...rest }) {
@@ -31,6 +31,8 @@ export interface HomeProps extends Omit<ComponentProps<typeof Screen>, 'children
31
31
  onOpenChange?: (open: number | null) => void;
32
32
  /** the open sheet's scroller was already at the top and the finger pulled down past the commit. The stack asks with that index. It does not fetch. Omit it and a pull in the body only scrolls */
33
33
  onRefresh?: (index: number) => void;
34
+ /** the sheet the app is reloading (its index), or `null` — as `SheetStack refreshing` */
35
+ refreshing?: number | null;
34
36
  /** CONTENT above the sheet heads — one `Page`: a welcome, the news, a picture. It reads
35
37
  * from the top; a sheet coming up covers it */
36
38
  base?: ReactNode;
@@ -50,4 +52,4 @@ export interface HomeProps extends Omit<ComponentProps<typeof Screen>, 'children
50
52
  * at the bottom). A tap on a head brings that sheet up to fill everything under the top. Only the open sheet's body is rendered — three
51
53
  * sections on one home never cost three pages (`keep` holds a body once opened).
52
54
  */
53
- export declare function Home({ top, title, sheets, open: openProp, defaultOpen, onOpenChange, onRefresh, base, keep, backLabel, backAt, children, ...rest }: HomeProps): import("react").JSX.Element;
55
+ export declare function Home({ top, title, sheets, open: openProp, defaultOpen, onOpenChange, onRefresh, refreshing, base, keep, backLabel, backAt, children, ...rest }: HomeProps): import("react").JSX.Element;
@@ -15,7 +15,7 @@ const LEAVE = 800; // ms: a closing sheet keeps its body until it has slid home
15
15
  * at the bottom). A tap on a head brings that sheet up to fill everything under the top. Only the open sheet's body is rendered — three
16
16
  * sections on one home never cost three pages (`keep` holds a body once opened).
17
17
  */
18
- export function Home({ top, title, sheets, open: openProp, defaultOpen = null, onOpenChange, onRefresh, base, keep, backLabel = word('back'), backAt, children, ...rest }) {
18
+ export function Home({ top, title, sheets, open: openProp, defaultOpen = null, onOpenChange, onRefresh, refreshing, base, keep, backLabel = word('back'), backAt, children, ...rest }) {
19
19
  const [open, change] = useControlled(openProp, defaultOpen, onOpenChange);
20
20
  const [leaving, setLeaving] = useState(null);
21
21
  const [seen, setSeen] = useState(() => new Set(open != null ? [open] : []));
@@ -35,5 +35,5 @@ export function Home({ top, title, sheets, open: openProp, defaultOpen = null, o
35
35
  }, [leaving]);
36
36
  const [tabs, setTabs] = useState(() => Object.fromEntries(sheets.map((x, i) => [i, x.defaultTab ?? x.tabs?.[0]?.id])));
37
37
  const shows = (i) => i === open || i === leaving || (keep && seen.has(i));
38
- return (_jsxs(Screen, { fill: true, top: top, ...rest, children: [_jsx(Text, { level: "h1", as: "h1", sr: true, children: title }), _jsx(SheetStack, { closed: true, open: open, onOpenChange: change, onRefresh: onRefresh, base: base, children: sheets.map((s, i) => (_jsx(StackSheet, { tone: s.tone ?? TONES[i], 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] }));
38
+ return (_jsxs(Screen, { fill: true, top: top, ...rest, children: [_jsx(Text, { level: "h1", as: "h1", sr: true, children: title }), _jsx(SheetStack, { closed: true, open: open, onOpenChange: change, onRefresh: onRefresh, refreshing: refreshing, base: base, children: sheets.map((s, i) => (_jsx(StackSheet, { tone: s.tone ?? TONES[i], 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] }));
39
39
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usableapp/cardds",
3
- "version": "0.7.51",
3
+ "version": "0.7.53",
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.",