@usableapp/cardds 0.7.52 → 0.7.54

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,24 @@
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.54 — 2026-10-06
6
+
7
+ ### Changed
8
+ - **A stacked sheet renders its own place.** `SheetStack` still takes `open` and `refreshing` as indexes: which sheet is open, and whether its data is in, stay yours. The card renders what those mean (`is-open`, `is-before`, `is-after`, `is-loading`, the grip, the pull). Nothing to change in a screen that already passes those two. Do not stamp those classes onto the card after it has rendered.
9
+ - **The open step's cursor mark comes from the field.** `Field`, `Pin` (its first cell), `Composer`, `FileBtn`, and `Slider` render `data-autofocus`, and `data-cursor` while the steps' holder is parked. A raw `<input>` written by hand in a step does not get the mark. Nothing to change when the step's first control is one of those.
10
+ - **`Slider` publishes `--slider-at` from the value it renders.** Nothing to change in a screen.
11
+
12
+ ## 0.7.53 — 2026-10-05
13
+
14
+ ### Fixed
15
+ - **`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.
16
+
17
+ ### Added
18
+ - **`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.
19
+
20
+ ### Changed
21
+ - **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.
22
+
5
23
  ## 0.7.52 — 2026-10-05
6
24
 
7
25
  ### Changed
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) — that sheet's grip dots dissolve white to black 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.
@@ -218,8 +220,9 @@ of `Screen`:
218
220
  - **Checked:** `validateScreen` (`scripts/validate.mjs`, run on every story and template by
219
221
  `tests/composition.spec.js`) fails a bar after `</Screen>` or a pinned bar, warns on a Sheet + ActionBar.
220
222
  - **Templates** are `Screen`s with the three parts filled (`Walkthrough` = top `lead` · content `Pages` · bottom
221
- `ActionBar`); the gallery draws those parts on a template's page. The gallery's nav groups the components by
222
- the part they fill: *top* · *content* · *bottom*.
223
+ `ActionBar`); the gallery draws those parts on a template's page. The gallery's nav groups a screen's
224
+ *top* and *bottom*, then each frame with the pieces that fill it: *card* (Card, its head, body and foot),
225
+ *sheet*, *stack*, *step*. A piece that also fits another frame says so on its page.
223
226
 
224
227
  ## Step markup
225
228
 
@@ -269,7 +272,10 @@ whose markup has rules, because the sheet's box is the step itself:
269
272
  **handle · head · body**: the handle, then a `.card__head` title row (that
270
273
  row is what `peek` shows — heading left, the contextual thing right), then
271
274
  `.sheet__body` with what the member acts on. The handle and the head are
272
- pinned; the body is the one thing that scrolls, to the card's own edges. A
275
+ pinned; the body is the one thing that scrolls, to the card's own edges. The
276
+ handle is a line, or four dots (`mark="dots"`): both sit in the card's top
277
+ padding, the same distance from the edge, so the title stays put. `paper` makes
278
+ the content white while the head keeps the sheet's colour (`tone`, as a card). A
273
279
  sheet with no head (the dim page sheet) is a body alone.
274
280
  - **The step follows its top bar directly, and touches it.** `full` means
275
281
  the sheet's top edge at the bar's bottom edge — nothing above it but the
@@ -530,9 +536,13 @@ Live: every screen in the gallery (`npm run dev`).
530
536
  get covered. Closing reverses it in two phases: the after-pack returns
531
537
  while the card drops into its slot, then the before-pack slides back up
532
538
  from behind. Slots, offsets and paint order are all CSS `:nth-child`, so
533
- a stack renders correctly with no script; `SheetStack` only sets the
534
- state classes. Don't reintroduce height, `display` or z-index switching
535
- here — every past bug in this animation came from exactly that.
539
+ a stack renders correctly with no script. The app owns which index is
540
+ open and whether that sheet's data is in. The card renders what those
541
+ mean (`is-open`, `is-before`, `is-after`, `is-loading`, the grip, the
542
+ pull) from the `stack` prop `SheetStack` passes it. Don't reintroduce
543
+ height, `display` or z-index switching here — every past bug in this
544
+ animation came from exactly that. Don't stamp those classes onto the
545
+ card after it has rendered.
536
546
  7. **Everything shrinks to 320px.** `.screen > *`, `.card` and `.card > *`
537
547
  carry `min-width: 0`, so no child's min-content (an input, a fixed row)
