@usableapp/cardds 0.1.7 → 0.2.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
@@ -1,13 +1,17 @@
1
1
  # cardds
2
2
 
3
+ > **For:** people and AI alike — this is the reference, the one place a rule of the system is written.
4
+ > The other docs only point here: `CLAUDE.md` (AI working IN this repo — decisions and gotchas),
5
+ > `docs/guides/building-with-cardds.md` (AI composing screens in React; shipped to Claude Design),
6
+ > the `/cardds` skill (AI in another project — install + contract). To LOOK at the system: `npm run dev`.
7
+
3
8
  Card-first mobile design system, **React-first**: `src/` is the component
4
9
  library — one thin component per pattern, emitting exactly the markup the CSS
5
10
  documents. The CSS (`css/*.css`)
6
11
  stays the only truth: a component never styles anything, it only picks classes
7
12
  from props. The CSS also works alone, with no build, for a host that wants
8
- class names rather than components. The demo is `/gallery` (`npm run dev`):
9
- every component's stories, live, searchable; `tests/fixtures/` holds the
10
- HTML pages the acceptance tests drive (not a demo — nobody reads them).
13
+ class names rather than components. The demo is the gallery (`npm run dev`):
14
+ every component's stories, live, searchable.
11
15
 
12
16
  Repo: **https://github.com/everysundays/cardds** (private — this is the
13
17
  source of truth; every consuming project vendors a copy from here, never
@@ -25,29 +29,28 @@ licences (`fonts/LICENSES.md`).
25
29
  npm install
26
30
  npm run dev # the gallery on http://localhost:5174 — every story, searchable
27
31
  npm run build # the library: icons → src/type/icons.ts · tsc → dist/ (ESM + .d.ts) · dist/cardds.css (flattened)
28
- npm test # Playwright: the step/sheet/centre geometry on tests/fixtures/, the app's screens and the four handoff upgrades
32
+ npm test # Playwright: the tokens and the step/sheet/centre geometry on tests/fixtures/, the gallery's stories and behaviours
29
33
  ```
30
34
 
31
35
  ```tsx
32
- import { Screen, TopBar, Step, BaseContent, Sheet, SheetBody, CardHead, Field, Pin, ActionBar, Btn } from 'cardds';
33
- import 'cardds/dist/cardds.css'; // or cardds/cardds.css with the css/ folder beside it
36
+ import { Screen, TopBar, Step, BaseContent, Sheet, SheetBody, CardHead, Field, Pin, ActionBar, Btn } from '@usableapp/cardds';
37
+ import '@usableapp/cardds/dist/cardds.css';
38
+ import './theme.css'; // the project's own look, after the system's — see "A project's theme"
34
39
  ```
35
40
 
36
41
  `src/index.ts` imports `cardds.js` for its side effects, so a React host gets
37
42
  the behaviours for free: the `.sheet` handle (tap, drag, keyboard) with the
38
43
  focus and on-screen-keyboard raises, `.sheet-stack--tap` opening and closing,
39
44
  the `.pin` auto-advance (a digit moves on, Backspace moves back, a paste fills
40
- the code), a card heading that needs more than two lines squeezing its type
41
- (see *Anatomy*), and the `.dropdown` writing its picked value back. `Icon`
45
+ the code), the modal's close, and the `.dropdown` writing its picked value back. `Icon`
42
46
  inlines the 56 symbols of `icons.svg` at build time, so no sprite file ships.
43
47
 
44
- **The gallery** (`gallery/`): Vite + React Router, `cardds` aliased to `src/`
45
- so the gallery and the package share one truth with no build between them.
46
- `/` lists every story of every component, `/:Name` one component. (The
47
- Commons Time Bank app that used to live here — twelve screens from
48
- `docs/handoff/commons-time-bank-SPEC.md` — was dropped on 2026-09-16; nobody
49
- looked at it, the gallery is the demo. The type scale is the `Text · Scale`
50
- story.)
48
+ **The gallery** (`gallery/`, `npm run dev` → http://localhost:5174): Vite + React Router, `cardds`
49
+ aliased to `src/`, so the gallery and the package share one truth with no build between them.
50
+ `/` every story of every component, `/:Name` one component — each `.design-sync/previews/<Name>.tsx`
51
+ story live in a 375×812 phone cell, grouped by `src/<group>/`, searchable; switches for the palette
52
+ (`own` = a colour well per colour base), inspect (box outlines + dimensions), text size, and a slider
53
+ per size base. Nothing is hand-listed: add a preview file or a story and it shows.
51
54
 
52
55
  **CSS only** (no React, no build):
53
56
 
@@ -62,12 +65,7 @@ popover) but won't update its label, a stack still displays but won't open, a
62
65
  sheet renders every state but only moves when a class changes, a pin is six
63
66
  plain inputs.
64
67
 
65
- The one place to look is `/gallery` (`npm run dev` → `http://localhost:5174/gallery`):
66
- every `.design-sync/previews/<Name>.tsx` story, live, grouped by `src/<group>/`,
67
- searchable, in a 375×812 phone cell, with a palette switch and an inspect
68
- switch (box outlines + dimensions on hover). `tests/fixtures/*.html` (ex
69
- `demo/`) are the pages `tests/step.spec.js` and `tests/centre.spec.js` measure;
70
- `tests/gallery.spec.js` drives the gallery. Nothing here is verified by eye.
68
+ `tests/fixtures/*.html` are the pages the geometry tests measure — not a demo. Nothing here is verified by eye.
71
69
 
72
70
  ### In another project
73
71
 
@@ -83,7 +81,6 @@ import { Card, CardHead, Btn } from '@usableapp/cardds';
83
81
  import '@usableapp/cardds/dist/cardds.css'; // the CSS, fonts resolve from the package
