@usableapp/cardds 0.4.0 → 0.4.1

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/README.md CHANGED
@@ -105,7 +105,7 @@ the card is the material, the sheet is what it is in the stack.
105
105
  <div class="sheet-stack sheet-stack--tap"> <!-- drop --tap for a static stack -->
106
106
  <article class="card card--2">
107
107
  <div class="card__head">
108
- <button class="icon-btn icon-btn--sm card__back" aria-label="Back">…</button>
108
+ <button class="icon-btn icon-btn--sm sheet-stack__back" aria-label="Back">…</button>
109
109
  <h2 class="t-h2 t-caps">Shopping</h2>
110
110
  <span class="icon-row">…</span> <!-- optional trailing icons -->
111
111
  </div>
@@ -125,7 +125,7 @@ the card is the material, the sheet is what it is in the stack.
125
125
  A tap opens any sheet over it; back drops it home.
126
126
  - **Five sheets max.** Slots are declared per `:nth-child` in `stack.css`; a
127
127
  sixth would render at slot 0, on top of the first.
128
- - **Every head needs its own `.card__back`** if the stack is `--tap`. It stays
128
+ - **Every head needs its own `.sheet-stack__back`** if the stack is `--tap`. It stays
129
129
  hidden until that sheet is open, then slides into a reserved header slot.
130
130
  - **No ids, no wiring.** `cardds.js` finds any `.sheet-stack--tap` on the page.
131
131
  - **`.card-stack` is gone.** That was this component's first name and the wrong
@@ -433,7 +433,7 @@ Demo: the *skeleton* block at the top of `elements.html`.
433
433
  padding. Two boxes hold sheets and the states
434
434
  mean the same in both: `.step` (an app screen, the bar outside — Anatomy)
435
435
  and `.sheet-stage` (a page-level overlay: a sheet over a map, the dim page
436
- sheet `--dim` + `--raised`, the ask `--ask` + `.modal` = `__lift` + `__drawer`).
436
+ stage `--dim` + `.modal` = `__lift` + `__drawer` — a sheet always stands on the floor, never raised).
437
437
  A stage keeps the top bar's line free (`--_top`), so `--full` stops under
438
438
  `--header-h` and keeps its corners. Both sheet tokens are published — a
439
439
  sheet with a taller head retunes `--sheet-peek` — and registered in
package/css/layover.css CHANGED
@@ -5,8 +5,8 @@
5
5
  lives in sheet.css; these compose with .sheet-stage.
6
6
  ============================================================ */
7
7
 
8
- /* ---- ask: the Modal — the case drops in, its drawer opens below (Lh 2026-09-16: ONE card, two parts)
9
- <div class="sheet-stage sheet-stage--ask">
8
+ /* ---- the Modal — the case drops in, its drawer opens below (Lh 2026-09-16: ONE card, two parts)
9
+ <div class="sheet-stage sheet-stage--dim"> (dim = the page under a scrim; a Modal on a clean ground is the stage without it — Lh 2026-09-22, `--ask` retired: the stage does the ground, the modal centres itself)
10
10
  <article class="card modal">
11
11
  <div class="modal__lift">…the card's content (a head, rows, a timeline)…</div>
12
12
  <div class="modal__drawer"><h2 class="t-h2">Take the case?</h2><p class="t-caption">This cannot be undone.</p>
@@ -17,30 +17,27 @@
17
17
  .modal is the card — no border (the shadow is its edge on the scrim);
18
18
  .modal__lift the light part that holds the thing being asked about (it
19
19
  scrolls when tall); .modal__drawer the dark end that asks. The ground is
20
- the page under a scrim (like --dim). Two beats (2 × --motion-sheet) when
20
+ whatever the stage shows (dim it or not). Two beats (2 × --motion-sheet) when
21
21
  the stage appears, and the same two backwards when it closes:
22
22
  1. the card drops in from above the stage to the screen's centre — its
23
23
  drawer still inside it, height 0: nothing of the ask shows yet
24
24
  2. the drawer comes out of the card's bottom edge while the lift moves
25
25
  up, both at once: the card grows from its centre, which never moves
26
- (the stage centres it; a growing card keeps its middle where it is)
26
+ (the modal centres itself — absolute, margin auto; a growing card keeps its middle where it is)
27
27
  Closing: a click on any button in the drawer plays the same animations in
28
28
  reverse (cardds.js: Animation.reverse() on each — nothing duplicated), then
29
29
  fires "cardds:modal" on the .sheet-stage (bubbles, detail.answer from the
30
30
  button's data-answer) for the app to remove the stage.
31
31
  Reduced motion: the finished state, no beats; closing fires at once. */