538
548
  can widen a card's grid track past its padding; topbar titles ellipsize;
@@ -627,7 +637,7 @@ That file is where a project's look lives. Bases and roles:
627
637
  |---|---|---|
628
638
  | room **between** things | `--sp-base` (0.25rem = 4) | every `--sp-N` = base × N → paddings, gaps, the screen's inset, the bar's reserve, the sheet's peek |
629
639
  | type | `--fs-base` (1.375rem = 22 — the body) | every `--fs-*`, `--badge`, `--icon-xs` |
630
- | corners | `--r-base` (1rem = 16) | every `--r-*`; `0` = square. `--r-chip` stays fully round |
640
+ | corners | `--r-base` (1.3rem = 20.8) | every `--r-*`; `0` = square. `--r-chip` stays fully round |
631
641
  | the size **of** things | `--tap-base` (3rem = 48) | `--tap*`, `--btn-h`, `--chip-h`, `--bar-h`, `--header-h`, `--fab`, `--icon*`, `--avatar*`, `--dot*`, `--ring*`, `--tile-*`. `--tap*` never under `--tap-floor` (44px) |
632
642
  | faces | `--font-ui`, `--font-display` | |
633
643
  | colour | `--color-surface`, `--color-ink`, `--color-paper`, `--color-brand`, `--color-on-brand` — no default: unset = the wireframe | every role: surface / words, the three card tones (paper · the brand washed 14% into the surface · the brand), the muted inks (the ink washed into their ground), accent, neutral actions, `--dot-a` |
package/css/sheet.css CHANGED
@@ -46,10 +46,10 @@
46
46
  ============================================================ */
47
47
 