84
82
  ```
85
83
 
86
- Browse what exists at `npm run dev` → `/gallery` in this repo (every component, every story, searchable).
87
84
 
88
85
  **CSS only:** there's no build and no dependencies, so installing is cloning once, then
89
86
  copying the static files into your project — never edit the clone, never
@@ -105,21 +102,17 @@ cp -R ~/Sites/cardds/css ~/Sites/cardds/fonts ~/Sites/cardds/cardds.css \
105
102
  folder next to it, and `fonts/` beside that (`css/fonts.css` reaches the faces
106
103
  as `../fonts/`). `icons.svg` is referenced by path from your markup
107
104
  (`<use href="icons.svg#bell">`), so put it where those references resolve.
108
- Then follow the rules below — the short version is: tune `palette.css` and
109
- nothing else, and never write a raw hex or px in a component.
105
+ Then follow the rules below — the short version is: tune your own `theme.css`
106
+ (a copy of `css/theme.template.css`, see "A project's theme") and nothing else, and never write a raw hex or px in a component.
110
107
 
111
108
  The repo is private, so cloning needs an account with access (`everysundays`
112
109
  on GitHub) — set that up as an SSH host alias if you already use a different
113
110
  account for `git@github.com` day to day, the way this machine's `~/.ssh/config`
114
111
  does it for other `everysundays` repos.
115
112
 
116
- **Recording what you copied matters.** A vendored copy with no record of which
117
- commit it came from can't be told apart from a fork. timebank's
118
- `scripts/cardds-vendor.mjs` is the reference: `npm run cardds:sync` copies the
119
- files above from a local checkout (`CARDDS_SRC`, default `~/Sites/cardds`) and
120
- writes `VENDORED.json` — commit hash, date, a sha256 per file — so
121
- `npm run check:cardds` can catch a hand edit or a stale copy. `git pull` that
122
- checkout before syncing to pick up what's been pushed here.
113
+ **Record what you copied.** A vendored copy with no record of the commit it came
114
+ from can't be told apart from a fork: write the commit hash (and a sha256 per
115
+ file) beside the copy, so a hand edit or a stale copy can be caught.
123
116
 
124
117
  Working with Claude in another project? The `/cardds` skill
125
118
  (`~/.claude/skills/cardds/`) carries the install and the contract, and points
@@ -204,9 +197,10 @@ whose markup has rules, because the sheet's box is the step itself:
204
197
  …cardds controls (.field / .pin / .composer / .rows + .check / .segment /
205
198
  .chip-grid), or small content cards (.card--sm)…
206
199
  </div>
200
+ <div class="card__foot"><button class="btn btn--primary btn--block">…the one button that finishes the sheet…</button></div>
207
201
  </article>
208
202
  </section>
209
- <div class="action-bar">…one wide button…</div>
203
+ <!-- no .action-bar: a bar is the base's, never in front of a sheet -->
210
204
  ```
211
205
 
212
206
  - **`.step` needs a bounded column**: a direct child of `.screen--fill`, or
@@ -239,9 +233,13 @@ whose markup has rules, because the sheet's box is the step itself:
239
233
  - **The bar is outside the step**, after it. `.step` reads the bar's room
240
234
  from `--bar-reserve`; any parent that hosts the `.action-bar` counts
241
235
  (`<body>`, an app shell, a demo frame).
242
- - **A sheet inside a step never carries the step's action.** The bar is the
243
- one move. A sheet gets its own buttons only on a screen that has no bar —
244
- the map sheet in `sheet.html`, whose "Directions" is that screen's move.
236
+ - **Layers: the base, the sheet over it, the top bar** (Lh 2026-09-20). The `.action-bar` belongs to the
237
+ BASE — the move of a page of words (a pager, or one wide button). It is never in front of a sheet. A
238
+ screen with a sheet finishes ON the sheet: a `.card__foot`, the sheet's last child — ONE row, the page's
239
+ conclusion. The foot stands on the screen's bottom edge at half, 3q and full alike (a layer as tall as
240
+ the sheet, slid up by the share the sheet slid down — `sheet.css`), and is gone at peek. Nothing inside
241
+ the sheet's body commits on its own (a `Composer` there takes no `send`). A bar written beside a sheet anyway
242
+ stands down — no pointer (`actions.css`), `inert` (`cardds.js`): a net for a mistake, not a layout.
245
243
  - **Never write a sheet's height, and never a `.sheet-stage` inside a step.**
246
244
  State is a class (`sheet--peek` / `--half` / `--full`); the sheet is always
247
245
  the step's full height and only slides. `carddsSheetSet(sheet, state)` is
@@ -290,6 +288,23 @@ between at 40% down the screen (see *Anatomy*, "Where the centre is"):
290
288
  - **The round pager** is `.action-bar--pager` (or an `.action-bar__tier--pager`)
291
289
  holding two `.icon-btn`s and nothing else: they stand at the two edges. The
292
290
  step that commits still goes back to one wide button (rule 3).