32
- .sheet-stage--ask {
33
- --drawer-h: 14rem;
34
- display: grid;
35
- align-content: center; /* the card at the screen's centre — and kept there as it grows */
36
- padding-inline: var(--screen-pad);
37
- }
38
- .sheet-stage--ask::after { content: ""; position: absolute; inset: 0; background: var(--scrim); } /* the page dimmed */
39
- .sheet-stage--ask > .sheet-stage__bg { filter: blur(var(--sp-2)); }
40
32
  .modal {
41
- position: relative;
42
- z-index: 1; /* over the scrim */
43
- max-height: calc(100cqh - 2 * var(--sp-5)); /* cqh: the stage (a size container) — a % here would be of the grid area, which is the card itself */
33
+ --drawer-h: 14rem;
34
+ position: absolute; /* at the stage's centre, and kept there as it grows: absolute + margin auto re-centres on every height */
35
+ inset: 0;
36
+ margin: auto;
37
+ height: fit-content; /* inset 0 alone would stretch it to the stage: its own height, then the margins share the rest */
38
+ width: calc(100% - 2 * var(--screen-pad));
39
+ z-index: 2; /* over the dim stage's scrim (z 1) */
40
+ max-height: calc(100cqh - 2 * var(--sp-5)); /* cqh: the stage (a size container) */
44
41
  padding: 0; /* the parts pad themselves */
45
42
  border: 0; /* no frame on the modal (Lh 2026-09-16) — its edge is the shadow */
46
43
  display: grid;
package/css/lists.css CHANGED
@@ -36,6 +36,15 @@
36
36
  .row__end { margin-left: auto; text-align: right; display: grid; gap: var(--row-end-gap, var(--sp-0)); justify-items: end; flex: none; }
37
37
  .row--lg { min-height: var(--row-lg-min-h, var(--fab)); } /* menu rows: a taller target, round arrow at the end; .rows gives them more air */
38
38
  .row--lg .row__title { font: 700 var(--fs-lg)/1.2 var(--font-display); }
39
+ /* a row that EXPANDS (Lh 2026-09-22): the row is a <details>' summary, what it shows comes out under it —
40
+ who liked a postcard, the rest of a list. An end chevron turns when open. The app owns the list.
41
+ <details class="row-expand"><summary class="row">…lead · body · end…</summary><div class="row-expand__more">…</div></details> */
42
+ .row-expand { display: block; }
43
+ .row-expand > summary { list-style: none; cursor: pointer; }
44
+ .row-expand > summary::-webkit-details-marker { display: none; }
45
+ .row-expand > summary > .icon:last-child { transition: rotate var(--motion-slide); }
46
+ .row-expand[open] > summary > .icon:last-child { rotate: 180deg; }
47
+ .row-expand__more { padding-top: var(--row-expand-gap, var(--sp-3)); }
39
48
  /* a finished item in a checklist (a to-do list, an onboarding "complete your introduction" list):
40
49
  the words go muted, a filled tick sits at the end, and the row is no longer a tap target —
41
50
  pointer-events off here, aria-disabled from the markup. The tick is the .check's checked look
package/css/media.css CHANGED
@@ -152,59 +152,75 @@
152
152
  .mosaic > .ph { opacity: 0.4; border-width: var(--border-w); }
153
153
  .mosaic > img { width: 100%; height: 100%; object-fit: cover; }
154
154
 
155
- /* ---- postcard: a member's introduction, a card in the hand — photo in front,
156
- words on the back; tap to turn it over (Lh, 2026-09-12; card #63) ----
155
+ /* ---- postcard: a member's introduction in the shape of a cover card — the photo fills it,
156
+ the band at the bottom names it; tap the photo to turn it over (Lh 2026-09-12; reshaped 2026-09-22) ----
157
157
  <div class="postcard">
158
- <button class="postcard__face postcard__front" type="button" aria-label="พลิกดูด้านหลัง">
159
- <img src="…" alt=""> — or <div class="ph"></div> —
160
- <span class="postcard__label">ป้าแจ๋ว</span>
161
- </button>
162
- <button class="postcard__face postcard__back" type="button" aria-label="พลิกดูด้านหน้า">
163
- <p class="postcard__caption">ชอบชงกาแฟดริปตอนเช้า</p>
164
- <span class="postcard__label">ป้าแจ๋ว</span>
165
- </button>
158
+ <div class="postcard__face postcard__front">
159
+ <button class="postcard__turn" type="button" aria-label="พลิกดูด้านหลัง"><img src="…" alt=""> — or <div class="ph"></div> —</button>
160
+ <div class="postcard__band">
161
+ <div class="postcard__words"><span class="t-h1">ป้าแจ๋ว</span><span class="t-body">บ้านสวนผัก</span></div>
162
+ <button class="icon-btn icon-btn--invert" aria-label="ถูกใจ" aria-pressed="false">♥</button>
163
+ </div>
164
+ </div>
165
+ <div class="postcard__face postcard__back">
166
+ <button class="postcard__turn" type="button" aria-label="พลิกดูด้านหน้า">
167
+ <div class="postcard__words">…</div><p class="postcard__caption">ชอบชงกาแฟดริปตอนเช้า</p>
168
+ </button>
169
+ <div class="postcard__voice"><div class="progress"></div><span class="t-caption">0:07 / 0:12</span><button class="icon-btn icon-btn--invert" aria-label="ฟัง">▶</button></div> (a voice note: no caption — the line, its time, the control under them, at the middle)
170
+ </div>
166
171
  </div>
167
- Each face is the whole tap target; cardds.js toggles .is-flipped on the
168
- postcard (either face, Enter/Space too). A voice note, a like, a reply live
169
- OUTSIDE the postcard (a Row under it): a face is a button, and a button
170
- holds no button. The faces share one box (a 3:2 landscape card, the media
171
- radius); the turn is a Y rotation at --motion-flip, a plain swap under
172
- prefers-reduced-motion. Reads ink from the card slot it sits in. */
172
+ The two faces share one box and turn (a Y rotation at --motion-flip, a plain
173
+ swap under prefers-reduced-motion). A face is a div: the TURN is the button
174
+ that fills it (the photo, or the caption), the band lies over its foot and
175
+ turns with it — words and the heart; the back sets the name a fifth of the way down, the words after it; a
176
+ button in the band is never inside the turn button. The size is the cover
177
+ card's (--postcard-min-h = the cover's min-height, the card radius). Who
178
+ liked it, a voice note, a reply live OUTSIDE the postcard, in a Row under it. */
173
179
  .postcard {
174
180
  position: relative;
175
- aspect-ratio: 3 / 2;
181
+ min-height: var(--postcard-min-h, 22.5rem);
176
182
  perspective: 60rem;
177
- border-radius: var(--postcard-radius, var(--r-media));
183
+ border-radius: var(--postcard-radius, var(--r-card));
184
+ isolation: isolate;
178
185
  }