48
48
  .sheet {
49
- /* the strip a peeked sheet shows: the card's own top padding, the handle,
50
- the gap, one title row (--tap — the head rule below makes every head at
51
- least that tall) and the gap under it, so the cut lands exactly where
52
- the first control starts and nothing of it leaks through. Published
49
+ /* the strip a peeked sheet shows: the card's own top padding (the handle
50
+ sits inside it), one title row (--tap — the head rule below makes every
51
+ head at least that tall) and the gap under it, so the cut lands exactly
52
+ where the first control starts and nothing of it leaks through. Published
53
53
  (and registered in tokens.css) so a sheet whose head is taller can
54
54
  retune it: .my-sheet { --sheet-peek: … } */
55
55
  --sheet-half: 0.5; /* a share of the sheet's own height */
@@ -90,7 +90,7 @@
90
90
  /* --sheet-peek is declared on the BOX the sheet slides in, not on the sheet: the words behind a peeked sheet
91
91
  have to read it too (step.css keeps that much room under them), and a token only flows down. The sheet
92
92
  inherits it. A sheet whose head is taller retunes it on the box. */
93
- :is(.step, .sheet-stage) { --_handle: var(--sp-1); --sheet-peek: calc(var(--card-pad-rest) + var(--_handle) + 2 * var(--card-gap-rest) + var(--sheet-head-h) + var(--safe-bottom)); } /* + the gesture bar: the peeked head stands above it */
93
+ :is(.step, .sheet-stage) { --_handle: var(--sp-0); --sheet-peek: calc(var(--card-pad-rest) + var(--sheet-head-h) + var(--card-gap-rest) + var(--safe-bottom)); } /* + the gesture bar: the peeked head stands above it. The handle sits IN the card's top padding, so the strip is that padding, the title row and the gap under it */
94
94
  /* the head's floor: a tap, or an h2 line with its Thai-mark padding when the type is bigger than that (a large text-size
95
95
  setting on a narrow phone) — the strip and the head read the same number, so the title row is always inside the strip */
96
96
  :root { --sheet-head-h: max(var(--tap), calc(var(--fs-h2) * 1.25 + var(--sp-2))); }
@@ -174,25 +174,55 @@
174
174
  .sheet__body > * { min-width: 0; } /* as .card > *: children shrink to the track, never widen it */
175
175
  .sheet__body:last-child { margin-bottom: calc(-1 * var(--card-pad)); padding-bottom: calc(var(--card-pad) + var(--_cover) + var(--_safe-b-open)); }
176
176
 
177
- /* grab handle — first child of the sheet. Drawn as a small bar, but it is
178
- the thumb's grab area for the whole strip above the title: a pseudo widens
179
- its hit box to --tap tall and wider than the bar without changing the
180
- picture. touch-action: none hands a touch drag to the Sheet component instead of to
181
- the scroller behind it. */
177
+ /* grab handle — first child of the sheet, but it does not take a row: it sits in the
178
+ card's top padding, the same air as a stack sheet's four dots (stack.css --_grip-y),
179
+ so a line and the dots leave the title in the same place (Lh 2026-10-05). The line
180
+ is the default; .sheet__handle--dots is the four dots. Either way it is the thumb's
181
+ grab: a pseudo widens its hit box to --tap tall and wider than the mark.
182
+ touch-action: none hands a touch drag to the Sheet component instead of to the scroller. */
182
183
  .sheet__handle {
183
- position: relative;
184
+ position: absolute;
185
+ z-index: 1;
186
+ top: calc((var(--card-pad) - var(--sp-1)) / 2); /* the dots' top: (pad − 4) / 2. The line starts there too */
187
+ left: 50%;
188
+ translate: -50% 0;
184
189
  border: 0;
185
- color: inherit; /* a <button> has its own UA colour; the bar is the card's ink */
190
+ color: inherit; /* a <button> has its own UA colour; the mark is the card's ink */
186
191
  background: currentColor;
187
192
  opacity: 0.3;
188
- width: var(--tap-sm);
193
+ width: calc(var(--sp-base) * 18); /* 72: the line. The grab is the ::before, not this width */
189
194
  height: var(--_handle);
190
195
  border-radius: var(--sheet-handle-radius, var(--r-chip));
191
- margin: 0 auto;
196
+ margin: 0;
192
197
  padding: 0;
193
198
  touch-action: none;
194
199
  cursor: grab;
195
200
  }
201
+ /* paper: the head keeps the sheet's own colour, the content is white (Lh 2026-10-05).
202
+ The body and the foot read as paper; the handle and the head stay on the card. */
203
+ .sheet--paper > .sheet__body {
204
+ --card-bg: var(--color-paper, #fff);
205
+ --card-ink: var(--color-ink);
206
+ --card-muted: var(--_muted-on-paper);
207
+ background: var(--card-bg);
208
+ color: var(--card-ink);
209
+ }
210
+ .sheet--paper > .card__foot { --card-bg: var(--color-paper, #fff); color: var(--color-ink); }
211
+ .sheet__handle--dots {
212
+ --_dot: var(--sp-1);
213
+ --_step: calc(var(--_dot) * 2.5);
214
+ --_ink: color-mix(in srgb, currentColor 35%, transparent);
215
+ --_img: radial-gradient(circle closest-side, var(--_ink) 85%, transparent);
216
+ width: calc(3 * var(--_step) + var(--_dot));
217
+ height: var(--_dot);
218
+ opacity: 1;
219
+ border-radius: 0;
220
+ background-color: transparent;
221
+ background-image: var(--_img), var(--_img), var(--_img), var(--_img);
222
+ background-repeat: no-repeat;
223
+ background-size: var(--_dot) var(--_dot);
224
+ background-position: 0 0, var(--_step) 0, calc(2 * var(--_step)) 0, calc(3 * var(--_step)) 0;
225
+ }
196
226
  .sheet__handle::before {
197
227
  content: "";
198
228
  position: absolute;
package/css/stack.css CHANGED
@@ -162,7 +162,7 @@
162
162
  overflow: clip;
163
163
  transition: translate var(--motion-sheet) ease; /* the only animated property here */
164
164
  transition-delay: calc(var(--stagger, 0) * 70ms); /* 0 for everything
165
- except a staggered is-before card, set in JS as it tucks or un-tucks */
165
+ except a staggered is-before card: the card renders --stagger as it tucks or un-tucks */
166
166
  --_back-size: var(--stack-back-size, calc(var(--tap-base) * 2 / 3)); /* read once, shared by the back button and the head's padding below */
167
167
  }
168
168
  .sheet-stack--tap > .card.is-open {
@@ -183,6 +183,34 @@
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 dots at the middle of every sheet's top edge — 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). The card renders `.stack-card` when it is a sheet of a tap stack, so the dots are its own.
189
+ At rest they are the card's own ink, faint. Only while THAT sheet reloads (`.is-loading`, from its `refreshing` prop,
190
+ and only while it is open) they dissolve white → black, so a light card and a dark one each get a moment the dots stand out.
191
+ Nothing travels. --_grip is the mix, 0 white → 1 black, private. */
192
+ @property --_grip { syntax: "<number>"; inherits: false; initial-value: 0; }
193
+ .stack-card {
194
+ --_grip-dot: var(--sp-1);
195
+ --_grip-step: calc(var(--sp-1) * 2.5);
196
+ --_grip-y: calc((var(--card-pad) - var(--_grip-dot)) / 2);
197
+ --_grip-ink: color-mix(in srgb, currentColor 35%, transparent);
198
+ --_grip-img: radial-gradient(circle closest-side, var(--_grip-ink) 85%, transparent);
199
+ background-image: var(--_grip-img), var(--_grip-img), var(--_grip-img), var(--_grip-img);
200
+ background-repeat: no-repeat;
201
+ background-size: var(--_grip-dot) var(--_grip-dot);
202
+ background-position:
203
+ calc(50% - 1.5 * var(--_grip-step)) var(--_grip-y), calc(50% - 0.5 * var(--_grip-step)) var(--_grip-y),
204
+ calc(50% + 0.5 * var(--_grip-step)) var(--_grip-y), calc(50% + 1.5 * var(--_grip-step)) var(--_grip-y);
205
+ }
206
+ .stack-card.is-open.is-loading {
207
+ --_grip-ink: color-mix(in srgb, black calc(var(--_grip) * 100%), white);
208
+ animation: stack-grip-dissolve 0.4s ease-in-out infinite alternate;
209
+ }
210
+ @keyframes stack-grip-dissolve { from { --_grip: 0; } to { --_grip: 1; } }
211
+ @media (prefers-reduced-motion: reduce) {
212
+ .stack-card.is-open.is-loading { animation: none; --_grip-ink: currentColor; }
213
+ }
186
214
  /* the front sheet — the one open on arrival — never travels up (it is not
187
215
  tappable), so its resting offset would leave its last n steps below the
188
216
  screen forever, unreachable by any scroll (timebank home, 2026-09-13). It
@@ -24,7 +24,7 @@
24
24
  /* ---- size: four bases, the multipliers stay the system's ---- */
25
25
  --sp-base: 0.25rem; /* 4 the room BETWEEN things: every --sp-N = base × N, so paddings and gaps */
26
26
  --fs-base: 1.375rem; /* 22 the type — the body size; every --fs-* is a multiplier of it (17 · 22 · 26 · 32 · 40 · 55 · 64), the count badge, the inline icon */
27
- --r-base: 1rem; /* 16 the corners: every --r-* (0 = square; chips and buttons stay fully round) */
27
+ --r-base: 1.3rem; /* 20.8 the corners: every --r-* (0 = square; chips and buttons stay fully round) */
28
28
  --tap-base: 3rem; /* 48 the size of THINGS: touch targets, control heights, icons, avatars, ring, tiles
29
29
  (a touch target never goes under 44px, whatever this says) */
30
30
  /* a member's text-size setting is this same --fs-base, written on <html> by the app at run time (1.375rem = 100%, 2.2rem = 160%) — the words grow, the things keep their size */
package/css/tokens.css CHANGED
@@ -24,7 +24,7 @@
24
24
  --sp-base: 0.25rem; /* 4 spacing: --sp-N = base × N */
25
25
  --fs-base: 1.375rem; /* 22 type: the BODY is the base (Lh 2026-09-22) — every --fs-* is a multiplier of it; nothing under 0.68 (15, minor links — was 0.775/17 until 2026-09-23). A new size is Lh's call only.
26
26
  Also the member's text-size setting: the app writes it on <html> at run time (2.2rem = 160%) — words grow, things don't */
27
- --r-base: 1rem; /* 16 radius: --r-* (--r-chip stays fully round) */
27
+ --r-base: 1.3rem; /* 20.8 radius, 130% of 1rem (Lh 2026-10-05): --r-* (--r-chip stays fully round) */
28
28
  --tap-base: 3rem; /* 48 the size of THINGS: touch targets and control heights (--tap*, --btn-h, --chip-h, --bar-h,
29
29
  --header-h, --fab), icons, avatars, dots, the ring and the tiles. --sp-base is the room BETWEEN things.
30
30
  Text-holders follow the type instead: --badge and --icon-xs (inline with captions) are on --fs-base.
@@ -79,10 +79,10 @@
79
79
 
80
80
  /* ---- radius ---- */
81
81
  --r-chip: 999px; /* pills, chips, buttons — always fully round */
82
- --r-card: calc(var(--r-base) * 2); /* 32 the one card radius: every card, row, tile, menu, sheet, fab */
83
- --r-media: var(--r-base); /* 16 images inside cards */
84
- --r-control: calc(var(--r-base) * 0.875); /* 14 fields, thumbs, menu rows */
85
- --r-badge: calc(var(--r-base) * 0.625); /* 10 tile badge, bars */
82
+ --r-card: calc(var(--r-base) * 2); /* 41.6 the one card radius: every card, row, tile, menu, sheet, fab */
83
+ --r-media: var(--r-base); /* 20.8 images inside cards */
84
+ --r-control: calc(var(--r-base) * 0.875); /* 18.2 fields, thumbs, menu rows */
85
+ --r-badge: calc(var(--r-base) * 0.625); /* 13 tile badge, bars */
86
86
 
87
87
  /* ---- borders / rules / elevation (px on purpose: crisp at any scale) ---- */
88
88
  --border-w: 1.5px;
package/dist/cardds.css CHANGED
@@ -65,7 +65,7 @@
65
65
  --sp-base: 0.25rem; /* 4 spacing: --sp-N = base × N */
66
66
  --fs-base: 1.375rem; /* 22 type: the BODY is the base (Lh 2026-09-22) — every --fs-* is a multiplier of it; nothing under 0.68 (15, minor links — was 0.775/17 until 2026-09-23). A new size is Lh's call only.
67
67
  Also the member's text-size setting: the app writes it on <html> at run time (2.2rem = 160%) — words grow, things don't */
68
- --r-base: 1rem; /* 16 radius: --r-* (--r-chip stays fully round) */
68
+ --r-base: 1.3rem; /* 20.8 radius, 130% of 1rem (Lh 2026-10-05): --r-* (--r-chip stays fully round) */
69
69
  --tap-base: 3rem; /* 48 the size of THINGS: touch targets and control heights (--tap*, --btn-h, --chip-h, --bar-h,
70
70
  --header-h, --fab), icons, avatars, dots, the ring and the tiles. --sp-base is the room BETWEEN things.
71
71
  Text-holders follow the type instead: --badge and --icon-xs (inline with captions) are on --fs-base.
@@ -120,10 +120,10 @@
120
120
 
121
121
  /* ---- radius ---- */
122
122
  --r-chip: 999px; /* pills, chips, buttons — always fully round */
123
- --r-card: calc(var(--r-base) * 2); /* 32 the one card radius: every card, row, tile, menu, sheet, fab */
124
- --r-media: var(--r-base); /* 16 images inside cards */
125
- --r-control: calc(var(--r-base) * 0.875); /* 14 fields, thumbs, menu rows */
126
- --r-badge: calc(var(--r-base) * 0.625); /* 10 tile badge, bars */
123
+ --r-card: calc(var(--r-base) * 2); /* 41.6 the one card radius: every card, row, tile, menu, sheet, fab */
124
+ --r-media: var(--r-base); /* 20.8 images inside cards */
125
+ --r-control: calc(var(--r-base) * 0.875); /* 18.2 fields, thumbs, menu rows */
126
+ --r-badge: calc(var(--r-base) * 0.625); /* 13 tile badge, bars */
127
127
 
128
128
  /* ---- borders / rules / elevation (px on purpose: crisp at any scale) ---- */
129
129
  --border-w: 1.5px;
@@ -1163,7 +1163,7 @@ button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
1163
1163
  overflow: clip;
1164
1164
  transition: translate var(--motion-sheet) ease; /* the only animated property here */
1165
1165
  transition-delay: calc(var(--stagger, 0) * 70ms); /* 0 for everything
1166
- except a staggered is-before card, set in JS as it tucks or un-tucks */
1166
+ except a staggered is-before card: the card renders --stagger as it tucks or un-tucks */
1167
1167
  --_back-size: var(--stack-back-size, calc(var(--tap-base) * 2 / 3)); /* read once, shared by the back button and the head's padding below */
1168
1168
  }
1169
1169
  .sheet-stack--tap > .card.is-open {
@@ -1184,6 +1184,34 @@ 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 dots at the middle of every sheet's top edge — 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). The card renders `.stack-card` when it is a sheet of a tap stack, so the dots are its own.
1190
+ At rest they are the card's own ink, faint. Only while THAT sheet reloads (`.is-loading`, from its `refreshing` prop,
1191
+ and only while it is open) they dissolve white → black, so a light card and a dark one each get a moment the dots stand out.
1192
+ Nothing travels. --_grip is the mix, 0 white → 1 black, private. */
1193
+ @property --_grip { syntax: "<number>"; inherits: false; initial-value: 0; }
1194
+ .stack-card {
1195
+ --_grip-dot: var(--sp-1);
1196
+ --_grip-step: calc(var(--sp-1) * 2.5);
1197
+ --_grip-y: calc((var(--card-pad) - var(--_grip-dot)) / 2);
1198
+ --_grip-ink: color-mix(in srgb, currentColor 35%, transparent);
1199
+ --_grip-img: radial-gradient(circle closest-side, var(--_grip-ink) 85%, transparent);
1200
+ background-image: var(--_grip-img), var(--_grip-img), var(--_grip-img), var(--_grip-img);
1201
+ background-repeat: no-repeat;
1202
+ background-size: var(--_grip-dot) var(--_grip-dot);
1203
+ background-position:
1204
+ calc(50% - 1.5 * var(--_grip-step)) var(--_grip-y), calc(50% - 0.5 * var(--_grip-step)) var(--_grip-y),
1205
+ calc(50% + 0.5 * var(--_grip-step)) var(--_grip-y), calc(50% + 1.5 * var(--_grip-step)) var(--_grip-y);
1206
+ }
1207
+ .stack-card.is-open.is-loading {
1208
+ --_grip-ink: color-mix(in srgb, black calc(var(--_grip) * 100%), white);
1209
+ animation: stack-grip-dissolve 0.4s ease-in-out infinite alternate;
1210
+ }
1211
+ @keyframes stack-grip-dissolve { from { --_grip: 0; } to { --_grip: 1; } }
1212
+ @media (prefers-reduced-motion: reduce) {
1213
+ .stack-card.is-open.is-loading { animation: none; --_grip-ink: currentColor; }
1214
+ }
1187
1215
  /* the front sheet — the one open on arrival — never travels up (it is not
1188
1216
  tappable), so its resting offset would leave its last n steps below the
1189
1217
  screen forever, unreachable by any scroll (timebank home, 2026-09-13). It
@@ -1414,10 +1442,10 @@ button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
1414
1442
  ============================================================ */
1415
1443
 
1416
1444
  .sheet {
1417
- /* the strip a peeked sheet shows: the card's own top padding, the handle,
1418
- the gap, one title row (--tap — the head rule below makes every head at
1419
- least that tall) and the gap under it, so the cut lands exactly where
1420
- the first control starts and nothing of it leaks through. Published
1445
+ /* the strip a peeked sheet shows: the card's own top padding (the handle
1446
+ sits inside it), one title row (--tap — the head rule below makes every
1447
+ head at least that tall) and the gap under it, so the cut lands exactly
1448
+ where the first control starts and nothing of it leaks through. Published
1421
1449
  (and registered in tokens.css) so a sheet whose head is taller can
1422
1450
  retune it: .my-sheet { --sheet-peek: … } */
1423
1451
  --sheet-half: 0.5; /* a share of the sheet's own height */
@@ -1458,7 +1486,7 @@ button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
1458
1486
  /* --sheet-peek is declared on the BOX the sheet slides in, not on the sheet: the words behind a peeked sheet
1459
1487
  have to read it too (step.css keeps that much room under them), and a token only flows down. The sheet
1460
1488
  inherits it. A sheet whose head is taller retunes it on the box. */
1461
- :is(.step, .sheet-stage) { --_handle: var(--sp-1); --sheet-peek: calc(var(--card-pad-rest) + var(--_handle) + 2 * var(--card-gap-rest) + var(--sheet-head-h) + var(--safe-bottom)); } /* + the gesture bar: the peeked head stands above it */
1489
+ :is(.step, .sheet-stage) { --_handle: var(--sp-0); --sheet-peek: calc(var(--card-pad-rest) + var(--sheet-head-h) + var(--card-gap-rest) + var(--safe-bottom)); } /* + the gesture bar: the peeked head stands above it. The handle sits IN the card's top padding, so the strip is that padding, the title row and the gap under it */
1462
1490
  /* the head's floor: a tap, or an h2 line with its Thai-mark padding when the type is bigger than that (a large text-size
1463
1491
  setting on a narrow phone) — the strip and the head read the same number, so the title row is always inside the strip */
1464
1492
  :root { --sheet-head-h: max(var(--tap), calc(var(--fs-h2) * 1.25 + var(--sp-2))); }
@@ -1542,25 +1570,55 @@ button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
1542
1570
  .sheet__body > * { min-width: 0; } /* as .card > *: children shrink to the track, never widen it */
1543
1571
  .sheet__body:last-child { margin-bottom: calc(-1 * var(--card-pad)); padding-bottom: calc(var(--card-pad) + var(--_cover) + var(--_safe-b-open)); }
1544
1572
 
1545
- /* grab handle — first child of the sheet. Drawn as a small bar, but it is
1546
- the thumb's grab area for the whole strip above the title: a pseudo widens
1547
- its hit box to --tap tall and wider than the bar without changing the
1548
- picture. touch-action: none hands a touch drag to the Sheet component instead of to
1549
- the scroller behind it. */
1573
+ /* grab handle — first child of the sheet, but it does not take a row: it sits in the
1574
+ card's top padding, the same air as a stack sheet's four dots (stack.css --_grip-y),
1575
+ so a line and the dots leave the title in the same place (Lh 2026-10-05). The line
1576
+ is the default; .sheet__handle--dots is the four dots. Either way it is the thumb's
1577
+ grab: a pseudo widens its hit box to --tap tall and wider than the mark.
1578
+ touch-action: none hands a touch drag to the Sheet component instead of to the scroller. */
1550
1579
  .sheet__handle {
1551
- position: relative;
1580
+ position: absolute;
1581
+ z-index: 1;
1582
+ top: calc((var(--card-pad) - var(--sp-1)) / 2); /* the dots' top: (pad − 4) / 2. The line starts there too */
1583
+ left: 50%;
1584
+ translate: -50% 0;
1552
1585
  border: 0;
1553
- color: inherit; /* a <button> has its own UA colour; the bar is the card's ink */
1586
+ color: inherit; /* a <button> has its own UA colour; the mark is the card's ink */
1554
1587
  background: currentColor;
1555
1588
  opacity: 0.3;
1556
- width: var(--tap-sm);
1589
+ width: calc(var(--sp-base) * 18); /* 72: the line. The grab is the ::before, not this width */
1557
1590
  height: var(--_handle);
1558
1591
  border-radius: var(--sheet-handle-radius, var(--r-chip));
1559
- margin: 0 auto;
1592
+ margin: 0;
1560
1593
  padding: 0;
1561
1594
  touch-action: none;
1562
1595
  cursor: grab;
1563
1596
  }
1597
+ /* paper: the head keeps the sheet's own colour, the content is white (Lh 2026-10-05).
1598
+ The body and the foot read as paper; the handle and the head stay on the card. */
1599
+ .sheet--paper > .sheet__body {
1600
+ --card-bg: var(--color-paper, #fff);
1601
+ --card-ink: var(--color-ink);
1602
+ --card-muted: var(--_muted-on-paper);
1603
+ background: var(--card-bg);
1604
+ color: var(--card-ink);
1605
+ }
1606
+ .sheet--paper > .card__foot { --card-bg: var(--color-paper, #fff); color: var(--color-ink); }
1607
+ .sheet__handle--dots {
1608
+ --_dot: var(--sp-1);
1609
+ --_step: calc(var(--_dot) * 2.5);
1610
+ --_ink: color-mix(in srgb, currentColor 35%, transparent);
1611
+ --_img: radial-gradient(circle closest-side, var(--_ink) 85%, transparent);
1612
+ width: calc(3 * var(--_step) + var(--_dot));
1613
+ height: var(--_dot);
1614
+ opacity: 1;
1615
+ border-radius: 0;
1616
+ background-color: transparent;
1617
+ background-image: var(--_img), var(--_img), var(--_img), var(--_img);
1618
+ background-repeat: no-repeat;
1619
+ background-size: var(--_dot) var(--_dot);
1620
+ background-position: 0 0, var(--_step) 0, calc(2 * var(--_step)) 0, calc(3 * var(--_step)) 0;
1621
+ }
1564
1622
  .sheet__handle::before {
1565
1623
  content: "";
1566
1624
  position: absolute;