291
+ - **BaseContent that moves** (Lh 2026-09-20) — two ways, never both on one screen:
292
+ - *Pages of words:* `.step > .pages > .base-content` (one `aria-current="step"`, the rest `inert`) — every page
293
+ one height, the round pager turns them (`slideTo()`), the words slide like a page. No sheet there.
294
+ - *Called sheets:* the base offers WAYS IN — sign in · new phone · join — as buttons (`.cta-pack`), each naming a
295
+ sheet: `<button aria-controls="signin">` … `<article id="signin" class="card sheet sheet--away" inert
296
+ data-sheet-states="peek full">`. Each sheet holds that way's WHOLE form and its own submit on its foot, leading
297
+ to the next page; the base carries no control and the screen no bar. `.sheet--away` = not on the screen (slid
298
+ out, hidden once gone). Nothing waits at peek. A tap raises its sheet to full and sends any other away; the
299
+ handle takes it down to peek and never further — the words and the other buttons are back in reach (the step
300
+ publishes `--sheet-room`, the peek strip, so they scroll clear of it); the same button raises it from peek to full.
301
+ **Never a form taken apart** — one sheet per field, a "send" left on the base: a form lives whole in ONE sheet.
302
+ Fixture `tests/fixtures/calls.html`, tests `tests/calls.spec.js`, stories `Step · Walks` / `Step · Calls`.
303
+ - **A pager turns pages of words, never a screen with a sheet** (Lh 2026-09-20). A
304
+ sheet means this screen has work that cannot be walked past: no ← →, no bar at all — the sheet's
305
+ own foot finishes it. Should a pager meet a sheet anyway it stands
306
+ down while the sheet is on the screen, a peeking one included: dimmed, no pointer
307
+ (`actions.css`), `inert` (`cardds.js`).
293
308
 
294
309
  ## Files
295
310
 
@@ -297,6 +312,7 @@ between at 40% down the screen (see *Anatomy*, "Where the centre is"):
297
312
  |---|---|---|
298
313
  | `css/fonts.css` + `fonts/` | the two `@font-face`s: PK Nonthaburi (body, `--font-ui`), FC Pride (display, `--font-display`); the font files and their licences | when swapping a face |
299
314
  | `css/tokens.css` | spacing, radius, type levels, motion (`--motion-sheet`), the human centre (`--screen-centre`), grayscale role defaults; registers the two sheet tokens (`--sheet-peek`, `--sheet-half`) so script can read them resolved | rarely |
315
+ | `css/theme.template.css` | **a project's theme** — the four size bases + the colour roles, at their defaults; NOT in the bundle: copied into the project as its `theme.css`, loaded after `cardds.css` | copy it, don't edit it |
300
316
  | `css/palette.css` | **color settings — palettes as role-token overrides** | yes, this one |
301
317
  | `css/base.css` | reset, surface, typography classes, `.base-content` | rarely |
302
318
  | `css/card.css` | `.card` + its skeleton (`.card__head` / content / `.card__foot`), `.card-list`, `.card--row`, `.icon-row`, stats, `.card--centre` (one card at the human centre of a fill screen) and `.screen__centre` (a small group there) | when adding variants |
@@ -309,9 +325,9 @@ between at 40% down the screen (see *Anatomy*, "Where the centre is"):
309
325
  | `css/numbers.css` | `.ring` gauge, `.track` steps, `.card--band` + `.band-stack`, `.dotgrid`, `.picker`, `.badge`, `.bars` | big numbers |
310
326
  | `css/people.css` | `.avatar` (sm/lg/xl, outline, add, on, halo), `.avatar-stack`, `.avatar-pick` | people |
311
327
  | `css/choice.css` | `.check`, `.toggle`, `.chip-grid` + `.chip--pick` (+ `--sign`), `.day-strip`, `.calendar`, `.mood`, `.pin`, `.composer`, `.slider` | choice controls |
312
- | `css/lists.css` | `.row` / `.rows` (frameless rows in a card), `.kv`, `.kv-grid`, `.timeline`, `.legend`, `.link` | lists inside cards |
328
+ | `css/lists.css` | `.row` / `.rows` (frameless rows in a card), `.kv`, `.kv-grid`, `.thread` + `.bubble` (chat: theirs left, mine right, no tail), `.timeline`, `.legend`, `.link` | lists inside cards |
313
329
  | `css/media.css` | `.card--cover`, `.quote`, `.tile-grid` + `.tile`, `.card--fold`, `.mosaic`, `.wave` | media & display |
314
- | `css/layover.css` | `.modal` (the ask: `__lift` + `__drawer`, closes on any drawer button → `cardds:modal`), `.menu` (popover), `.banner` (details), `.float-bar`, `.callout`, `.chip--bubble`, `.deck` | cards over content |
330
+ | `css/layover.css` | `.modal` (the ask: `__lift` + `__drawer`, closes on any drawer button → `cardds:modal`), `.menu` (popover), `.banner` (details), `.float-bar`, `.callout`, `.deck` | cards over content |
315
331
  | `icons.svg` | Lucide sprite (ISC), 56 minimal stroke icons | add symbols as needed |
316
332
  | `src/` → `dist/` | the React library: `src/<group>/<Name>.tsx`, one thin component per pattern, JSDoc = the rule; built by `npm run build` (tsc) into `dist/` with `.d.ts`; `src/type/icons.ts` is generated from `icons.svg` | when adding a component |
317
333
  | `gallery/` | the gallery site: `npm run dev` → `/` every story, `/:Name` one component (`gallery/src/Gallery.tsx` globs `.design-sync/previews/`) | the demo |
@@ -502,6 +518,63 @@ Demo: the *skeleton* block at the top of `elements.html`.
502
518
  `--on-accent` to its own ink and bg, so every accent consumer (buttons,
503
519
  checks, toggles, rings, selected days) inverts without extra rules.
504
520
 