179
186
  .postcard__face {
180
187
  position: absolute;
181
188
  inset: 0;
182
189
  display: grid;
183
190
  align-content: end;
184
- padding: var(--postcard-face-pad, var(--sp-4));
185
- gap: var(--postcard-face-gap, var(--sp-2));
186
191
  border: var(--border-w) solid var(--postcard-face-border, var(--card-border));
187
192
  border-radius: inherit;
188
193
  overflow: hidden;
189
194
  isolation: isolate;
190
- color: inherit;
191
- text-align: left;
192
195
  backface-visibility: hidden;
193
196
  -webkit-backface-visibility: hidden;
194
197
  transition: transform var(--motion-flip) ease-in-out;
195
198
  box-shadow: var(--postcard-face-shadow, var(--shadow-float));
196
199
  }
197
- .postcard__face:focus-visible { outline: var(--focus); outline-offset: var(--sp-1); }
198
- /* the front: the photo (or the wireframe box) fills it, the name reads on a scrim */
200
+ /* the turn: the button that fills the face, under the band */
201
+ .postcard__turn {
202
+ position: absolute;
203
+ inset: 0;
204
+ display: grid;
205
+ align-content: start;
206
+ padding: var(--postcard-face-pad, var(--card-pad-rest));
207
+ border: 0;
208
+ border-radius: inherit;
209
+ background: transparent;
210
+ color: inherit;
211
+ text-align: left;
212
+ cursor: pointer;
213
+ }
214
+ .postcard__turn:focus-visible { outline: var(--focus); outline-offset: calc(-1 * var(--sp-1)); }
215
+ /* the front: the photo (or the wireframe box) fills it; the band reads on a scrim */
199
216
  .postcard__front { background: var(--postcard-front-bg, var(--card-3-bg)); color: var(--postcard-front-ink, var(--card-3-ink)); }
200
- .postcard__front > img,
201
- .postcard__front > .ph {
217
+ .postcard__front .postcard__turn > img,
218
+ .postcard__front .postcard__turn > .ph {
202
219
  position: absolute;
203
220
  inset: 0;
204
221
  width: 100%;
205
222
  height: 100%;
206
223
  object-fit: cover;
207
- z-index: -2;
208
224
  border: 0;
209
225
  border-radius: 0;
210
226
  min-height: 0;
@@ -213,20 +229,40 @@
213
229
  content: "";
214
230
  position: absolute;
215
231
  inset: 0;
216
- z-index: -1;
232
+ z-index: 1;
233
+ pointer-events: none;
217
234
  background: linear-gradient(to top, var(--scrim), transparent 45%);
218
235
  }
219
- /* the back: paper — the words, big enough to read at arm's length */
236
+ /* the back: paper — the caption and the name packed at the middle, big enough to read at arm's length */
220
237
  .postcard__back {
221
238
  background: var(--postcard-back-bg, var(--card-2-bg));
222
239
  color: var(--postcard-back-ink, var(--card-2-ink));
223
- align-content: space-between;
224
240
  transform: rotateY(180deg);
225
241
  }
242
+ .postcard__back .postcard__turn { align-content: start; padding-top: var(--postcard-back-top, 20%); gap: var(--postcard-back-gap, var(--sp-5)); } /* the name a fifth down the paper, the words after it */
226
243
  .postcard__caption { margin: 0; font: var(--postcard-caption-font, var(--type-title)); text-wrap: pretty; }
227
- .postcard__label { font: var(--postcard-label-font, var(--type-overline)); letter-spacing: var(--track-overline); text-transform: uppercase; opacity: 0.85; }
244
+ /* the band: the face's foot — name and line left, the heart right; in flow at the face's end, over the turn.
245
+ Taps between its words fall through to the turn; its buttons take their own. */
246
+ .postcard__band {
247
+ position: relative;
248
+ z-index: 2;
249
+ display: flex;
250
+ align-items: end;
251
+ gap: var(--postcard-band-gap, var(--sp-3));
252
+ padding: var(--postcard-face-pad, var(--card-pad-rest));
253
+ pointer-events: none;
254
+ }
255
+ .postcard__band > * { pointer-events: auto; }
256
+ .postcard__words { display: grid; gap: var(--postcard-words-gap, var(--sp-1)); flex: 1; min-width: 0; pointer-events: none; }
257
+ .postcard__front .postcard__band { --accent: var(--postcard-front-ink, var(--card-3-ink)); --on-accent: var(--postcard-front-bg, var(--card-3-bg)); }
258
+ .postcard__band .icon-btn[aria-pressed="true"] .icon { fill: currentColor; }
228
259
  .postcard.is-flipped > .postcard__front { transform: rotateY(-180deg); }
229
260
  .postcard.is-flipped > .postcard__back { transform: rotateY(0); }
261
+ /* a voice note on the back: no words to read — its timeline (a Progress), its length and the control to hear it
262
+ under them, at the middle over the turn, on the face's inset; the app plays and keeps the time */
263
+ .postcard__voice { position: absolute; inset: 0; z-index: 2; display: grid; align-content: center; justify-items: center; gap: var(--postcard-voice-gap, var(--sp-3)); padding: var(--postcard-face-pad, var(--card-pad-rest)); pointer-events: none; }
264
+ .postcard__voice > * { pointer-events: auto; }
265
+ .postcard__voice > .progress { justify-self: stretch; pointer-events: none; }
230
266
  @media (prefers-reduced-motion: reduce) {
231
267
  .postcard__face { transition: none; }
232
268
  }
package/css/numbers.css CHANGED
@@ -256,3 +256,19 @@
256
256
  }
257
257
  .bar--on::after { background: var(--accent); }
258
258
  .bar--on > span { background: var(--accent); color: var(--on-accent); padding: var(--sp-0) var(--sp-2); border-radius: var(--r-chip); }
259
+
260
+ /* ---- progress: one straight line, the done share solid, the rest faint (Lh 2026-09-22) — a voice note's
261
+ timeline, a download, a step count as a line. <div class="progress" role="progressbar" style="--progress-at: 0.4"></div>
262
+ --progress-at is DATA the component writes (0–1), not a knob (scripts/knobs.mjs lists it). The done part is a
263
+ gradient of the line's ink sized by the share; the rest is the background colour. */
264
+ .progress {
265
+ display: block;
266
+ width: 100%;
267
+ height: var(--progress-h, var(--sp-1));
268
+ border-radius: var(--progress-radius, var(--r-chip));
269
+ color: var(--progress-done-ink, inherit);
270
+ background-color: var(--progress-rest-bg, color-mix(in srgb, currentColor 25%, transparent));
271
+ background-image: linear-gradient(currentColor, currentColor);
272
+ background-size: calc(var(--progress-at, 0) * 100%) 100%;
273
+ background-repeat: no-repeat;
274
+ }
package/css/sheet.css CHANGED
@@ -250,8 +250,13 @@
250
250
  inset-inline: var(--screen-pad);
251
251
  top: var(--_band-centre);
252
252
  translate: 0 -50%;
253
- /* no z-index: it follows the background in DOM order and the sheet, later, covers it at full */
253
+ /* as tall as the band less the screen's inset at both ends (Lh 2026-09-22: the room under the hero = the room at its sides);
254
+ what it holds fills it (a postcard's min-height gives way) */
255
+ height: calc((100cqh - var(--_top, 0rem)) / 2 - 2 * var(--screen-pad));
256
+ display: grid;
254
257
  }
258
+ .sheet-stage > .sheet-stage__hero > * { min-height: 0; } /* outranks a component's own min-height (the postcard's) */
259
+ /* no z-index on the hero: it follows the background in DOM order and the sheet, later, covers it at full */
255
260
 