521
+ ## A project's theme
522
+
523
+ A project retunes cardds from ONE file of its own, and nothing else. Copy
524
+ `css/theme.template.css` into the project as `theme.css`, load it **after**
525
+ `cardds.css`, change values:
526
+
527
+ ```css
528
+ :root {
529
+ --sp-base: 0.3rem; /* roomier */
530
+ --r-base: 0.5rem; /* tighter corners */
531
+ --color-surface: #faf6ee; --color-ink: #1f1b16; --color-paper: #fff;
532
+ --color-brand: #c2410c; --color-on-brand: #fff;
533
+ }
534
+ ```
535
+
536
+ ```html
537
+ <html data-palette="own"> <!-- colours of your own = coloured mode; any value no stock palette uses -->
538
+ ```
539
+
540
+ That file is the whole public surface:
541
+
542
+ | what | tokens | moves |
543
+ |---|---|---|
544
+ | 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 |
545
+ | type | `--fs-base` (1rem = 16) | every `--fs-*`, `--badge`, `--icon-xs` |
546
+ | corners | `--r-base` (1rem = 16) | every `--r-*`; `0` = square. `--r-chip` stays fully round |
547
+ | 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) |
548
+ | faces | `--font-ui`, `--font-display` | |
549
+ | 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` |
550
+ | colour roles (the escape hatch) | `--surface`, `--on-surface(-muted)`, `--card-{1,2,3}-{bg,ink}`, `--card-border`, `--card-muted`, `--card-2-muted`, `--accent`, `--on-accent`, `--action-{bg,ink,border}`, `--pos`, `--neg`, `--dot-{a,b}` | a role set directly wins over its derivation; the tints derive from the roles |
551
+
552
+ Rules:
553
+
554
+ - **A base, never a multiplier.** Every size token is `calc(base × multiplier)`
555
+ in `tokens.css`; the multipliers are the system's proportions. A project
556
+ never sets a derived token (`--sp-4`, `--fs-h1`, `--r-card`, `--tap`…), a
557
+ private one (`--_*`), or a published one (`--card-pad`, `--bar-reserve`, `--sheet-*`).
558
+ - **On `:root` only.** The derived tokens are computed where they are declared
559
+ (`:root`), so a base set on a subtree does not reach them.
560
+ - **After `cardds.css`.** A stock palette's block and a project's `:root` weigh
561
+ the same; the later file wins.
562
+ - **Coloured mode is the attribute.** `data-palette`, whatever its value, is what
563
+ trades the wireframe's borders for the card's shadow and the controls' soft
564
+ edge (`palette.css`, `forms.css`). Colours of your own → `<html data-palette="own">`.
565
+ Without it the colours still apply, on wireframe borders.
566
+ - **cardds does not judge the colours.** No contrast check, no warning — a
567
+ theme's values are the project's call. The derivations are only starting points;
568
+ any role can be set outright.
569
+ - `--text-scale` (below) is the member's setting, not the theme's: it scales
570
+ everything, the bases included.
571
+
572
+ Try it live: the gallery's nav (`npm run dev`) has a slider per size base, and
573
+ palette `own` shows a colour well per colour base.
574
+ `tests/tokens.spec.js` holds the defaults to the px they always were and the
575
+ template to the defaults; the step / centre geometry runs at two extreme
576
+ themes (`tight`, `loose`).
577
+
505
578
  ## New palette
506
579
 
507
580
  Copy any block in `palette.css`, rename, retune:
@@ -517,9 +590,10 @@ Copy any block in `palette.css`, rename, retune:
517
590
 
518
591
  ## Scale: everything is rem
519
592
 
520
- Every length token is rem (`--sp-4: 1rem`, `--tap: 3rem`, `--r-card: 2rem`,
521
- `--fs-h1: 1.75rem` …), so the whole system resizes from one number: the root
522
- font size. `base.css` keeps it at `100%` (the user's text-size setting, 16px by
593
+ Every length token resolves to rem — a multiplier × one of four rem bases
594
+ (`--sp-4` = `--sp-base` × 4 = 1rem, `--tap` = `--tap-base` = 3rem, `--r-card` =
595
+ `--r-base` × 2, `--fs-h1` = `--fs-base` × 1.75; see "A project's theme") — so the
596
+ whole system still resizes from one number: the root font size. `base.css` keeps it at `100%` (the user's text-size setting, 16px by
523
597
  default) and steps narrow phones (≤360px) to `93.75%`, which keeps the 25rem
524
598
  column, its 48px taps and its type in proportion on 320–360 screens. Only
525
599
  `--border-w`, `--rule-w` and the focus ring stay in px, so edges stay crisp.
package/cardds.js CHANGED
@@ -207,16 +207,19 @@ function carddsSheetStates(sheet) {
207
207
  }
208
208
 
209
209
  function carddsSheetState(sheet) {
210
+ if (sheet.classList.contains('sheet--away')) return 'away'; // a called sheet not on the screen — a state to set, never one to snap or tap to
210
211
  return CARDDS_SHEET_STATES.find(s => sheet.classList.contains('sheet--' + s)) ?? 'half'; // the CSS default
211
212
  }
212
213
 
213
214
  function carddsSheetSet(sheet, state) {
214
- if (!CARDDS_SHEET_STATES.includes(state)) return;
215
+ if (state !== 'away' && !CARDDS_SHEET_STATES.includes(state)) return;
215
216
  const was = carddsSheetState(sheet);
216
- CARDDS_SHEET_STATES.forEach(s => sheet.classList.toggle('sheet--' + s, s === state));
217
+ [...CARDDS_SHEET_STATES, 'away'].forEach(s => sheet.classList.toggle('sheet--' + s, s === state));
218
+ sheet.inert = state === 'away';
217
219
  /* where a tap from peek returns to: the non-peek state it is going to, or the
218
220
  one it is leaving (an authored state never passed through here before) */
219
- const home = state !== 'peek' ? state : was !== 'peek' ? was : null;
221
+ const rests = s => s !== 'peek' && s !== 'away';
222
+ const home = rests(state) ? state : rests(was) ? was : null;
220
223
  if (home) sheet.dataset.sheetHome = home;
221
224
  if (was !== state) sheet.dispatchEvent(new CustomEvent('cardds:sheet', { bubbles: true, detail: { state } }));
222
225
  }
@@ -375,7 +378,9 @@ function carddsSheetReveal(el, atLeast) {
375
378
  const box = sheet.parentElement.getBoundingClientRect(); // the box that clips it: .step or .sheet-stage
376
379
  const vv = window.visualViewport;
377
380
  const cut = vv ? Math.min(box.bottom, vv.offsetTop + vv.height) : box.bottom;
378
- const need = el.getBoundingClientRect().height;
381
+ const foot = sheet.querySelector(':scope > .card__foot'); // the sheet's own button stands on the cut (sheet.css): the control has to clear it too
382
+ const button = foot?.firstElementChild; // the foot is a layer as tall as the sheet: what the control must clear is its strip
383
+ const need = (el.closest('.field') ?? el).getBoundingClientRect().height + (button ? box.bottom - button.getBoundingClientRect().top : 0); // from the button's top to the box's bottom edge, where it stands
379
384
  let to = states[states.length - 1];
380
385
  for (let i = from; i < states.length; i++) {
381
386
  if (cut - (restTop + stops[states[i]] + bodyTop) >= need) { to = states[i]; break; }
@@ -414,3 +419,56 @@ document.addEventListener('click', e => {
414
419
  stage.dispatchEvent(new CustomEvent('cardds:modal', { bubbles: true, detail: { answer: btn.dataset.answer ?? null } }));
415
420
  });
416
421
  });
422
+
423
+ /* ---- called sheets (sheet.css): a button in a step's words names a sheet with aria-controls ----
424
+ (The base offers ways in — sign in · new phone · join — each sheet holds that way's whole form and
425
+ its own submit on its foot.) A tap on it sends every other sheet of that step away and raises its own to the top state it snaps
426
+ to (full) — from away, and from peek alike. The step then publishes --sheet-room, the strip the
427
+ sheet keeps at peek, so the words can scroll their buttons clear of it (step.css); the buttons say
428
+ aria-expanded. */
429
+ function carddsSheetCall(sheet) {
430
+ const step = sheet.parentElement;
431
+ step.querySelectorAll(':scope > .sheet').forEach(s => { if (s !== sheet && carddsSheetState(s) !== 'away') carddsSheetSet(s, 'away'); });
432
+ const states = carddsSheetStates(sheet);
433
+ const top = states.includes('full') ? 'full' : states[states.length - 1];
434
+ const was = carddsSheetState(sheet);
435
+ if (was !== top) carddsSheetSet(sheet, top);
436
+ if (was === 'away') sheet.querySelector(':scope > .sheet__handle')?.focus({ preventScroll: true });
437
+ }
438
+ function carddsStepSync(step) {
439
+ const callers = [...step.querySelectorAll('[aria-controls]')].filter(b => document.getElementById(b.getAttribute('aria-controls'))?.matches('.step > .sheet'));
440
+ if (!callers.length) return; // a plain step: one sheet, nothing calls it, nothing to publish
441
+ const on = [...step.querySelectorAll(':scope > .sheet')].find(s => carddsSheetState(s) !== 'away');
442
+ if (on) step.style.setProperty('--sheet-room', getComputedStyle(on).getPropertyValue('--sheet-peek')); // registered: reads back in px
443
+ else step.style.removeProperty('--sheet-room');
444
+ callers.forEach(b => b.setAttribute('aria-expanded', String(carddsSheetState(document.getElementById(b.getAttribute('aria-controls'))) !== 'away')));
445
+ }
446
+ document.addEventListener('click', e => {
447
+ const caller = e.target.closest('[aria-controls]');
448
+ const sheet = caller && document.getElementById(caller.getAttribute('aria-controls'));
449
+ if (sheet && sheet.matches('.step > .sheet')) carddsSheetCall(sheet);
450
+ });
451
+ document.addEventListener('cardds:sheet', e => { const step = e.target.closest?.('.step'); if (step) carddsStepSync(step); });
452
+
453
+ /* ---- bar guard: a bar is the base's, never in front of a sheet (actions.css, Lh 2026-09-20) ----
454
+ A sheet on the screen covers the bar and CSS takes the pointer away; `inert` takes the keyboard and
455
+ the accessibility tree too. Re-checked when a sheet changes state and when the page changes. */
456
+ function carddsPagerGuard() {
457
+ document.querySelectorAll('.action-bar').forEach((bar) => {
458
+ const host = bar.parentElement;
459
+ bar.inert = !!(host && host.querySelector('.step > .sheet:not(.sheet--away)'));
460
+ });
461
+ }
462
+ if (typeof document !== 'undefined') {
463
+ document.addEventListener('cardds:sheet', carddsPagerGuard);
464
+ const start = () => {
465
+ carddsPagerGuard();
466
+ let queued = false;
467
+ new MutationObserver(() => {
468
+ if (queued) return;
469
+ queued = true;
470
+ requestAnimationFrame(() => { queued = false; carddsPagerGuard(); });
471
+ }).observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['class'] });
472
+ };
473
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start); else start();
474
+ }
package/css/actions.css CHANGED
@@ -239,6 +239,14 @@
239
239
  <button class="icon-btn icon-btn--invert" aria-label="ถัดไป">…</button>
240
240
  </div> — or the same two in an .action-bar__tier--pager under a control */
241
241
  .action-bar--pager { align-items: center; }
242
+ /* The bar belongs to the BASE (Lh, 2026-09-20): it is the move of a page of words — a pager turning
243
+ them, or one wide button. It is never in front of a sheet: a sheet that is on the screen covers it
244
+ (step.css, "layers"), a peeking one included, and the button that finishes a sheet is the sheet's own
245
+ foot (sheet.css). So a pager never works beside a sheet — that screen has work that cannot be walked
246
+ past — and neither does any other bar: while a sheet is on (only .sheet--away, a sheet not called yet,
247
+ is not), the bar takes no pointer here and cardds.js makes it inert, so the keyboard cannot reach what
248
+ the eye cannot see. */
249
+ :has(.step > .sheet:not(.sheet--away)) > .action-bar { pointer-events: none; }
242
250
  /* the page dots: under the card in a .screen__centre group; tight gaps */
243
251
  .pager__at { flex: 0 1 auto; min-width: 0; overflow: hidden; display: flex; align-items: center; gap: var(--sp-1); }
244
252
  .pager__at > * {
package/css/card.css CHANGED
@@ -209,7 +209,7 @@
209
209
  and updates inside it; a browser without them (the floor: Chrome 105 / iOS 16)
210
210
  simply switches. Only the card is named; the rest of the page must not
211
211
  cross-fade. */
212
- .screen__centre > .card, .pages > .card[aria-current] { view-transition-name: centre-card; }
212
+ .screen__centre > .card, .pages > [aria-current] { view-transition-name: centre-card; }
213
213
 
214
214
  /* ---- pages: the cards of a walk, all one height ----
215
215
  A sequence the member walks (an introduction) reads steadier when every card
@@ -223,8 +223,14 @@
223
223
  <article class="card" inert>…</article>
224
224
  </div><div class="pager__at">…</div></div> */
225
225
  .pages { display: grid; }
226
- .pages > .card { grid-area: 1 / 1; align-content: start; } /* stretched to the cell, the words stay at the top */
227
- .pages > .card[inert] { visibility: hidden; }
226
+ .pages > :is(.card, .base-content) { grid-area: 1 / 1; align-content: start; } /* stretched to the cell, the words stay at the top */
227
+ .pages > [inert] { visibility: hidden; }
228
+ /* pages of WORDS (Lh, 2026-09-20): the same walk with .base-content pages, on the base — in a .step the
229
+ .pages is the reading block (step.css), the round pager in the bar turns it, the same slideTo().
230
+ <section class="step"><div class="pages">
231
+ <section class="base-content" aria-current="step">…</section>
232
+ <section class="base-content" inert>…</section>
233
+ </div></section> <div class="action-bar action-bar--pager">← →</div> */
228
234
  ::view-transition-old(root), ::view-transition-new(root) { animation: none; }
229
235
  ::view-transition-old(centre-card), ::view-transition-new(centre-card) {
230
236
  animation-duration: var(--motion-slide);
package/css/layover.css CHANGED
@@ -271,22 +271,6 @@
271
271
  }
272
272
  .callout__title { font: var(--type-title); }
273
273
  .callout__sub { font: var(--type-caption); opacity: 0.7; }
274
- /* speech chip: a chip with the same tail (mascot / greeting) */
275
- .chip--bubble { position: relative; }
276
- .chip--bubble::after {
277
- content: "";
278
- position: absolute;
279
- left: var(--sp-4);
280
- bottom: calc(-1 * var(--sp-2) + var(--border-w));
281
- width: var(--sp-3);
282
- height: var(--sp-3);
283
- rotate: 45deg;
284
- background: inherit;
285
- border: inherit;
286
- border-top: 0;
287
- border-left: 0;
288
- }
289
-
290
274
  /* ---- deck: swipe cards; the last child is on top ----
291
275
  <div class="deck"><article class="card">…</article><article class="card">…</article><article class="card card--fold">front</article></div>
292
276
  <div class="deck__actions"><button class="icon-btn">×</button><button class="icon-btn icon-btn--invert">✓</button></div> */
package/css/lists.css CHANGED
@@ -87,6 +87,56 @@
87
87
  .kv-cell__key { font: var(--type-caption); color: var(--card-muted); }
88
88
  .card--3 .kv-cell__key { color: inherit; opacity: 0.7; }
89
89
 
90
+ /* ---- bubble: a chat message — theirs on the left, mine on the right (Lh 2026-09-18, replaces the chip's tail) ----
91
+ No tail: a rounded block whose corner nearest the sender is square-ish
92
+ (bottom-left for theirs, bottom-right for mine), the way messaging apps
93
+ draw it. Under it a caption line (time · a tick), beside it small round
94
+ actions (a heart, more). A .thread stacks them.
95
+ <div class="thread">
96
+ <div class="bubble"><p class="bubble__body">…</p><span class="bubble__actions">…icon-btns…</span><span class="bubble__meta">Dec 4 · 8:15</span></div>
97
+ <div class="bubble bubble--right">…</div>
98
+ </div> */
99
+ .thread { display: grid; gap: var(--sp-4); }
100
+ /* two columns: the message (max-content, but it gives way — minmax(0) — so a long one shrinks
101
+ and wraps while the actions stay beside it) and the actions; the meta on a second row under the message */
102
+ .bubble {
103
+ display: grid;
104
+ grid-template-columns: minmax(0, max-content) auto;
105
+ justify-content: start;
106
+ align-items: center;
107
+ gap: var(--sp-1) var(--sp-3);
108
+ max-width: 88%;
109
+ margin-right: auto; /* theirs: hugs the left */
110
+ }
111
+ .bubble--right { grid-template-columns: auto minmax(0, max-content); justify-content: end; margin-right: 0; margin-left: auto; } /* mine: hugs the right, the actions on its left */
112
+ .bubble__body, .bubble__meta { grid-column: 1; }
113
+ .bubble__actions { grid-column: 2; grid-row: 1; }
114
+ .bubble--right > :is(.bubble__body, .bubble__meta) { grid-column: 2; }
115
+ .bubble--right > .bubble__actions { grid-column: 1; }
116
+ .bubble__body {
117
+ margin: 0;
118
+ min-width: 0;
119
+ padding: var(--sp-3) var(--sp-4);
120
+ border-radius: var(--r-card);
121
+ border-bottom-left-radius: var(--r-control);
122
+ background: var(--card-2-bg);
123
+ color: var(--card-2-ink);
124
+ font: var(--type-body);
125
+ overflow-wrap: anywhere;
126
+ }
127
+ .bubble--right > .bubble__body {
128
+ border-bottom-left-radius: var(--r-card);
129
+ border-bottom-right-radius: var(--r-control);
130
+ background: var(--accent);
131
+ color: var(--on-accent);
132
+ text-align: right;
133
+ }
134
+ .bubble__actions { display: flex; gap: var(--sp-1); flex: none; opacity: 0.7; }
135
+ .bubble__meta { display: flex; align-items: center; gap: var(--sp-1); font: var(--type-caption); color: var(--on-surface-muted); }
136
+ .bubble--right > .bubble__meta { justify-self: end; }
137
+ .bubble__meta > .icon { color: var(--accent); }
138
+ .card--3 .bubble__meta { color: inherit; opacity: 0.7; }
139
+
90
140
  /* ---- timeline: a hairline with date chips, entries to the right ----
91
141
  <ol class="timeline"><li class="timeline__item"><span class="timeline__mark">Wed 14</span><p class="t-body">…</p></li>…</ol> */
92
142
  .timeline {
package/css/palette.css CHANGED
@@ -108,5 +108,10 @@
108
108
 
109
109
  /* Colored palettes trade wireframe outlines for soft elevation. */
110
110
  [data-palette] :is(.card, .band-stack) { box-shadow: var(--shadow-card); }
111
+ /* COLOURED MODE is the attribute, whatever its value: a project with colours of its own (the five
112
+ --color-* bases, or roles, in its theme.css) writes <html data-palette="own"> — no block here matches
113
+ "own", so only the three generic rules apply: the card's shadow, the soft outline, the field's soft
114
+ edge (forms.css); and the two wireframe borders step back, as in every stock palette. */
115
+ [data-palette] { --card-border: transparent; --action-border: transparent; }
111
116
  /* …but controls keep a faint edge so they still read as controls */
112
117
  [data-palette] { --outline: color-mix(in srgb, currentColor 25%, transparent); }
package/css/sheet.css CHANGED
@@ -88,6 +88,19 @@
88
88
  .sheet--half { --_y: calc(var(--sheet-half) * 100%); --_cover: calc(var(--sheet-half) * var(--_box)); }
89
89
  .sheet--3q { --_y: calc(var(--sheet-3q) * 100%); --_cover: calc(var(--sheet-3q) * var(--_box)); }
90
90
  .sheet--peek { --_y: calc(100% - var(--sheet-peek)); --_cover: calc(var(--_box) - var(--sheet-peek)); }
91
+ /* ---- called sheets (Lh, 2026-09-20): a .step whose .base-content has buttons that each CALL a sheet ----
92
+ .sheet--away = not on the screen: slid all the way out (translate, like every state — the material never
93
+ changes), and hidden once it has left so its shadow does not lie on the step's bottom edge; write it
94
+ with `inert`. A button names its sheet — <button aria-controls="when"> … <article id="when" class="card
95
+ sheet sheet--away" inert data-sheet-states="peek full"> — and cardds.js does the rest: a tap slides that
96
+ sheet up to full and sends any other one away; the handle takes it down to peek, never further, so the
97
+ words and the other buttons are back in reach; the same button again raises it from peek to full.
98
+ Nothing waits at peek before the first call. No bar on such a screen (actions.css).
99
+ WHAT IT IS FOR: the base offers WAYS IN — sign in · new phone · join — and each button calls a sheet
100
+ that holds that way's WHOLE form, its submit on the sheet's foot, leading to the next page. The base
101
+ carries no control. It is NOT for a form taken apart — one sheet per field, a "send" left on the base:
102
+ a form lives whole in one sheet. */
103
+ .sheet--away { --_y: 100%; --_cover: var(--_box); visibility: hidden; transition: translate var(--motion-sheet) ease, visibility 0s linear var(--motion-sheet); }
91
104
  .sheet.is-dragging { transition: none; } /* follows the finger; the snap gets the ease back */
92
105
 
93
106
  /* the body — everything under the head, the one thing in a sheet that
@@ -113,6 +126,31 @@
113
126
  cut is a share of the SHEET's height, not of the body's. */
114
127
  scroll-padding-bottom: var(--_cover);
115
128
  }
129
+ /* ---- the sheet's foot: the button that FINISHES the sheet, on the sheet (Lh, 2026-09-20) ----
130
+ A screen with a sheet has no bar in front of it: the button that completes what the sheet asks — the
131
+ page's conclusion — is a .card__foot, the sheet's last child, on the sheet's own material. The sheet is
132
+ full height at every state, so its real bottom edge is under the cut unless it stands at full. So the
133
+ foot is a layer as tall as the sheet, its buttons at its bottom, slid UP by the very share the sheet
134
+ slid down (--_y, + the drag): the two cancel, and the buttons stand on the box's bottom edge at half,
135
+ 3q and full alike while the sheet slides behind them. A percentage of the same height on both — not a
136
+ length — so a box that resizes (the keyboard) moves them as one, with nothing to catch up. The layer
137
+ takes no taps, its buttons do; the strip behind them is the sheet's ground, --_foot tall, and the body
138
+ stops that far above the sheet's edge. At peek there is only the handle and the title: the foot is gone.
139
+ One row, ≤ 2 buttons. <div class="card__foot"><button class="btn btn--primary btn--block">…</button></div> */
140
+ .sheet { --_foot: calc(var(--card-gap) + var(--btn-h) + var(--card-pad)); }
141
+ .sheet > .card__foot {
142
+ position: absolute; inset: calc(-1 * var(--border-w)) 0 0; z-index: 1; /* the sheet's border box: the same height the sheet's own % reads */
143
+ align-items: end;
144
+ padding: 0 var(--card-pad) var(--card-pad);
145
+ background: linear-gradient(var(--card-bg), var(--card-bg)) bottom / 100% var(--_foot) no-repeat;
146
+ pointer-events: none;
147
+ translate: 0 calc(-1 * var(--_y) - var(--_drag, 0rem));
148
+ transition: translate var(--motion-sheet) ease, opacity var(--motion-sheet) ease, visibility 0s;
149
+ }
150
+ .sheet > .card__foot > * { pointer-events: auto; }
151
+ .sheet:has(> .card__foot) > .sheet__body { margin-bottom: calc(var(--_foot) - var(--card-pad)); } /* the body stops above the strip (the card's own padding is the rest) */
152
+ .sheet.is-dragging > .card__foot { transition: none; }
153
+ :is(.sheet--peek, .sheet--away) > .card__foot { opacity: 0; visibility: hidden; transition: translate var(--motion-sheet) ease, opacity var(--motion-sheet) ease, visibility 0s linear var(--motion-sheet); }
116
154
  .sheet__body > * { min-width: 0; } /* as .card > *: children shrink to the track, never widen it */
117
155
  .sheet__body:last-child { margin-bottom: calc(-1 * var(--card-pad)); padding-bottom: var(--card-pad); }
118
156
 
package/css/step.css CHANGED
@@ -1,14 +1,16 @@
1
1
  /* ============================================================
2
2
  cardds/step.css — .step: one screen, one task.
3
3
  What the member reads (words on the base — not a card, Lh 2026-09-16), what the member does when the
4
- step asks for it (a sheet, sheet.css), and the screen's one
5
- move (.action-bar, actions.css) OUTSIDE the step, below it.
4
+ step asks for it (a sheet, sheet.css). The move: a page of words has
5
+ an .action-bar (actions.css) OUTSIDE the step, below it — the base's;
6
+ a step with a sheet has none: the sheet's own .card__foot finishes it
7
+ (see "LAYERS" below).
6
8
 
7
9
  <section class="step">
8
10
  <section class="base-content">…the words…</section> ← first
9
- <article class="card sheet sheet--half">…the controls…</article> ← last, optional
11
+ <article class="card sheet sheet--half">…the controls… <div class="card__foot">…</div></article> ← last, optional
10
12
  </section>
11
- <div class="action-bar">…</div>
13
+ <div class="action-bar">…</div> ← only when there is no sheet
12
14
 
13
15
  The step is the box the sheet slides in. It fills the screen
14
16
  column between the top bar's reserve (.screen already pads for
@@ -58,6 +60,10 @@
58
60
  bar. Any parent that hosts the bar counts — <body>, an app shell, a demo
59
61
  frame — the bar is fixed to the viewport wherever it sits. */
60
62
  :has(> .action-bar) .step { margin-bottom: calc(var(--bar-reserve) + var(--kb, 0rem)); }
63
+ /* LAYERS (Lh, 2026-09-20): the base, then the sheet over it, then the top bar. A bar is the BASE's — the
64
+ move of a page of words — so a step with a sheet has NO bar: the sheet finishes on its own .card__foot
65
+ (sheet.css), and a form lives whole in ONE sheet, never item by item on the base. Should a bar be written
66
+ beside a sheet anyway it stands down (actions.css + cardds.js) — a net for a mistake, not a layout. */
61
67
 
62
68
  /* the reading block: a .base-content (base.css) — the same words-on-the-base
63
69
  block as anywhere else (it was .step__card, and a .card before 2026-09-16;
@@ -67,14 +73,23 @@
67
73
  gets the flex rule: it hugs its content, and when the column runs out (a
68
74
  short phone, large text) it shrinks and scrolls inside itself instead of
69
75
  pushing the bar or the sheet */
70
- .step > .base-content {
76
+ .step > .base-content, .step > .pages > .base-content {
71
77
  --card-pad: 0rem; /* published for .bleed: nothing to cancel */
72
78
  --card-gap: var(--sp-4);
73
79
  gap: var(--card-gap);
74
80
  padding-block: 0; /* --_lead carries the top; the bar's room is the step's */
75
81
  align-content: start;
76
82
  color: var(--on-surface);
83
+ }
84
+ /* the reading block is the .base-content — or the .pages that holds several of them, a walk of words
85
+ (card.css). --sheet-room: the strip a CALLED sheet keeps at peek (sheet.css, "called sheets"), published
86
+ on the step by cardds.js while one is on the screen, so the buttons under it can scroll clear of it;
87
+ nothing without one. */
88
+ .step > :is(.base-content, .pages) {
89
+ padding-bottom: var(--sheet-room, 0rem);
77
90
  flex: 0 1 auto;
91
+ }
92
+ .step > :is(.base-content, .pages) {
78
93
  min-height: 0;
79
94
  margin: var(--_lead, 0) var(--screen-pad) 0; /* the screen's gap, moved in here from above the step; the inset, taken back from the bleed */
80
95
  overflow-y: auto;