256
261
  /* ---- map: the box a map engine draws in, behind a stage sheet ----
257
262
  <div class="sheet-stage__bg"><div class="map"><div>…the engine's canvas…</div>
@@ -327,10 +332,9 @@
327
332
  /* EVERY sheet has one shape (Lh, 2026-09-21): the handle and the head stay at the top and never scroll · the
328
333
  foot's button stands at the bottom, and ABOVE the keyboard when there is one · everything between them
329
334
  scrolls, as far as it needs to. In a .step the keyboard's room is the step's (--kb, step.css); on a stage
330
- the sheet gives it up itself, as --_lift — the same token a raised sheet floats on. */
335
+ the sheet gives it up itself, as --_lift. (A sheet ALWAYS stands on the floor — `.sheet--raised`, the one that
336
+ floated above a footnote strip on a dim stage, was retired 2026-09-22: that is a Modal's job.) */
331
337
  .sheet-stage > .sheet { --_lift: var(--kb, 0rem); bottom: var(--_lift); }
332
- .sheet-stage > .sheet--raised { --_lift: calc(var(--tap-lg) + var(--kb, 0rem)); }
333
- .sheet--raised { --_lift: var(--tap-lg); bottom: var(--_lift); border-radius: var(--sheet-raised-radius, var(--r-card)); border-bottom: var(--border-w) solid var(--card-border); }
334
338
  .sheet-stage__foot {
335
339
  position: absolute;
336
340
  inset-inline: 0;
package/css/stack.css CHANGED
@@ -19,7 +19,7 @@
19
19
  <div class="sheet-stack sheet-stack--tap"> (drop --tap for a static stack)
20
20
  <article class="card card--2">
21
21
  <div class="card__head">
22
- <button class="icon-btn icon-btn--sm card__back" aria-label="Back">…</button>
22
+ <button class="icon-btn icon-btn--sm sheet-stack__back" aria-label="Back">…</button>
23
23
  <h2 class="t-h2 t-caps">Shopping</h2>
24
24
  <span class="icon-row">…</span> (optional trailing icons)
25
25
  </div>
@@ -243,7 +243,7 @@
243
243
  .sheet-stack--tap > .card.is-open > .card__head {
244
244
  padding-left: calc(var(--tap-sm) + var(--sp-3));
245
245
  }
246
- .sheet-stack--tap .card__back {
246
+ .sheet-stack--tap .sheet-stack__back {
247
247
  position: absolute;
248
248
  left: 0;
249
249
  top: 50%;
@@ -252,7 +252,7 @@
252
252
  visibility: hidden;
253
253
  transition: opacity 0.35s ease, visibility 0.35s;
254
254
  }
255
- .sheet-stack--tap > .card.is-open .card__back {
255
+ .sheet-stack--tap > .card.is-open .sheet-stack__back {
256
256
  opacity: 1;
257
257
  visibility: visible;
258
258
  }
package/dist/cardds.css CHANGED
@@ -839,7 +839,7 @@ button { font: inherit; cursor: pointer; }
839
839
  <div class="sheet-stack sheet-stack--tap"> (drop --tap for a static stack)
840
840
  <article class="card card--2">
841
841
  <div class="card__head">
842
- <button class="icon-btn icon-btn--sm card__back" aria-label="Back">…</button>
842
+ <button class="icon-btn icon-btn--sm sheet-stack__back" aria-label="Back">…</button>
843
843
  <h2 class="t-h2 t-caps">Shopping</h2>
844
844
  <span class="icon-row">…</span> (optional trailing icons)
845
845
  </div>
@@ -1063,7 +1063,7 @@ button { font: inherit; cursor: pointer; }
1063
1063
  .sheet-stack--tap > .card.is-open > .card__head {
1064
1064
  padding-left: calc(var(--tap-sm) + var(--sp-3));
1065
1065
  }
1066
- .sheet-stack--tap .card__back {
1066
+ .sheet-stack--tap .sheet-stack__back {
1067
1067
  position: absolute;
1068
1068
  left: 0;
1069
1069
  top: 50%;
@@ -1072,7 +1072,7 @@ button { font: inherit; cursor: pointer; }
1072
1072
  visibility: hidden;
1073
1073
  transition: opacity 0.35s ease, visibility 0.35s;
1074
1074
  }
1075
- .sheet-stack--tap > .card.is-open .card__back {
1075
+ .sheet-stack--tap > .card.is-open .sheet-stack__back {
1076
1076
  opacity: 1;
1077
1077
  visibility: visible;
1078
1078
  }
@@ -1330,8 +1330,13 @@ button { font: inherit; cursor: pointer; }
1330
1330
  inset-inline: var(--screen-pad);
1331
1331
  top: var(--_band-centre);
1332
1332
  translate: 0 -50%;
1333
- /* no z-index: it follows the background in DOM order and the sheet, later, covers it at full */
1333
+ /* as tall as the band less the screen's inset at both ends (Lh 2026-09-22: the room under the hero = the room at its sides);
1334
+ what it holds fills it (a postcard's min-height gives way) */
1335
+ height: calc((100cqh - var(--_top, 0rem)) / 2 - 2 * var(--screen-pad));
1336
+ display: grid;
1334
1337
  }
1338
+ .sheet-stage > .sheet-stage__hero > * { min-height: 0; } /* outranks a component's own min-height (the postcard's) */
1339
+ /* no z-index on the hero: it follows the background in DOM order and the sheet, later, covers it at full */
1335
1340
 
1336
1341
  /* ---- map: the box a map engine draws in, behind a stage sheet ----
1337
1342
  <div class="sheet-stage__bg"><div class="map"><div>…the engine's canvas…</div>
@@ -1407,10 +1412,9 @@ button { font: inherit; cursor: pointer; }
1407
1412
  /* EVERY sheet has one shape (Lh, 2026-09-21): the handle and the head stay at the top and never scroll · the
1408
1413
  foot's button stands at the bottom, and ABOVE the keyboard when there is one · everything between them
1409
1414
  scrolls, as far as it needs to. In a .step the keyboard's room is the step's (--kb, step.css); on a stage
1410
- the sheet gives it up itself, as --_lift — the same token a raised sheet floats on. */
1415
+ the sheet gives it up itself, as --_lift. (A sheet ALWAYS stands on the floor — `.sheet--raised`, the one that
1416
+ floated above a footnote strip on a dim stage, was retired 2026-09-22: that is a Modal's job.) */
1411
1417
  .sheet-stage > .sheet { --_lift: var(--kb, 0rem); bottom: var(--_lift); }
1412
- .sheet-stage > .sheet--raised { --_lift: calc(var(--tap-lg) + var(--kb, 0rem)); }
1413
- .sheet--raised { --_lift: var(--tap-lg); bottom: var(--_lift); border-radius: var(--sheet-raised-radius, var(--r-card)); border-bottom: var(--border-w) solid var(--card-border); }
1414
1418
  .sheet-stage__foot {
1415
1419
  position: absolute;
1416
1420
  inset-inline: 0;
@@ -2211,6 +2215,22 @@ button { font: inherit; cursor: pointer; }
2211
2215
  .bar--on::after { background: var(--accent); }
2212
2216
  .bar--on > span { background: var(--accent); color: var(--on-accent); padding: var(--sp-0) var(--sp-2); border-radius: var(--r-chip); }
2213
2217
 
2218
+ /* ---- progress: one straight line, the done share solid, the rest faint (Lh 2026-09-22) — a voice note's
2219
+ timeline, a download, a step count as a line. <div class="progress" role="progressbar" style="--progress-at: 0.4"></div>
2220
+ --progress-at is DATA the component writes (0–1), not a knob (scripts/knobs.mjs lists it). The done part is a
2221
+ gradient of the line's ink sized by the share; the rest is the background colour. */
2222
+ .progress {
2223
+ display: block;
2224
+ width: 100%;
2225
+ height: var(--progress-h, var(--sp-1));
2226
+ border-radius: var(--progress-radius, var(--r-chip));
2227
+ color: var(--progress-done-ink, inherit);
2228
+ background-color: var(--progress-rest-bg, color-mix(in srgb, currentColor 25%, transparent));
2229
+ background-image: linear-gradient(currentColor, currentColor);
2230
+ background-size: calc(var(--progress-at, 0) * 100%) 100%;
2231
+ background-repeat: no-repeat;
2232
+ }
2233
+
2214
2234
  /* ---- css/people.css — avatar, avatar stack, chooser row ---- */
2215
2235
  /* ============================================================
2216
2236
  cardds/people.css — avatars: single, stacked, chooser row.
@@ -2625,6 +2645,15 @@ button { font: inherit; cursor: pointer; }
2625
2645
  .row__end { margin-left: auto; text-align: right; display: grid; gap: var(--row-end-gap, var(--sp-0)); justify-items: end; flex: none; }
2626
2646
  .row--lg { min-height: var(--row-lg-min-h, var(--fab)); } /* menu rows: a taller target, round arrow at the end; .rows gives them more air */
2627
2647
  .row--lg .row__title { font: 700 var(--fs-lg)/1.2 var(--font-display); }
2648
+ /* a row that EXPANDS (Lh 2026-09-22): the row is a <details>' summary, what it shows comes out under it —
2649
+ who liked a postcard, the rest of a list. An end chevron turns when open. The app owns the list.
2650
+ <details class="row-expand"><summary class="row">…lead · body · end…</summary><div class="row-expand__more">…</div></details> */
2651
+ .row-expand { display: block; }
2652
+ .row-expand > summary { list-style: none; cursor: pointer; }
2653
+ .row-expand > summary::-webkit-details-marker { display: none; }
2654
+ .row-expand > summary > .icon:last-child { transition: rotate var(--motion-slide); }
2655
+ .row-expand[open] > summary > .icon:last-child { rotate: 180deg; }
2656
+ .row-expand__more { padding-top: var(--row-expand-gap, var(--sp-3)); }
2628
2657
  /* a finished item in a checklist (a to-do list, an onboarding "complete your introduction" list):
2629
2658
  the words go muted, a filled tick sits at the end, and the row is no longer a tap target —
2630
2659
  pointer-events off here, aria-disabled from the markup. The tick is the .check's checked look
@@ -2945,59 +2974,75 @@ button { font: inherit; cursor: pointer; }
2945
2974
  .mosaic > .ph { opacity: 0.4; border-width: var(--border-w); }
2946
2975
  .mosaic > img { width: 100%; height: 100%; object-fit: cover; }
2947
2976
 
2948
- /* ---- postcard: a member's introduction, a card in the hand — photo in front,
2949
- words on the back; tap to turn it over (Lh, 2026-09-12; card #63) ----
2977
+ /* ---- postcard: a member's introduction in the shape of a cover card — the photo fills it,
2978
+ the band at the bottom names it; tap the photo to turn it over (Lh 2026-09-12; reshaped 2026-09-22) ----
2950
2979
  <div class="postcard">
2951
- <button class="postcard__face postcard__front" type="button" aria-label="พลิกดูด้านหลัง">
2952
- <img src="…" alt=""> — or <div class="ph"></div> —
2953
- <span class="postcard__label">ป้าแจ๋ว</span>
2954
- </button>
2955
- <button class="postcard__face postcard__back" type="button" aria-label="พลิกดูด้านหน้า">
2956
- <p class="postcard__caption">ชอบชงกาแฟดริปตอนเช้า</p>
2957
- <span class="postcard__label">ป้าแจ๋ว</span>
2958
- </button>
2980
+ <div class="postcard__face postcard__front">
2981
+ <button class="postcard__turn" type="button" aria-label="พลิกดูด้านหลัง"><img src="…" alt=""> — or <div class="ph"></div> —</button>
2982
+ <div class="postcard__band">
2983
+ <div class="postcard__words"><span class="t-h1">ป้าแจ๋ว</span><span class="t-body">บ้านสวนผัก</span></div>
2984
+ <button class="icon-btn icon-btn--invert" aria-label="ถูกใจ" aria-pressed="false">♥</button>
2985
+ </div>
2986
+ </div>
2987
+ <div class="postcard__face postcard__back">
2988
+ <button class="postcard__turn" type="button" aria-label="พลิกดูด้านหน้า">
2989
+ <div class="postcard__words">…</div><p class="postcard__caption">ชอบชงกาแฟดริปตอนเช้า</p>
2990
+ </button>
2991
+ <div class="postcard__voice"><div class="progress"></div><span class="t-caption">0:07 / 0:12</span><button class="icon-btn icon-btn--invert" aria-label="ฟัง">▶</button></div> (a voice note: no caption — the line, its time, the control under them, at the middle)
2992
+ </div>
2959
2993
  </div>
2960
- Each face is the whole tap target; cardds.js toggles .is-flipped on the
2961
- postcard (either face, Enter/Space too). A voice note, a like, a reply live
2962
- OUTSIDE the postcard (a Row under it): a face is a button, and a button
2963
- holds no button. The faces share one box (a 3:2 landscape card, the media
2964
- radius); the turn is a Y rotation at --motion-flip, a plain swap under
2965
- prefers-reduced-motion. Reads ink from the card slot it sits in. */
2994
+ The two faces share one box and turn (a Y rotation at --motion-flip, a plain
2995
+ swap under prefers-reduced-motion). A face is a div: the TURN is the button
2996
+ that fills it (the photo, or the caption), the band lies over its foot and
2997
+ turns with it — words and the heart; the back sets the name a fifth of the way down, the words after it; a
2998
+ button in the band is never inside the turn button. The size is the cover
2999
+ card's (--postcard-min-h = the cover's min-height, the card radius). Who
3000
+ liked it, a voice note, a reply live OUTSIDE the postcard, in a Row under it. */
2966
3001
  .postcard {
2967
3002
  position: relative;
2968
- aspect-ratio: 3 / 2;
3003
+ min-height: var(--postcard-min-h, 22.5rem);
2969
3004
  perspective: 60rem;
2970
- border-radius: var(--postcard-radius, var(--r-media));
3005
+ border-radius: var(--postcard-radius, var(--r-card));
3006
+ isolation: isolate;
2971
3007
  }
2972
3008
  .postcard__face {
2973
3009
  position: absolute;
2974
3010
  inset: 0;
2975
3011
  display: grid;
2976
3012
  align-content: end;
2977
- padding: var(--postcard-face-pad, var(--sp-4));
2978
- gap: var(--postcard-face-gap, var(--sp-2));
2979
3013
  border: var(--border-w) solid var(--postcard-face-border, var(--card-border));
2980
3014
  border-radius: inherit;
2981
3015
  overflow: hidden;
2982
3016
  isolation: isolate;
2983
- color: inherit;
2984
- text-align: left;
2985
3017
  backface-visibility: hidden;
2986
3018
  -webkit-backface-visibility: hidden;
2987
3019
  transition: transform var(--motion-flip) ease-in-out;
2988
3020
  box-shadow: var(--postcard-face-shadow, var(--shadow-float));
2989
3021
  }
2990
- .postcard__face:focus-visible { outline: var(--focus); outline-offset: var(--sp-1); }
2991
- /* the front: the photo (or the wireframe box) fills it, the name reads on a scrim */
3022
+ /* the turn: the button that fills the face, under the band */
3023
+ .postcard__turn {
3024
+ position: absolute;
3025
+ inset: 0;
3026
+ display: grid;
3027
+ align-content: start;
3028
+ padding: var(--postcard-face-pad, var(--card-pad-rest));
3029
+ border: 0;
3030
+ border-radius: inherit;
3031
+ background: transparent;
3032
+ color: inherit;
3033
+ text-align: left;
3034
+ cursor: pointer;
3035
+ }
3036
+ .postcard__turn:focus-visible { outline: var(--focus); outline-offset: calc(-1 * var(--sp-1)); }
3037
+ /* the front: the photo (or the wireframe box) fills it; the band reads on a scrim */
2992
3038
  .postcard__front { background: var(--postcard-front-bg, var(--card-3-bg)); color: var(--postcard-front-ink, var(--card-3-ink)); }
2993
- .postcard__front > img,
2994
- .postcard__front > .ph {
3039
+ .postcard__front .postcard__turn > img,
3040
+ .postcard__front .postcard__turn > .ph {
2995
3041
  position: absolute;
2996
3042
  inset: 0;
2997
3043
  width: 100%;
2998
3044
  height: 100%;
2999
3045
  object-fit: cover;
3000
- z-index: -2;
3001
3046
  border: 0;
3002
3047
  border-radius: 0;
3003
3048
  min-height: 0;
@@ -3006,20 +3051,40 @@ button { font: inherit; cursor: pointer; }
3006
3051
  content: "";
3007
3052
  position: absolute;
3008
3053
  inset: 0;
3009
- z-index: -1;
3054
+ z-index: 1;
3055
+ pointer-events: none;
3010
3056
  background: linear-gradient(to top, var(--scrim), transparent 45%);
3011
3057
  }
3012
- /* the back: paper — the words, big enough to read at arm's length */
3058
+ /* the back: paper — the caption and the name packed at the middle, big enough to read at arm's length */
3013
3059
  .postcard__back {
3014
3060
  background: var(--postcard-back-bg, var(--card-2-bg));
3015
3061
  color: var(--postcard-back-ink, var(--card-2-ink));
3016
- align-content: space-between;
3017
3062
  transform: rotateY(180deg);
3018
3063
  }
3064
+ .postcard__back .postcard__turn { align-content: start; padding-top: var(--postcard-back-top, 20%); gap: var(--postcard-back-gap, var(--sp-5)); } /* the name a fifth down the paper, the words after it */
3019
3065
  .postcard__caption { margin: 0; font: var(--postcard-caption-font, var(--type-title)); text-wrap: pretty; }
3020
- .postcard__label { font: var(--postcard-label-font, var(--type-overline)); letter-spacing: var(--track-overline); text-transform: uppercase; opacity: 0.85; }
3066
+ /* the band: the face's foot — name and line left, the heart right; in flow at the face's end, over the turn.
3067
+ Taps between its words fall through to the turn; its buttons take their own. */
3068
+ .postcard__band {
3069
+ position: relative;
3070
+ z-index: 2;
3071
+ display: flex;
3072
+ align-items: end;
3073
+ gap: var(--postcard-band-gap, var(--sp-3));
3074
+ padding: var(--postcard-face-pad, var(--card-pad-rest));
3075
+ pointer-events: none;
3076
+ }
3077
+ .postcard__band > * { pointer-events: auto; }
3078
+ .postcard__words { display: grid; gap: var(--postcard-words-gap, var(--sp-1)); flex: 1; min-width: 0; pointer-events: none; }
3079
+ .postcard__front .postcard__band { --accent: var(--postcard-front-ink, var(--card-3-ink)); --on-accent: var(--postcard-front-bg, var(--card-3-bg)); }
3080
+ .postcard__band .icon-btn[aria-pressed="true"] .icon { fill: currentColor; }
3021
3081
  .postcard.is-flipped > .postcard__front { transform: rotateY(-180deg); }
3022
3082
  .postcard.is-flipped > .postcard__back { transform: rotateY(0); }
3083
+ /* a voice note on the back: no words to read — its timeline (a Progress), its length and the control to hear it
3084
+ under them, at the middle over the turn, on the face's inset; the app plays and keeps the time */
3085
+ .postcard__voice { position: absolute; inset: 0; z-index: 2; display: grid; align-content: center; justify-items: center; gap: var(--postcard-voice-gap, var(--sp-3)); padding: var(--postcard-face-pad, var(--card-pad-rest)); pointer-events: none; }
3086
+ .postcard__voice > * { pointer-events: auto; }
3087
+ .postcard__voice > .progress { justify-self: stretch; pointer-events: none; }
3023
3088
  @media (prefers-reduced-motion: reduce) {
3024
3089
  .postcard__face { transition: none; }
3025
3090
  }
@@ -3032,8 +3097,8 @@ button { font: inherit; cursor: pointer; }
3032
3097
  lives in sheet.css; these compose with .sheet-stage.
3033
3098
  ============================================================ */
3034
3099
 
3035
- /* ---- ask: the Modal — the case drops in, its drawer opens below (Lh 2026-09-16: ONE card, two parts)
3036
- <div class="sheet-stage sheet-stage--ask">
3100
+ /* ---- the Modal — the case drops in, its drawer opens below (Lh 2026-09-16: ONE card, two parts)
3101
+ <div class="sheet-stage sheet-stage--dim"> (dim = the page under a scrim; a Modal on a clean ground is the stage without it — Lh 2026-09-22, `--ask` retired: the stage does the ground, the modal centres itself)
3037
3102
  <article class="card modal">
3038
3103
  <div class="modal__lift">…the card's content (a head, rows, a timeline)…</div>
3039
3104
  <div class="modal__drawer"><h2 class="t-h2">Take the case?</h2><p class="t-caption">This cannot be undone.</p>
@@ -3044,30 +3109,27 @@ button { font: inherit; cursor: pointer; }
3044
3109
  .modal is the card — no border (the shadow is its edge on the scrim);
3045
3110
  .modal__lift the light part that holds the thing being asked about (it
3046
3111
  scrolls when tall); .modal__drawer the dark end that asks. The ground is
3047
- the page under a scrim (like --dim). Two beats (2 × --motion-sheet) when
3112
+ whatever the stage shows (dim it or not). Two beats (2 × --motion-sheet) when
3048
3113
  the stage appears, and the same two backwards when it closes:
3049
3114
  1. the card drops in from above the stage to the screen's centre — its
3050
3115
  drawer still inside it, height 0: nothing of the ask shows yet
3051
3116
  2. the drawer comes out of the card's bottom edge while the lift moves
3052
3117
  up, both at once: the card grows from its centre, which never moves
3053
- (the stage centres it; a growing card keeps its middle where it is)
3118
+ (the modal centres itself — absolute, margin auto; a growing card keeps its middle where it is)
3054
3119
  Closing: a click on any button in the drawer plays the same animations in
3055
3120
  reverse (cardds.js: Animation.reverse() on each — nothing duplicated), then
3056
3121
  fires "cardds:modal" on the .sheet-stage (bubbles, detail.answer from the
3057
3122
  button's data-answer) for the app to remove the stage.
3058
3123
  Reduced motion: the finished state, no beats; closing fires at once. */
3059
- .sheet-stage--ask {
3060
- --drawer-h: 14rem;
3061
- display: grid;
3062
- align-content: center; /* the card at the screen's centre — and kept there as it grows */
3063
- padding-inline: var(--screen-pad);
3064
- }
3065
- .sheet-stage--ask::after { content: ""; position: absolute; inset: 0; background: var(--scrim); } /* the page dimmed */
3066
- .sheet-stage--ask > .sheet-stage__bg { filter: blur(var(--sp-2)); }
3067
3124
  .modal {
3068
- position: relative;
3069
- z-index: 1; /* over the scrim */
3070
- max-height: calc(100cqh - 2 * var(--sp-5)); /* cqh: the stage (a size container) — a % here would be of the grid area, which is the card itself */
3125
+ --drawer-h: 14rem;
3126
+ position: absolute; /* at the stage's centre, and kept there as it grows: absolute + margin auto re-centres on every height */
3127
+ inset: 0;
3128
+ margin: auto;
3129
+ height: fit-content; /* inset 0 alone would stretch it to the stage: its own height, then the margins share the rest */
3130
+ width: calc(100% - 2 * var(--screen-pad));
3131
+ z-index: 2; /* over the dim stage's scrim (z 1) */
3132
+ max-height: calc(100cqh - 2 * var(--sp-5)); /* cqh: the stage (a size container) */
3071
3133
  padding: 0; /* the parts pad themselves */
3072
3134
  border: 0; /* no frame on the modal (Lh 2026-09-16) — its edge is the shadow */
3073
3135
  display: grid;