@usableapp/cardds 0.1.6 → 0.2.0

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,8 +105,8 @@ cp -R ~/Sites/cardds/css ~/Sites/cardds/fonts ~/Sites/cardds/cardds.css \
105
105
  folder next to it, and `fonts/` beside that (`css/fonts.css` reaches the faces
106
106
  as `../fonts/`). `icons.svg` is referenced by path from your markup
107
107
  (`<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.
108
+ Then follow the rules below — the short version is: tune your own `theme.css`
109
+ (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
110
 
111
111
  The repo is private, so cloning needs an account with access (`everysundays`
112
112
  on GitHub) — set that up as an SSH host alias if you already use a different
@@ -297,6 +297,7 @@ between at 40% down the screen (see *Anatomy*, "Where the centre is"):
297
297
  |---|---|---|
298
298
  | `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
299
  | `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 |
300
+ | `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
301
  | `css/palette.css` | **color settings — palettes as role-token overrides** | yes, this one |
301
302
  | `css/base.css` | reset, surface, typography classes, `.base-content` | rarely |
302
303
  | `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 |
@@ -308,10 +309,10 @@ between at 40% down the screen (see *Anatomy*, "Where the centre is"):
308
309
  | `css/actions.css` | topbar, chips, buttons (`.btn--xl`), `.dropdown` (the standard select: pill + `.menu` popover), `.action-bar` (+ `--pager` for previous/next, two round icon buttons, never labels; `--tiers` for a control row above the buttons), `.fab` | when adding actions |
309
310
  | `css/numbers.css` | `.ring` gauge, `.track` steps, `.card--band` + `.band-stack`, `.dotgrid`, `.picker`, `.badge`, `.bars` | big numbers |
310
311
  | `css/people.css` | `.avatar` (sm/lg/xl, outline, add, on, halo), `.avatar-stack`, `.avatar-pick` | people |
311
- | `css/choice.css` | `.check`, `.toggle`, `.chip--toggle`, `.chip-grid` + `.chip--pick`, `.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 |
312
+ | `css/choice.css` | `.check`, `.toggle`, `.chip-grid` + `.chip--pick` (+ `--sign`), `.day-strip`, `.calendar`, `.mood`, `.pin`, `.composer`, `.slider` | choice controls |
313
+ | `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
314
  | `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 |
315
+ | `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
316
  | `icons.svg` | Lucide sprite (ISC), 56 minimal stroke icons | add symbols as needed |
316
317
  | `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
318
  | `gallery/` | the gallery site: `npm run dev` → `/` every story, `/:Name` one component (`gallery/src/Gallery.tsx` globs `.design-sync/previews/`) | the demo |
@@ -502,6 +503,63 @@ Demo: the *skeleton* block at the top of `elements.html`.
502
503
  `--on-accent` to its own ink and bg, so every accent consumer (buttons,
503
504
  checks, toggles, rings, selected days) inverts without extra rules.
504
505
 
506
+ ## A project's theme
507
+
508
+ A project retunes cardds from ONE file of its own, and nothing else. Copy
509
+ `css/theme.template.css` into the project as `theme.css`, load it **after**
510
+ `cardds.css`, change values:
511
+
512
+ ```css
513
+ :root {
514
+ --sp-base: 0.3rem; /* roomier */
515
+ --r-base: 0.5rem; /* tighter corners */
516
+ --color-surface: #faf6ee; --color-ink: #1f1b16; --color-paper: #fff;
517
+ --color-brand: #c2410c; --color-on-brand: #fff;
518
+ }
519
+ ```
520
+
521
+ ```html
522
+ <html data-palette="own"> <!-- colours of your own = coloured mode; any value no stock palette uses -->
523
+ ```
524
+
525
+ That file is the whole public surface:
526
+
527
+ | what | tokens | moves |
528
+ |---|---|---|
529
+ | 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 |
530
+ | type | `--fs-base` (1rem = 16) | every `--fs-*`, `--badge`, `--icon-xs` |
531
+ | corners | `--r-base` (1rem = 16) | every `--r-*`; `0` = square. `--r-chip` stays fully round |
532
+ | 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) |
533
+ | faces | `--font-ui`, `--font-display` | |
534
+ | 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` |
535
+ | 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 |
536
+
537
+ Rules:
538
+
539
+ - **A base, never a multiplier.** Every size token is `calc(base × multiplier)`
540
+ in `tokens.css`; the multipliers are the system's proportions. A project
541
+ never sets a derived token (`--sp-4`, `--fs-h1`, `--r-card`, `--tap`…), a
542
+ private one (`--_*`), or a published one (`--card-pad`, `--bar-reserve`, `--sheet-*`).
543
+ - **On `:root` only.** The derived tokens are computed where they are declared
544
+ (`:root`), so a base set on a subtree does not reach them.
545
+ - **After `cardds.css`.** A stock palette's block and a project's `:root` weigh
546
+ the same; the later file wins.
547
+ - **Coloured mode is the attribute.** `data-palette`, whatever its value, is what
548
+ trades the wireframe's borders for the card's shadow and the controls' soft
549
+ edge (`palette.css`, `forms.css`). Colours of your own → `<html data-palette="own">`.
550
+ Without it the colours still apply, on wireframe borders.
551
+ - **cardds does not judge the colours.** No contrast check, no warning — a
552
+ theme's values are the project's call. The derivations are only starting points;
553
+ any role can be set outright.
554
+ - `--text-scale` (below) is the member's setting, not the theme's: it scales
555
+ everything, the bases included.
556
+
557
+ Try it live: the gallery's nav (`npm run dev`) has a slider per size base, and
558
+ palette `own` shows a colour well per colour base.
559
+ `tests/tokens.spec.js` holds the defaults to the px they always were and the
560
+ template to the defaults; the step / centre geometry runs at two extreme
561
+ themes (`tight`, `loose`).
562
+
505
563
  ## New palette
506
564
 
507
565
  Copy any block in `palette.css`, rename, retune:
@@ -517,9 +575,10 @@ Copy any block in `palette.css`, rename, retune:
517
575
 
518
576
  ## Scale: everything is rem
519
577
 
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
578
+ Every length token resolves to rem — a multiplier × one of four rem bases
579
+ (`--sp-4` = `--sp-base` × 4 = 1rem, `--tap` = `--tap-base` = 3rem, `--r-card` =
580
+ `--r-base` × 2, `--fs-h1` = `--fs-base` × 1.75; see "A project's theme") — so the
581
+ 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
582
  default) and steps narrow phones (≤360px) to `93.75%`, which keeps the 25rem
524
583
  column, its 48px taps and its type in proportion on 320–360 screens. Only
525
584
  `--border-w`, `--rule-w` and the focus ring stay in px, so edges stay crisp.
package/css/actions.css CHANGED
@@ -58,7 +58,7 @@
58
58
  .appbar__sub { font: var(--type-caption); color: var(--on-surface-muted); }
59
59
  /* the bar's trailing controls (one or two: a labelled switch chip, a wallet button — home,
60
60
  Lh 2026-09-13) stand on the tap floor: a chip that is a switch grows to --tap here */
61
- .appbar .chip--toggle { min-height: var(--tap); }
61
+ .appbar .chip--pick { min-height: var(--tap); }
62
62
 
63
63
  /* ---- icon button (round) ---- */
64
64
  .icon-btn {
package/css/base.css CHANGED
@@ -8,7 +8,7 @@
8
8
  in cards, rows and bodies stay selectable (an address, a code, a number
9
9
  the member wants to copy), and inputs are untouched. Native buttons and
10
10
  links are matched by element; the rest by the class that makes them one */
11
- button, [role="button"], [role="tab"], [role="option"], [role="menuitem"], a.btn, a.icon-btn, a.chip, label.chip, .chip--toggle, .chip--pick,
11
+ button, [role="button"], [role="tab"], [role="option"], [role="menuitem"], a.btn, a.icon-btn, a.chip, label.chip, .chip--pick,
12
12
  .segment__item, .day, .calendar__day, .mood__opt, .pin__cell, .slider, .picker__item, .float-bar__item, .menu__item, .sheet__handle, .dropdown__trigger, .track__step, .pager__at {
13
13
  user-select: none;
14
14
  -webkit-user-select: none;
package/css/choice.css CHANGED
@@ -71,27 +71,11 @@
71
71
  .toggle:checked::after { translate: calc(var(--_w) - var(--_h)) 0; background: var(--on-accent); opacity: 1; }
72
72
  .toggle:focus-visible { outline: var(--focus); outline-offset: var(--sp-1); }
73
73
 
74
- /* ---- toggle chip: interest / filter picker — shows + off, × on ----
75
- <label class="chip chip--toggle"><input type="checkbox">Nature</label> */
76
- .chip--toggle { cursor: pointer; padding-right: var(--sp-2); }
77
- .chip--toggle > input { appearance: none; position: absolute; width: 1px; height: 1px; margin: 0; opacity: 0; }
78
- .chip--toggle::after {
79
- content: "";
80
- width: var(--icon-xs);
81
- height: var(--icon-xs);
82
- flex: none;
83
- background: currentColor;
84
- mask: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="black" stroke-width="2.5" stroke-linecap="round"><path d="M5 12h14"/><path d="M12 5v14"/></svg>') center / contain no-repeat;
85
- -webkit-mask: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="black" stroke-width="2.5" stroke-linecap="round"><path d="M5 12h14"/><path d="M12 5v14"/></svg>') center / contain no-repeat;
86
- }
87
- .chip--toggle:has(:checked) { background: var(--accent); color: var(--on-accent); border-color: transparent; }
88
- /* checked: the same plus turns 45° into an x — one mask, one rotate, no swap (Lh 2026-09-16) */
89
- .chip--toggle:has(:checked)::after { rotate: 45deg; }
90
- @media (prefers-reduced-motion: no-preference) { .chip--toggle::after { transition: rotate var(--motion-slide); } }
91
- .chip--toggle:has(:focus-visible) { outline: var(--focus); outline-offset: var(--sp-1); }
92
-
93
- /* ---- chip grid: pick one slot / many options in a grid ----
94
- <div class="chip-grid"><label class="chip chip--pick"><input type="radio" name="t">9:00</label>…</div> */
74
+ /* ---- chip pick: a chip that is a choice — a native radio/checkbox inside the label, the picked one filled with accent.
75
+ Full-width in a .chip-grid (one slot / many options), or hugging its word on a .chip-row with --sign (an interest
76
+ picker: + off, × on). Was two things, .chip--toggle and .chip--pick — one chip, one state, the sign is a variant (Lh 2026-09-16).
77
+ <div class="chip-grid"><label class="chip chip--pick"><input type="radio" name="t">9:00</label>…</div>
78
+ <div class="chip-row"><label class="chip chip--pick chip--sign"><input type="checkbox">Nature</label>…</div> */
95
79
  .chip-grid {
96
80
  display: grid;
97
81
  grid-template-columns: repeat(auto-fill, minmax(min(5.5rem, 100%), 1fr));
@@ -102,10 +86,23 @@
102
86
  Matters most in scripts without word spaces (Thai): min-content is the
103
87
  whole string, tracks get wide, and hugging chips leave a ragged grid. */
104
88
  .chip-grid > .chip { justify-self: stretch; justify-content: center; min-height: var(--btn-h); padding-inline: var(--sp-2); position: relative; }
105
- .chip--pick { cursor: pointer; }
89
+ .chip--pick { cursor: pointer; position: relative; } /* the input covers the whole chip, on a row as in a grid */
106
90
  .chip--pick > input { appearance: none; position: absolute; inset: 0; margin: 0; opacity: 0; cursor: pointer; }
107
91
  .chip--pick:has(:checked) { background: var(--accent); color: var(--on-accent); border-color: transparent; }
108
92
  .chip--pick:has(:focus-visible) { outline: var(--focus); outline-offset: var(--sp-1); }
93
+ /* --sign: the trailing + that turns 45° into an × when picked — one mask, one rotate, no swap (Lh 2026-09-16) */
94
+ .chip--sign { padding-right: var(--sp-2); }
95
+ .chip--sign::after {
96
+ content: "";
97
+ width: var(--icon-xs);
98
+ height: var(--icon-xs);
99
+ flex: none;
100
+ background: currentColor;
101
+ mask: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="black" stroke-width="2.5" stroke-linecap="round"><path d="M5 12h14"/><path d="M12 5v14"/></svg>') center / contain no-repeat;
102
+ -webkit-mask: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="black" stroke-width="2.5" stroke-linecap="round"><path d="M5 12h14"/><path d="M12 5v14"/></svg>') center / contain no-repeat;
103
+ }
104
+ .chip--sign:has(:checked)::after { rotate: 45deg; }
105
+ @media (prefers-reduced-motion: no-preference) { .chip--sign::after { transition: rotate var(--motion-slide); } }
109
106
 
110
107
  /* ---- day strip: a week, one day selected ----
111
108
  <div class="day-strip" role="tablist"><button class="day" role="tab" aria-selected="true"><span class="day__name">Thu</span><span class="day__num">16</span></button>…</div> */
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); }
@@ -0,0 +1,49 @@
1
+ /* ============================================================
2
+ cardds/theme.template.css — A PROJECT'S THEME. Copy, rename, retune.
3
+
4
+ Not part of the bundle: copy this file into the project as its own
5
+ `theme.css` and load it AFTER cardds.css. It is the whole public
6
+ surface of the system — the four size bases and the colour roles.
7
+ Everything else in tokens.css is derived from these; a project never
8
+ sets a derived token (--sp-4, --fs-h1, --r-card, --tap, --sheet-*…)
9
+ and never a multiplier.
10
+
11
+ The values below are the defaults (the wireframe): as shipped, this
12
+ file changes nothing (tests/tokens.spec.js holds it to that).
13
+
14
+ On :root only. The size tokens are computed where tokens.css declares
15
+ them (:root), so a base set on a subtree does not reach them.
16
+ A stock palette's colours and this file's have the same weight — the
17
+ later file wins, so: after cardds.css.
18
+ ============================================================ */
19
+
20
+ :root {
21
+ /* ---- size: four bases, the multipliers stay the system's ---- */
22
+ --sp-base: 0.25rem; /* 4 the room BETWEEN things: every --sp-N = base × N, so paddings and gaps */
23
+ --fs-base: 1rem; /* 16 the type: every --fs-*, the count badge, the inline icon */
24
+ --r-base: 1rem; /* 16 the corners: every --r-* (0 = square; chips and buttons stay fully round) */
25
+ --tap-base: 3rem; /* 48 the size of THINGS: touch targets, control heights, icons, avatars, ring, tiles
26
+ (a touch target never goes under 44px, whatever this says) */
27
+ /* the whole system at once (a member's text-size setting) is --text-scale, a unitless number — not a theme's job */
28
+
29
+ /* ---- faces ---- */
30
+ --font-ui: "PK Nonthaburi", -apple-system, system-ui, sans-serif;
31
+ --font-display: "FC Pride", var(--font-ui);
32
+
33
+ /* ---- colour: five bases, the roles derive (tone 2, the muted inks, the tints) ----
34
+ No default — unset = the wireframe. With colours of your own, put the page in coloured mode:
35
+ <html data-palette="own"> (any value no stock palette uses): cards take a shadow for their
36
+ wireframe border, controls a soft edge. */
37
+ /* --color-surface: #f9e8dc; */ /* the app's ground */
38
+ /* --color-ink: #241f1b; */ /* the words */
39
+ /* --color-paper: #ffffff; */ /* the plain card, neutral buttons and fields */
40
+ /* --color-brand: #ef4e0b; */ /* the primary action, the strong card */
41
+ /* --color-on-brand: #fff4ec; */ /* the words on the brand */
42
+
43
+ /* ---- colour roles: only where a derivation is not what you want — a role set here wins over it ----
44
+ --surface --on-surface --on-surface-muted
45
+ --card-1-bg --card-1-ink --card-2-bg --card-2-ink --card-3-bg --card-3-ink (three card tones)
46
+ --card-border --card-muted --card-2-muted
47
+ --accent --on-accent --action-bg --action-ink --action-border
48
+ --pos --neg --dot-a --dot-b */
49
+ }
package/css/tokens.css CHANGED
@@ -15,56 +15,69 @@
15
15
  --screen-w: 25rem; /* 400: mobile column — .screen, .action-bar, .sheet-stage */
16
16
  --screen-centre: 40%; /* the human centre of a screen: the middle of its top four fifths — the bottom fifth is the thumb's (Lh, 2026-09-10). .card--centre sits here */
17
17
 
18
- /* ---- spacing (4pt grid) ---- */
19
- --sp-0: 0.125rem; /* 2 hairline gaps (title→meta) */
20
- --sp-1: 0.25rem; /* 4 */
21
- --sp-2: 0.5rem; /* 8 */
22
- --sp-3: 0.75rem; /* 12 */
23
- --sp-4: 1rem; /* 16 */
24
- --sp-5: 1.25rem; /* 20 */
25
- --sp-6: 1.5rem; /* 24 */
26
- --sp-8: 2rem; /* 32 */
27
- --sp-10: 2.5rem; /* 40 */
28
- --sp-12: 3rem; /* 48 */
18
+ /* ---- the four bases: what a project retunes (Lh, 2026-09-20) ----
19
+ Every size token below is multiplier × its family's base. A project sets a BASE
20
+ and never a multiplier, so the proportions of the system hold at any size.
21
+ The px in the comments are at these defaults (1rem = 16px). */
22
+ --sp-base: 0.25rem; /* 4 spacing: --sp-N = base × N */
23
+ --fs-base: 1rem; /* 16 type: --fs-* */
24
+ --r-base: 1rem; /* 16 radius: --r-* (--r-chip stays fully round) */
25
+ --tap-base: 3rem; /* 48 the size of THINGS: touch targets and control heights (--tap*, --btn-h, --chip-h, --bar-h,
26
+ --header-h, --fab), icons, avatars, dots, the ring and the tiles. --sp-base is the room BETWEEN things.
27
+ Text-holders follow the type instead: --badge and --icon-xs (inline with captions) are on --fs-base.
28
+ Not on a base: --screen-w (the column), borders / rules / focus (px, crisp), shadows, motion. */
29
+
30
+ /* ---- spacing (4pt grid): the number in the name is the multiplier ---- */
31
+ --sp-0: calc(var(--sp-base) * 0.5); /* 2 hairline gaps (title→meta) */
32
+ --sp-1: var(--sp-base); /* 4 */
33
+ --sp-2: calc(var(--sp-base) * 2); /* 8 */
34
+ --sp-3: calc(var(--sp-base) * 3); /* 12 */
35
+ --sp-4: calc(var(--sp-base) * 4); /* 16 */
36
+ --sp-5: calc(var(--sp-base) * 5); /* 20 */
37
+ --sp-6: calc(var(--sp-base) * 6); /* 24 */
38
+ --sp-8: calc(var(--sp-base) * 8); /* 32 */
39
+ --sp-10: calc(var(--sp-base) * 10); /* 40 */
40
+ --sp-12: calc(var(--sp-base) * 12); /* 48 */
29
41
 
30
42
  /* ---- touch & control sizes ---- */
31
- --tap: 3rem; /* 48 primary touch target */
32
- --tap-sm: 2.75rem; /* 44 minimum touch target (Apple HIG floor) */
33
- --tap-lg: 3.5rem; /* 56 hero controls: track steps, send, field with action */
34
- --btn-h: 3.25rem; /* 52 button height */
35
- --btn-xl-h: 5.5rem; /* 88 the one giant CTA */
36
- --chip-h: 2.375rem; /* 38 chip height */
37
- --bar-h: 4rem; /* 64 float bar, day cell, deck actions */
38
- --header-h: 3.75rem;/* 60 top bar — .topbar and .appbar alike */
43
+ --tap-floor: 2.75rem; /* 44 the HIG floor — no --tap-base takes a touch target under it */
44
+ --tap: max(var(--tap-floor), var(--tap-base)); /* 48 primary touch target */
45
+ --tap-sm: max(var(--tap-floor), calc(var(--tap-base) * 11 / 12)); /* 44 minimum touch target */
46
+ --tap-lg: max(var(--tap-floor), calc(var(--tap-base) * 7 / 6)); /* 56 hero controls: track steps, send, field with action */
47
+ --btn-h: calc(var(--tap-base) * 13 / 12); /* 52 button height */
48
+ --btn-xl-h: calc(var(--tap-base) * 11 / 6); /* 88 the one giant CTA */
49
+ --chip-h: calc(var(--tap-base) * 19 / 24); /* 38 chip height */
50
+ --bar-h: calc(var(--tap-base) * 4 / 3); /* 64 float bar, day cell, deck actions */
51
+ --header-h: calc(var(--tap-base) * 5 / 4); /* 60 top bar — .topbar and .appbar alike */
39
52
  --screen-pad: var(--sp-4); /* 16 the screen's inset — .sheet-stack cancels it */
40
- --map-ring: 10rem; /* the rough-area ring on a map (sheet.css .map__ring) — a neighbourhood, not a house */
41
- --fab: 4.5rem; /* 72 square floating action */
42
- --badge: 1.625rem; /* 26 count badge */
43
- --dot: 0.625rem; /* 10 signal dot */
44
- --dot-xs: 0.375rem; /* 6 marker dot */
53
+ --map-ring: calc(var(--tap-base) * 10 / 3); /* the rough-area ring on a map (sheet.css .map__ring) — a neighbourhood, not a house */
54
+ --fab: calc(var(--tap-base) * 3 / 2); /* 72 square floating action */
55
+ --badge: calc(var(--fs-base) * 1.625); /* 26 count badge */
56
+ --dot: calc(var(--tap-base) * 5 / 24); /* 10 signal dot */
57
+ --dot-xs: calc(var(--tap-base) / 8); /* 6 marker dot */
45
58
 
46
59
  /* ---- icons (icons.svg sprite, Lucide) ---- */
47
- --icon: 1.75rem; /* 28 default */
48
- --icon-sm: 1.75rem; /* one size everywhere; retune if small contexts appear */
49
- --icon-lg: 2rem; /* 32 empty states */
50
- --icon-xs: 1rem; /* 16 inline with captions/chips only (row meta, link arrows) */
60
+ --icon: calc(var(--tap-base) * 7 / 12); /* 28 default */
61
+ --icon-sm: calc(var(--tap-base) * 7 / 12); /* one size everywhere; retune if small contexts appear */
62
+ --icon-lg: calc(var(--tap-base) * 2 / 3); /* 32 empty states */
63
+ --icon-xs: var(--fs-base); /* 16 inline with captions/chips only (row meta, link arrows) */
51
64
 
52
65
  /* ---- people & big elements ---- */
53
- --avatar-sm: 2.5rem; /* 40 row lead */
54
- --avatar: 3.5rem; /* 56 card head, person row */
55
- --avatar-lg: 5rem; /* 80 chooser row */
56
- --avatar-xl: 6rem; /* 96 sheet head icon ring */
57
- --ring: 11rem; /* 176 .ring gauge diameter */
58
- --ring-w: 1.375rem; /* 22 .ring stroke */
59
- --tile-min: 7.5rem; /* 120 .tile-grid column floor */
60
- --tile-h: 8.25rem; /* 132 tile / band height */
66
+ --avatar-sm: calc(var(--tap-base) * 5 / 6); /* 40 row lead */
67
+ --avatar: calc(var(--tap-base) * 7 / 6); /* 56 card head, person row */
68
+ --avatar-lg: calc(var(--tap-base) * 5 / 3); /* 80 chooser row */
69
+ --avatar-xl: calc(var(--tap-base) * 2); /* 96 sheet head icon ring */
70
+ --ring: calc(var(--tap-base) * 11 / 3); /* 176 .ring gauge diameter */
71
+ --ring-w: calc(var(--tap-base) * 11 / 24); /* 22 .ring stroke */
72
+ --tile-min: calc(var(--tap-base) * 5 / 2); /* 120 .tile-grid column floor */
73
+ --tile-h: calc(var(--tap-base) * 11 / 4); /* 132 tile / band height */
61
74
 
62
75
  /* ---- radius ---- */
63
76
  --r-chip: 999px; /* pills, chips, buttons — always fully round */
64
- --r-card: 2rem; /* 32 the one card radius: every card, row, tile, menu, sheet, fab */
65
- --r-media: 1rem; /* 16 images inside cards */
66
- --r-control: 0.875rem; /* 14 fields, thumbs, menu rows */
67
- --r-badge: 0.625rem; /* 10 tile badge, bars */
77
+ --r-card: calc(var(--r-base) * 2); /* 32 the one card radius: every card, row, tile, menu, sheet, fab */
78
+ --r-media: var(--r-base); /* 16 images inside cards */
79
+ --r-control: calc(var(--r-base) * 0.875); /* 14 fields, thumbs, menu rows */
80
+ --r-badge: calc(var(--r-base) * 0.625); /* 10 tile badge, bars */
68
81
 
69
82
  /* ---- borders / rules / elevation (px on purpose: crisp at any scale) ---- */
70
83
  --border-w: 1.5px;
@@ -85,22 +98,22 @@
85
98
  --font-display: "FC Pride", var(--font-ui); /* the character face for headings and big numbers (fonts.css) */
86
99
  --font-mono: "SF Mono", "JetBrains Mono", ui-monospace, monospace;
87
100
 
88
- --fs-xs: 0.6875rem; /* 11 overline */
89
- --fs-caption: 0.75rem; /* 12 */
90
- --fs-label: 0.8125rem; /* 13 */
91
- --fs-sm: 0.875rem; /* 14 chips, segments */
92
- --fs-body: 0.9375rem; /* 15 */
93
- --fs-md: 1rem; /* 16 buttons */
94
- --fs-title: 1.0625rem; /* 17 */
95
- --fs-lg: 1.25rem; /* 20 day numbers, tile values */
96
- --fs-h2: 1.375rem; /* 22 */
97
- --fs-2xl: 1.5rem; /* 24 composer, small ring */
98
- --fs-h1: 1.75rem; /* 28 */
99
- --fs-3xl: 2rem; /* 32 xl button */
100
- --fs-display: clamp(2.125rem, 9vw, 2.625rem); /* 34–42 */
101
- --fs-stat: clamp(1.875rem, 8vw, 2.375rem); /* 30–38 big numbers */
102
- --fs-hero: 3.5rem; /* 56 the one number on a sheet */
103
- --fs-giant: clamp(3rem, 15vw, 4rem); /* 48–64 data bands */
101
+ --fs-xs: calc(var(--fs-base) * 0.6875); /* 11 overline */
102
+ --fs-caption: calc(var(--fs-base) * 0.75); /* 12 */
103
+ --fs-label: calc(var(--fs-base) * 0.8125); /* 13 */
104
+ --fs-sm: calc(var(--fs-base) * 0.875); /* 14 chips, segments */
105
+ --fs-body: calc(var(--fs-base) * 0.9375); /* 15 */
106
+ --fs-md: var(--fs-base); /* 16 buttons */
107
+ --fs-title: calc(var(--fs-base) * 1.0625); /* 17 */
108
+ --fs-lg: calc(var(--fs-base) * 1.25); /* 20 day numbers, tile values */
109
+ --fs-h2: calc(var(--fs-base) * 1.375); /* 22 */
110
+ --fs-2xl: calc(var(--fs-base) * 1.5); /* 24 composer, small ring */
111
+ --fs-h1: calc(var(--fs-base) * 1.75); /* 28 */
112
+ --fs-3xl: calc(var(--fs-base) * 2); /* 32 xl button */
113
+ --fs-display: clamp(calc(var(--fs-base) * 2.125), 9vw, calc(var(--fs-base) * 2.625)); /* 34–42 */
114
+ --fs-stat: clamp(calc(var(--fs-base) * 1.875), 8vw, calc(var(--fs-base) * 2.375)); /* 30–38 big numbers */
115
+ --fs-hero: calc(var(--fs-base) * 3.5); /* 56 the one number on a sheet */
116
+ --fs-giant: clamp(calc(var(--fs-base) * 3), 15vw, calc(var(--fs-base) * 4)); /* 48–64 data bands */
104
117
 
105
118
  /* level weight size / line-height family */
106
119
  --type-display: 800 var(--fs-display) / 1.15 var(--font-display); /* leading: Thai marks stack to 1.1em up and 0.3em down in FC Pride — 1.02 clipped them (2026-09-10) */
@@ -120,35 +133,53 @@
120
133
  /* ============================================================
121
134
  COLOR ROLES — wireframe defaults (grayscale).
122
135
  palette.css overrides these under [data-palette="…"].
136
+
137
+ THE FIVE COLOUR BASES (Lh, 2026-09-20) — a project's own colours, the
138
+ same idea as the size bases: set a base, the roles follow.
139
+ --color-surface the app's ground
140
+ --color-ink the words
141
+ --color-paper the plain card, neutral buttons and fields
142
+ --color-brand the primary action, the strong card, the signal dot
143
+ --color-on-brand the words on the brand
144
+ They have NO default: unset, every role below is its exact wireframe
145
+ value. That is what the fallbacks are for — a private mix (--_*) that
146
+ reads an unset base is invalid, so var(--_x, #grey) lands on the grey.
147
+ Set, the roles derive: tone 2 = the brand washed into the surface, the
148
+ muted inks = the ink washed into their ground. A role set directly
149
+ (a palette, or a project's theme.css) always wins over its derivation.
123
150
  ============================================================ */
151
+ --_muted-on-surface: color-mix(in srgb, var(--color-ink) 66%, var(--color-surface, #f2f2f0));
152
+ --_muted-on-paper: color-mix(in srgb, var(--color-ink) 66%, var(--color-paper, #ffffff));
153
+ --_tone-2: color-mix(in srgb, var(--color-brand) 14%, var(--color-surface, #f2f2f0));
154
+ --_muted-on-tone-2: color-mix(in srgb, var(--color-ink, #141414) 72%, var(--_tone-2));
124
155
 
125
156
  /* app background ("earth-toned surface or shiny color, up to the app") */
126
- --surface: #f2f2f0;
127
- --on-surface: #141414;
128
- --on-surface-muted: #6b6b6b;
157
+ --surface: var(--color-surface, #f2f2f0);
158
+ --on-surface: var(--color-ink, #141414);
159
+ --on-surface-muted: var(--_muted-on-surface, #6b6b6b);
129
160
 
130
161
  /* card boundaries — 3 slots so stacked/adjacent cards can differ */
131
- --card-1-bg: #ffffff; --card-1-ink: #141414;
132
- --card-2-bg: #e6e6e3; --card-2-ink: #141414;
133
- --card-3-bg: #141414; --card-3-ink: #ffffff;
162
+ --card-1-bg: var(--color-paper, #ffffff); --card-1-ink: var(--color-ink, #141414);
163
+ --card-2-bg: var(--_tone-2, #e6e6e3); --card-2-ink: var(--color-ink, #141414);
164
+ --card-3-bg: var(--color-brand, #141414); --card-3-ink: var(--color-on-brand, #ffffff);
134
165
 
135
166
  --card-border: #141414; /* wireframe: visible outline; palettes may set transparent */
136
- --card-muted: #6b6b6b; /* secondary text inside cards (the default, tone-1 card) */
137
- --card-2-muted: #5f5f5f; /* the same text on the tone-2 card — 5.1:1 on its grey (#6b6b6b is 4.3:1, under AA);
167
+ --card-muted: var(--_muted-on-paper, #6b6b6b); /* secondary text inside cards (the default, tone-1 card) */
168
+ --card-2-muted: var(--_muted-on-tone-2, #5f5f5f); /* the same text on the tone-2 card — 5.1:1 on its grey (#6b6b6b is 4.3:1, under AA);
138
169
  a palette whose tone-2 ground is strong
139
170
  sets this to its tone-2 ink — a muted shade cannot reach AA there (clay) */
140
171
 
141
172
  /* actions */
142
- --accent: #141414; /* primary CTA fill */
143
- --on-accent: #ffffff;
144
- --action-bg: #ffffff; /* neutral buttons/chips/inputs */
145
- --action-ink: #141414;
173
+ --accent: var(--color-brand, #141414); /* primary CTA fill */
174
+ --on-accent: var(--color-on-brand, #ffffff);
175
+ --action-bg: var(--color-paper, #ffffff); /* neutral buttons/chips/inputs */
176
+ --action-ink: var(--color-ink, #141414);
146
177
  --action-border: #141414;
147
178
 
148
179
  /* signals (chip dots, deltas) */
149
180
  --pos: #8a8a8a;
150
181
  --neg: #3d3d3d;
151
- --dot-a: #141414;
182
+ --dot-a: var(--color-brand, #141414);
152
183
  --dot-b: #8a8a8a;
153
184
 
154
185
  /* derived tints — read the element's own ink, so they work on any slot */
@@ -4,15 +4,13 @@ export interface ChipProps extends ComponentPropsWithoutRef<'span'> {
4
4
  on?: boolean;
5
5
  /** a leading signal dot: `a` (the accent signal) or `b` (the secondary) */
6
6
  dot?: 'a' | 'b';
7
- /** a speech tail under the chip (a mascot's greeting) */
8
- bubble?: boolean;
9
7
  /** the element: span (default), a link, or a button */
10
8
  as?: 'span' | 'a' | 'button';
11
9
  href?: string;
12
10
  children?: ReactNode;
13
11
  }
14
- /** Chip — a status or attribute tag (with an optional signal Dot), or a small tap target; `on` when it is the live one. For pickers use ChipToggle / ChipPick. */
15
- export declare function Chip({ on, dot, bubble, as, href, className, children, ...rest }: ChipProps): import("react").DetailedReactHTMLElement<{
12
+ /** Chip — a status or attribute tag (with an optional signal Dot), or a small tap target; `on` when it is the live one. For a choice use ChipPick (`sign` for the +/× interest picker). */
13
+ export declare function Chip({ on, dot, as, href, className, children, ...rest }: ChipProps): import("react").DetailedReactHTMLElement<{
16
14
  slot?: string | undefined | undefined;
17
15
  style?: import("react").CSSProperties | undefined;
18
16
  title?: string | undefined | undefined;
@@ -296,26 +294,20 @@ export declare function Chip({ on, dot, bubble, as, href, className, children, .
296
294
  type: string | undefined;
297
295
  className: string;
298
296
  }, HTMLElement>;
299
- export interface ChipToggleProps extends Omit<ComponentPropsWithoutRef<'label'>, 'onChange'> {
300
- checked?: boolean;
301
- defaultChecked?: boolean;
302
- name?: string;
303
- value?: string;
304
- onChange?: React.ChangeEventHandler<HTMLInputElement>;
305
- }
306
- /** ChipToggle — an interest / filter chip: shows + off and × on; the state is a native checkbox inside the label. */
307
- export declare function ChipToggle({ checked, defaultChecked, name, value, onChange, className, children, ...rest }: ChipToggleProps): import("react").JSX.Element;
308
297
  export interface ChipPickProps extends Omit<ComponentPropsWithoutRef<'label'>, 'onChange'> {
309
- /** radio for pick-one (a time slot), checkbox for pick-many (skills) */
298
+ /** radio for pick-one (a time slot), checkbox for pick-many (skills); `sign` defaults to checkbox */
310
299
  type?: 'radio' | 'checkbox';
300
+ /** the +/× at the end: an interest / filter chip on a row, hugging its word (was ChipToggle) */
301
+ sign?: boolean;
311
302
  name?: string;
312
303
  value?: string;
313
304
  checked?: boolean;
314
305
  defaultChecked?: boolean;
315
306
  onChange?: React.ChangeEventHandler<HTMLInputElement>;
316
307
  }
317
- /** ChipPick — a full-width pick target in a ChipGrid (9:00 · 9:30 · …); the picked one fills with accent. */
318
- export declare function ChipPick({ type, name, value, checked, defaultChecked, onChange, className, children, ...rest }: ChipPickProps): import("react").JSX.Element;
308
+ /** ChipPick — a chip that is a choice: full-width in a ChipGrid (9:00 · 9:30 · …), or `sign` on a ChipRow (+ off, × on — the
309
+ * interest picker); the picked one fills with accent. A native radio/checkbox inside the label carries the state. */
310
+ export declare function ChipPick({ type, sign, name, value, checked, defaultChecked, onChange, className, children, ...rest }: ChipPickProps): import("react").JSX.Element;
319
311
  export interface ChipRowProps extends ComponentPropsWithoutRef<'div'> {
320
312
  /** run the row to the card's edges as a one-line scrolling strip (`.bleed`: nowrap, overflow, the hairline ink scrollbar) */
321
313
  bleed?: boolean;
@@ -2,18 +2,15 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { createElement } from 'react';
3
3
  import { cx } from '../cx.js';
4
4
  import { Dot } from './Dot.js';
5
- /** Chip — a status or attribute tag (with an optional signal Dot), or a small tap target; `on` when it is the live one. For pickers use ChipToggle / ChipPick. */
6
- export function Chip({ on, dot, bubble, as, href, className, children, ...rest }) {
5
+ /** Chip — a status or attribute tag (with an optional signal Dot), or a small tap target; `on` when it is the live one. For a choice use ChipPick (`sign` for the +/× interest picker). */
6
+ export function Chip({ on, dot, as, href, className, children, ...rest }) {
7
7
  const el = as ?? (href ? 'a' : 'span');
8
- return createElement(el, { href, type: el === 'button' ? 'button' : undefined, className: cx('chip', on && 'chip--on', bubble && 'chip--bubble', className), ...rest }, dot && _jsx(Dot, { b: dot === 'b' }), children);
8
+ return createElement(el, { href, type: el === 'button' ? 'button' : undefined, className: cx('chip', on && 'chip--on', className), ...rest }, dot && _jsx(Dot, { b: dot === 'b' }), children);
9
9
  }
10
- /** ChipToggle — an interest / filter chip: shows + off and × on; the state is a native checkbox inside the label. */
11
- export function ChipToggle({ checked, defaultChecked, name, value, onChange, className, children, ...rest }) {
12
- return (_jsxs("label", { className: cx('chip chip--toggle', className), ...rest, children: [_jsx("input", { type: "checkbox", checked: checked, defaultChecked: defaultChecked, name: name, value: value, onChange: onChange }), children] }));
13
- }
14
- /** ChipPick — a full-width pick target in a ChipGrid (9:00 · 9:30 · …); the picked one fills with accent. */
15
- export function ChipPick({ type = 'radio', name, value, checked, defaultChecked, onChange, className, children, ...rest }) {
16
- return (_jsxs("label", { className: cx('chip chip--pick', className), ...rest, children: [_jsx("input", { type: type, name: name, value: value, checked: checked, defaultChecked: defaultChecked, onChange: onChange }), children] }));
10
+ /** ChipPick — a chip that is a choice: full-width in a ChipGrid (9:00 · 9:30 · …), or `sign` on a ChipRow (+ off, × on — the
11
+ * interest picker); the picked one fills with accent. A native radio/checkbox inside the label carries the state. */
12
+ export function ChipPick({ type, sign, name, value, checked, defaultChecked, onChange, className, children, ...rest }) {
13
+ return (_jsxs("label", { className: cx('chip chip--pick', sign && 'chip--sign', className), ...rest, children: [_jsx("input", { type: type ?? (sign ? 'checkbox' : 'radio'), name: name, value: value, checked: checked, defaultChecked: defaultChecked, onChange: onChange }), children] }));
17
14
  }
18
15
  /** ChipRow — chips on a wrapping line (or a bleeding strip). */
19
16
  export function ChipRow({ bleed, className, ...rest }) {
package/dist/cardds.css CHANGED
@@ -56,56 +56,69 @@
56
56
  --screen-w: 25rem; /* 400: mobile column — .screen, .action-bar, .sheet-stage */
57
57
  --screen-centre: 40%; /* the human centre of a screen: the middle of its top four fifths — the bottom fifth is the thumb's (Lh, 2026-09-10). .card--centre sits here */
58
58
 
59
- /* ---- spacing (4pt grid) ---- */
60
- --sp-0: 0.125rem; /* 2 hairline gaps (title→meta) */
61
- --sp-1: 0.25rem; /* 4 */
62
- --sp-2: 0.5rem; /* 8 */
63
- --sp-3: 0.75rem; /* 12 */
64
- --sp-4: 1rem; /* 16 */
65
- --sp-5: 1.25rem; /* 20 */
66
- --sp-6: 1.5rem; /* 24 */
67
- --sp-8: 2rem; /* 32 */
68
- --sp-10: 2.5rem; /* 40 */
69
- --sp-12: 3rem; /* 48 */
59
+ /* ---- the four bases: what a project retunes (Lh, 2026-09-20) ----
60
+ Every size token below is multiplier × its family's base. A project sets a BASE
61
+ and never a multiplier, so the proportions of the system hold at any size.
62
+ The px in the comments are at these defaults (1rem = 16px). */
63
+ --sp-base: 0.25rem; /* 4 spacing: --sp-N = base × N */
64
+ --fs-base: 1rem; /* 16 type: --fs-* */
65
+ --r-base: 1rem; /* 16 radius: --r-* (--r-chip stays fully round) */
66
+ --tap-base: 3rem; /* 48 the size of THINGS: touch targets and control heights (--tap*, --btn-h, --chip-h, --bar-h,
67
+ --header-h, --fab), icons, avatars, dots, the ring and the tiles. --sp-base is the room BETWEEN things.
68
+ Text-holders follow the type instead: --badge and --icon-xs (inline with captions) are on --fs-base.
69
+ Not on a base: --screen-w (the column), borders / rules / focus (px, crisp), shadows, motion. */
70
+
71
+ /* ---- spacing (4pt grid): the number in the name is the multiplier ---- */
72
+ --sp-0: calc(var(--sp-base) * 0.5); /* 2 hairline gaps (title→meta) */
73
+ --sp-1: var(--sp-base); /* 4 */
74
+ --sp-2: calc(var(--sp-base) * 2); /* 8 */
75
+ --sp-3: calc(var(--sp-base) * 3); /* 12 */
76
+ --sp-4: calc(var(--sp-base) * 4); /* 16 */
77
+ --sp-5: calc(var(--sp-base) * 5); /* 20 */
78
+ --sp-6: calc(var(--sp-base) * 6); /* 24 */
79
+ --sp-8: calc(var(--sp-base) * 8); /* 32 */
80
+ --sp-10: calc(var(--sp-base) * 10); /* 40 */
81
+ --sp-12: calc(var(--sp-base) * 12); /* 48 */
70
82
 
71
83
  /* ---- touch & control sizes ---- */
72
- --tap: 3rem; /* 48 primary touch target */
73
- --tap-sm: 2.75rem; /* 44 minimum touch target (Apple HIG floor) */
74
- --tap-lg: 3.5rem; /* 56 hero controls: track steps, send, field with action */
75
- --btn-h: 3.25rem; /* 52 button height */
76
- --btn-xl-h: 5.5rem; /* 88 the one giant CTA */
77
- --chip-h: 2.375rem; /* 38 chip height */
78
- --bar-h: 4rem; /* 64 float bar, day cell, deck actions */
79
- --header-h: 3.75rem;/* 60 top bar — .topbar and .appbar alike */
84
+ --tap-floor: 2.75rem; /* 44 the HIG floor — no --tap-base takes a touch target under it */
85
+ --tap: max(var(--tap-floor), var(--tap-base)); /* 48 primary touch target */
86
+ --tap-sm: max(var(--tap-floor), calc(var(--tap-base) * 11 / 12)); /* 44 minimum touch target */
87
+ --tap-lg: max(var(--tap-floor), calc(var(--tap-base) * 7 / 6)); /* 56 hero controls: track steps, send, field with action */
88
+ --btn-h: calc(var(--tap-base) * 13 / 12); /* 52 button height */
89
+ --btn-xl-h: calc(var(--tap-base) * 11 / 6); /* 88 the one giant CTA */
90
+ --chip-h: calc(var(--tap-base) * 19 / 24); /* 38 chip height */
91
+ --bar-h: calc(var(--tap-base) * 4 / 3); /* 64 float bar, day cell, deck actions */
92
+ --header-h: calc(var(--tap-base) * 5 / 4); /* 60 top bar — .topbar and .appbar alike */
80
93
  --screen-pad: var(--sp-4); /* 16 the screen's inset — .sheet-stack cancels it */
81
- --map-ring: 10rem; /* the rough-area ring on a map (sheet.css .map__ring) — a neighbourhood, not a house */
82
- --fab: 4.5rem; /* 72 square floating action */
83
- --badge: 1.625rem; /* 26 count badge */
84
- --dot: 0.625rem; /* 10 signal dot */
85
- --dot-xs: 0.375rem; /* 6 marker dot */
94
+ --map-ring: calc(var(--tap-base) * 10 / 3); /* the rough-area ring on a map (sheet.css .map__ring) — a neighbourhood, not a house */
95
+ --fab: calc(var(--tap-base) * 3 / 2); /* 72 square floating action */
96
+ --badge: calc(var(--fs-base) * 1.625); /* 26 count badge */
97
+ --dot: calc(var(--tap-base) * 5 / 24); /* 10 signal dot */
98
+ --dot-xs: calc(var(--tap-base) / 8); /* 6 marker dot */
86
99
 
87
100
  /* ---- icons (icons.svg sprite, Lucide) ---- */
88
- --icon: 1.75rem; /* 28 default */
89
- --icon-sm: 1.75rem; /* one size everywhere; retune if small contexts appear */
90
- --icon-lg: 2rem; /* 32 empty states */
91
- --icon-xs: 1rem; /* 16 inline with captions/chips only (row meta, link arrows) */
101
+ --icon: calc(var(--tap-base) * 7 / 12); /* 28 default */
102
+ --icon-sm: calc(var(--tap-base) * 7 / 12); /* one size everywhere; retune if small contexts appear */
103
+ --icon-lg: calc(var(--tap-base) * 2 / 3); /* 32 empty states */
104
+ --icon-xs: var(--fs-base); /* 16 inline with captions/chips only (row meta, link arrows) */
92
105
 
93
106
  /* ---- people & big elements ---- */
94
- --avatar-sm: 2.5rem; /* 40 row lead */
95
- --avatar: 3.5rem; /* 56 card head, person row */
96
- --avatar-lg: 5rem; /* 80 chooser row */
97
- --avatar-xl: 6rem; /* 96 sheet head icon ring */
98
- --ring: 11rem; /* 176 .ring gauge diameter */
99
- --ring-w: 1.375rem; /* 22 .ring stroke */
100
- --tile-min: 7.5rem; /* 120 .tile-grid column floor */
101
- --tile-h: 8.25rem; /* 132 tile / band height */
107
+ --avatar-sm: calc(var(--tap-base) * 5 / 6); /* 40 row lead */
108
+ --avatar: calc(var(--tap-base) * 7 / 6); /* 56 card head, person row */
109
+ --avatar-lg: calc(var(--tap-base) * 5 / 3); /* 80 chooser row */
110
+ --avatar-xl: calc(var(--tap-base) * 2); /* 96 sheet head icon ring */
111
+ --ring: calc(var(--tap-base) * 11 / 3); /* 176 .ring gauge diameter */
112
+ --ring-w: calc(var(--tap-base) * 11 / 24); /* 22 .ring stroke */
113
+ --tile-min: calc(var(--tap-base) * 5 / 2); /* 120 .tile-grid column floor */
114
+ --tile-h: calc(var(--tap-base) * 11 / 4); /* 132 tile / band height */
102
115
 
103
116
  /* ---- radius ---- */
104
117
  --r-chip: 999px; /* pills, chips, buttons — always fully round */
105
- --r-card: 2rem; /* 32 the one card radius: every card, row, tile, menu, sheet, fab */
106
- --r-media: 1rem; /* 16 images inside cards */
107
- --r-control: 0.875rem; /* 14 fields, thumbs, menu rows */
108
- --r-badge: 0.625rem; /* 10 tile badge, bars */
118
+ --r-card: calc(var(--r-base) * 2); /* 32 the one card radius: every card, row, tile, menu, sheet, fab */
119
+ --r-media: var(--r-base); /* 16 images inside cards */
120
+ --r-control: calc(var(--r-base) * 0.875); /* 14 fields, thumbs, menu rows */
121
+ --r-badge: calc(var(--r-base) * 0.625); /* 10 tile badge, bars */
109
122
 
110
123
  /* ---- borders / rules / elevation (px on purpose: crisp at any scale) ---- */
111
124
  --border-w: 1.5px;
@@ -126,22 +139,22 @@
126
139
  --font-display: "FC Pride", var(--font-ui); /* the character face for headings and big numbers (fonts.css) */
127
140
  --font-mono: "SF Mono", "JetBrains Mono", ui-monospace, monospace;
128
141
 
129
- --fs-xs: 0.6875rem; /* 11 overline */
130
- --fs-caption: 0.75rem; /* 12 */
131
- --fs-label: 0.8125rem; /* 13 */
132
- --fs-sm: 0.875rem; /* 14 chips, segments */
133
- --fs-body: 0.9375rem; /* 15 */
134
- --fs-md: 1rem; /* 16 buttons */
135
- --fs-title: 1.0625rem; /* 17 */
136
- --fs-lg: 1.25rem; /* 20 day numbers, tile values */
137
- --fs-h2: 1.375rem; /* 22 */
138
- --fs-2xl: 1.5rem; /* 24 composer, small ring */
139
- --fs-h1: 1.75rem; /* 28 */
140
- --fs-3xl: 2rem; /* 32 xl button */
141
- --fs-display: clamp(2.125rem, 9vw, 2.625rem); /* 34–42 */
142
- --fs-stat: clamp(1.875rem, 8vw, 2.375rem); /* 30–38 big numbers */
143
- --fs-hero: 3.5rem; /* 56 the one number on a sheet */
144
- --fs-giant: clamp(3rem, 15vw, 4rem); /* 48–64 data bands */
142
+ --fs-xs: calc(var(--fs-base) * 0.6875); /* 11 overline */
143
+ --fs-caption: calc(var(--fs-base) * 0.75); /* 12 */
144
+ --fs-label: calc(var(--fs-base) * 0.8125); /* 13 */
145
+ --fs-sm: calc(var(--fs-base) * 0.875); /* 14 chips, segments */
146
+ --fs-body: calc(var(--fs-base) * 0.9375); /* 15 */
147
+ --fs-md: var(--fs-base); /* 16 buttons */
148
+ --fs-title: calc(var(--fs-base) * 1.0625); /* 17 */
149
+ --fs-lg: calc(var(--fs-base) * 1.25); /* 20 day numbers, tile values */
150
+ --fs-h2: calc(var(--fs-base) * 1.375); /* 22 */
151
+ --fs-2xl: calc(var(--fs-base) * 1.5); /* 24 composer, small ring */
152
+ --fs-h1: calc(var(--fs-base) * 1.75); /* 28 */
153
+ --fs-3xl: calc(var(--fs-base) * 2); /* 32 xl button */
154
+ --fs-display: clamp(calc(var(--fs-base) * 2.125), 9vw, calc(var(--fs-base) * 2.625)); /* 34–42 */
155
+ --fs-stat: clamp(calc(var(--fs-base) * 1.875), 8vw, calc(var(--fs-base) * 2.375)); /* 30–38 big numbers */
156
+ --fs-hero: calc(var(--fs-base) * 3.5); /* 56 the one number on a sheet */
157
+ --fs-giant: clamp(calc(var(--fs-base) * 3), 15vw, calc(var(--fs-base) * 4)); /* 48–64 data bands */
145
158
 
146
159
  /* level weight size / line-height family */
147
160
  --type-display: 800 var(--fs-display) / 1.15 var(--font-display); /* leading: Thai marks stack to 1.1em up and 0.3em down in FC Pride — 1.02 clipped them (2026-09-10) */
@@ -161,35 +174,53 @@
161
174
  /* ============================================================
162
175
  COLOR ROLES — wireframe defaults (grayscale).
163
176
  palette.css overrides these under [data-palette="…"].
177
+
178
+ THE FIVE COLOUR BASES (Lh, 2026-09-20) — a project's own colours, the
179
+ same idea as the size bases: set a base, the roles follow.
180
+ --color-surface the app's ground
181
+ --color-ink the words
182
+ --color-paper the plain card, neutral buttons and fields
183
+ --color-brand the primary action, the strong card, the signal dot
184
+ --color-on-brand the words on the brand
185
+ They have NO default: unset, every role below is its exact wireframe
186
+ value. That is what the fallbacks are for — a private mix (--_*) that
187
+ reads an unset base is invalid, so var(--_x, #grey) lands on the grey.
188
+ Set, the roles derive: tone 2 = the brand washed into the surface, the
189
+ muted inks = the ink washed into their ground. A role set directly
190
+ (a palette, or a project's theme.css) always wins over its derivation.
164
191
  ============================================================ */
192
+ --_muted-on-surface: color-mix(in srgb, var(--color-ink) 66%, var(--color-surface, #f2f2f0));
193
+ --_muted-on-paper: color-mix(in srgb, var(--color-ink) 66%, var(--color-paper, #ffffff));
194
+ --_tone-2: color-mix(in srgb, var(--color-brand) 14%, var(--color-surface, #f2f2f0));
195
+ --_muted-on-tone-2: color-mix(in srgb, var(--color-ink, #141414) 72%, var(--_tone-2));
165
196
 
166
197
  /* app background ("earth-toned surface or shiny color, up to the app") */
167
- --surface: #f2f2f0;
168
- --on-surface: #141414;
169
- --on-surface-muted: #6b6b6b;
198
+ --surface: var(--color-surface, #f2f2f0);
199
+ --on-surface: var(--color-ink, #141414);
200
+ --on-surface-muted: var(--_muted-on-surface, #6b6b6b);
170
201
 
171
202
  /* card boundaries — 3 slots so stacked/adjacent cards can differ */
172
- --card-1-bg: #ffffff; --card-1-ink: #141414;
173
- --card-2-bg: #e6e6e3; --card-2-ink: #141414;
174
- --card-3-bg: #141414; --card-3-ink: #ffffff;
203
+ --card-1-bg: var(--color-paper, #ffffff); --card-1-ink: var(--color-ink, #141414);
204
+ --card-2-bg: var(--_tone-2, #e6e6e3); --card-2-ink: var(--color-ink, #141414);
205
+ --card-3-bg: var(--color-brand, #141414); --card-3-ink: var(--color-on-brand, #ffffff);
175
206
 
176
207
  --card-border: #141414; /* wireframe: visible outline; palettes may set transparent */
177
- --card-muted: #6b6b6b; /* secondary text inside cards (the default, tone-1 card) */
178
- --card-2-muted: #5f5f5f; /* the same text on the tone-2 card — 5.1:1 on its grey (#6b6b6b is 4.3:1, under AA);
208
+ --card-muted: var(--_muted-on-paper, #6b6b6b); /* secondary text inside cards (the default, tone-1 card) */
209
+ --card-2-muted: var(--_muted-on-tone-2, #5f5f5f); /* the same text on the tone-2 card — 5.1:1 on its grey (#6b6b6b is 4.3:1, under AA);
179
210
  a palette whose tone-2 ground is strong
180
211
  sets this to its tone-2 ink — a muted shade cannot reach AA there (clay) */
181
212
 
182
213
  /* actions */
183
- --accent: #141414; /* primary CTA fill */
184
- --on-accent: #ffffff;
185
- --action-bg: #ffffff; /* neutral buttons/chips/inputs */
186
- --action-ink: #141414;
214
+ --accent: var(--color-brand, #141414); /* primary CTA fill */
215
+ --on-accent: var(--color-on-brand, #ffffff);
216
+ --action-bg: var(--color-paper, #ffffff); /* neutral buttons/chips/inputs */
217
+ --action-ink: var(--color-ink, #141414);
187
218
  --action-border: #141414;
188
219
 
189
220
  /* signals (chip dots, deltas) */
190
221
  --pos: #8a8a8a;
191
222
  --neg: #3d3d3d;
192
- --dot-a: #141414;
223
+ --dot-a: var(--color-brand, #141414);
193
224
  --dot-b: #8a8a8a;
194
225
 
195
226
  /* derived tints — read the element's own ink, so they work on any slot */
@@ -330,6 +361,11 @@
330
361
 
331
362
  /* Colored palettes trade wireframe outlines for soft elevation. */
332
363
  [data-palette] :is(.card, .band-stack) { box-shadow: var(--shadow-card); }
364
+ /* COLOURED MODE is the attribute, whatever its value: a project with colours of its own (the five
365
+ --color-* bases, or roles, in its theme.css) writes <html data-palette="own"> — no block here matches
366
+ "own", so only the three generic rules apply: the card's shadow, the soft outline, the field's soft
367
+ edge (forms.css); and the two wireframe borders step back, as in every stock palette. */
368
+ [data-palette] { --card-border: transparent; --action-border: transparent; }
333
369
  /* …but controls keep a faint edge so they still read as controls */
334
370
  [data-palette] { --outline: color-mix(in srgb, currentColor 25%, transparent); }
335
371
 
@@ -344,7 +380,7 @@
344
380
  in cards, rows and bodies stay selectable (an address, a code, a number
345
381
  the member wants to copy), and inputs are untouched. Native buttons and
346
382
  links are matched by element; the rest by the class that makes them one */
347
- button, [role="button"], [role="tab"], [role="option"], [role="menuitem"], a.btn, a.icon-btn, a.chip, label.chip, .chip--toggle, .chip--pick,
383
+ button, [role="button"], [role="tab"], [role="option"], [role="menuitem"], a.btn, a.icon-btn, a.chip, label.chip, .chip--pick,
348
384
  .segment__item, .day, .calendar__day, .mood__opt, .pin__cell, .slider, .picker__item, .float-bar__item, .menu__item, .sheet__handle, .dropdown__trigger, .track__step, .pager__at {
349
385
  user-select: none;
350
386
  -webkit-user-select: none;
@@ -1603,7 +1639,7 @@ button { font: inherit; cursor: pointer; }
1603
1639
  .appbar__sub { font: var(--type-caption); color: var(--on-surface-muted); }
1604
1640
  /* the bar's trailing controls (one or two: a labelled switch chip, a wallet button — home,
1605
1641
  Lh 2026-09-13) stand on the tap floor: a chip that is a switch grows to --tap here */
1606
- .appbar .chip--toggle { min-height: var(--tap); }
1642
+ .appbar .chip--pick { min-height: var(--tap); }
1607
1643
 
1608
1644
  /* ---- icon button (round) ---- */
1609
1645
  .icon-btn {
@@ -2237,27 +2273,11 @@ button { font: inherit; cursor: pointer; }
2237
2273
  .toggle:checked::after { translate: calc(var(--_w) - var(--_h)) 0; background: var(--on-accent); opacity: 1; }
2238
2274
  .toggle:focus-visible { outline: var(--focus); outline-offset: var(--sp-1); }
2239
2275
 
2240
- /* ---- toggle chip: interest / filter picker — shows + off, × on ----
2241
- <label class="chip chip--toggle"><input type="checkbox">Nature</label> */
2242
- .chip--toggle { cursor: pointer; padding-right: var(--sp-2); }
2243
- .chip--toggle > input { appearance: none; position: absolute; width: 1px; height: 1px; margin: 0; opacity: 0; }
2244
- .chip--toggle::after {
2245
- content: "";
2246
- width: var(--icon-xs);
2247
- height: var(--icon-xs);
2248
- flex: none;
2249
- background: currentColor;
2250
- mask: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="black" stroke-width="2.5" stroke-linecap="round"><path d="M5 12h14"/><path d="M12 5v14"/></svg>') center / contain no-repeat;
2251
- -webkit-mask: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="black" stroke-width="2.5" stroke-linecap="round"><path d="M5 12h14"/><path d="M12 5v14"/></svg>') center / contain no-repeat;
2252
- }
2253
- .chip--toggle:has(:checked) { background: var(--accent); color: var(--on-accent); border-color: transparent; }
2254
- /* checked: the same plus turns 45° into an x — one mask, one rotate, no swap (Lh 2026-09-16) */
2255
- .chip--toggle:has(:checked)::after { rotate: 45deg; }
2256
- @media (prefers-reduced-motion: no-preference) { .chip--toggle::after { transition: rotate var(--motion-slide); } }
2257
- .chip--toggle:has(:focus-visible) { outline: var(--focus); outline-offset: var(--sp-1); }
2258
-
2259
- /* ---- chip grid: pick one slot / many options in a grid ----
2260
- <div class="chip-grid"><label class="chip chip--pick"><input type="radio" name="t">9:00</label>…</div> */
2276
+ /* ---- chip pick: a chip that is a choice — a native radio/checkbox inside the label, the picked one filled with accent.
2277
+ Full-width in a .chip-grid (one slot / many options), or hugging its word on a .chip-row with --sign (an interest
2278
+ picker: + off, × on). Was two things, .chip--toggle and .chip--pick — one chip, one state, the sign is a variant (Lh 2026-09-16).
2279
+ <div class="chip-grid"><label class="chip chip--pick"><input type="radio" name="t">9:00</label>…</div>
2280
+ <div class="chip-row"><label class="chip chip--pick chip--sign"><input type="checkbox">Nature</label>…</div> */
2261
2281
  .chip-grid {
2262
2282
  display: grid;
2263
2283
  grid-template-columns: repeat(auto-fill, minmax(min(5.5rem, 100%), 1fr));
@@ -2268,10 +2288,23 @@ button { font: inherit; cursor: pointer; }
2268
2288
  Matters most in scripts without word spaces (Thai): min-content is the
2269
2289
  whole string, tracks get wide, and hugging chips leave a ragged grid. */
2270
2290
  .chip-grid > .chip { justify-self: stretch; justify-content: center; min-height: var(--btn-h); padding-inline: var(--sp-2); position: relative; }
2271
- .chip--pick { cursor: pointer; }
2291
+ .chip--pick { cursor: pointer; position: relative; } /* the input covers the whole chip, on a row as in a grid */
2272
2292
  .chip--pick > input { appearance: none; position: absolute; inset: 0; margin: 0; opacity: 0; cursor: pointer; }
2273
2293
  .chip--pick:has(:checked) { background: var(--accent); color: var(--on-accent); border-color: transparent; }
2274
2294
  .chip--pick:has(:focus-visible) { outline: var(--focus); outline-offset: var(--sp-1); }
2295
+ /* --sign: the trailing + that turns 45° into an × when picked — one mask, one rotate, no swap (Lh 2026-09-16) */
2296
+ .chip--sign { padding-right: var(--sp-2); }
2297
+ .chip--sign::after {
2298
+ content: "";
2299
+ width: var(--icon-xs);
2300
+ height: var(--icon-xs);
2301
+ flex: none;
2302
+ background: currentColor;
2303
+ mask: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="black" stroke-width="2.5" stroke-linecap="round"><path d="M5 12h14"/><path d="M12 5v14"/></svg>') center / contain no-repeat;
2304
+ -webkit-mask: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="black" stroke-width="2.5" stroke-linecap="round"><path d="M5 12h14"/><path d="M12 5v14"/></svg>') center / contain no-repeat;
2305
+ }
2306
+ .chip--sign:has(:checked)::after { rotate: 45deg; }
2307
+ @media (prefers-reduced-motion: no-preference) { .chip--sign::after { transition: rotate var(--motion-slide); } }
2275
2308
 
2276
2309
  /* ---- day strip: a week, one day selected ----
2277
2310
  <div class="day-strip" role="tablist"><button class="day" role="tab" aria-selected="true"><span class="day__name">Thu</span><span class="day__num">16</span></button>…</div> */
@@ -2550,6 +2583,56 @@ button { font: inherit; cursor: pointer; }
2550
2583
  .kv-cell__key { font: var(--type-caption); color: var(--card-muted); }
2551
2584
  .card--3 .kv-cell__key { color: inherit; opacity: 0.7; }
2552
2585
 
2586
+ /* ---- bubble: a chat message — theirs on the left, mine on the right (Lh 2026-09-18, replaces the chip's tail) ----
2587
+ No tail: a rounded block whose corner nearest the sender is square-ish
2588
+ (bottom-left for theirs, bottom-right for mine), the way messaging apps
2589
+ draw it. Under it a caption line (time · a tick), beside it small round
2590
+ actions (a heart, more). A .thread stacks them.
2591
+ <div class="thread">
2592
+ <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>
2593
+ <div class="bubble bubble--right">…</div>
2594
+ </div> */
2595
+ .thread { display: grid; gap: var(--sp-4); }
2596
+ /* two columns: the message (max-content, but it gives way — minmax(0) — so a long one shrinks
2597
+ and wraps while the actions stay beside it) and the actions; the meta on a second row under the message */
2598
+ .bubble {
2599
+ display: grid;
2600
+ grid-template-columns: minmax(0, max-content) auto;
2601
+ justify-content: start;
2602
+ align-items: center;
2603
+ gap: var(--sp-1) var(--sp-3);
2604
+ max-width: 88%;
2605
+ margin-right: auto; /* theirs: hugs the left */
2606
+ }
2607
+ .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 */
2608
+ .bubble__body, .bubble__meta { grid-column: 1; }
2609
+ .bubble__actions { grid-column: 2; grid-row: 1; }
2610
+ .bubble--right > :is(.bubble__body, .bubble__meta) { grid-column: 2; }
2611
+ .bubble--right > .bubble__actions { grid-column: 1; }
2612
+ .bubble__body {
2613
+ margin: 0;
2614
+ min-width: 0;
2615
+ padding: var(--sp-3) var(--sp-4);
2616
+ border-radius: var(--r-card);
2617
+ border-bottom-left-radius: var(--r-control);
2618
+ background: var(--card-2-bg);
2619
+ color: var(--card-2-ink);
2620
+ font: var(--type-body);
2621
+ overflow-wrap: anywhere;
2622
+ }
2623
+ .bubble--right > .bubble__body {
2624
+ border-bottom-left-radius: var(--r-card);
2625
+ border-bottom-right-radius: var(--r-control);
2626
+ background: var(--accent);
2627
+ color: var(--on-accent);
2628
+ text-align: right;
2629
+ }
2630
+ .bubble__actions { display: flex; gap: var(--sp-1); flex: none; opacity: 0.7; }
2631
+ .bubble__meta { display: flex; align-items: center; gap: var(--sp-1); font: var(--type-caption); color: var(--on-surface-muted); }
2632
+ .bubble--right > .bubble__meta { justify-self: end; }
2633
+ .bubble__meta > .icon { color: var(--accent); }
2634
+ .card--3 .bubble__meta { color: inherit; opacity: 0.7; }
2635
+
2553
2636
  /* ---- timeline: a hairline with date chips, entries to the right ----
2554
2637
  <ol class="timeline"><li class="timeline__item"><span class="timeline__mark">Wed 14</span><p class="t-body">…</p></li>…</ol> */
2555
2638
  .timeline {
@@ -3122,22 +3205,6 @@ button { font: inherit; cursor: pointer; }
3122
3205
  }
3123
3206
  .callout__title { font: var(--type-title); }
3124
3207
  .callout__sub { font: var(--type-caption); opacity: 0.7; }
3125
- /* speech chip: a chip with the same tail (mascot / greeting) */
3126
- .chip--bubble { position: relative; }
3127
- .chip--bubble::after {
3128
- content: "";
3129
- position: absolute;
3130
- left: var(--sp-4);
3131
- bottom: calc(-1 * var(--sp-2) + var(--border-w));
3132
- width: var(--sp-3);
3133
- height: var(--sp-3);
3134
- rotate: 45deg;
3135
- background: inherit;
3136
- border: inherit;
3137
- border-top: 0;
3138
- border-left: 0;
3139
- }
3140
-
3141
3208
  /* ---- deck: swipe cards; the last child is on top ----
3142
3209
  <div class="deck"><article class="card">…</article><article class="card">…</article><article class="card card--fold">front</article></div>
3143
3210
  <div class="deck__actions"><button class="icon-btn">×</button><button class="icon-btn icon-btn--invert">✓</button></div> */
package/dist/index.d.ts CHANGED
@@ -59,6 +59,7 @@ export * from './lists/Row.js';
59
59
  export * from './lists/Kv.js';
60
60
  export * from './lists/Timeline.js';
61
61
  export * from './lists/Legend.js';
62
+ export * from './lists/Bubble.js';
62
63
  export * from './media/Quote.js';
63
64
  export * from './media/Postcard.js';
64
65
  export * from './media/MapArea.js';
package/dist/index.js CHANGED
@@ -62,6 +62,7 @@ export * from './lists/Row.js';
62
62
  export * from './lists/Kv.js';
63
63
  export * from './lists/Timeline.js';
64
64
  export * from './lists/Legend.js';
65
+ export * from './lists/Bubble.js';
65
66
  export * from './media/Quote.js';
66
67
  export * from './media/Postcard.js';
67
68
  export * from './media/MapArea.js';
@@ -0,0 +1,18 @@
1
+ import type { ComponentPropsWithoutRef, ReactNode } from 'react';
2
+ export interface ThreadProps extends ComponentPropsWithoutRef<'div'> {
3
+ }
4
+ /** Thread — a stack of Bubbles: a conversation. */
5
+ export declare function Thread({ className, ...rest }: ThreadProps): import("react").JSX.Element;
6
+ export interface BubbleProps extends ComponentPropsWithoutRef<'div'> {
7
+ /** whose: theirs on the left (default), mine on the right */
8
+ side?: 'left' | 'right';
9
+ /** the caption under the message — a time, a tick (an Icon) */
10
+ meta?: ReactNode;
11
+ /** small round buttons beside the message (a heart, more) */
12
+ actions?: ReactNode;
13
+ }
14
+ /**
15
+ * Bubble — a chat message: a rounded block, its corner nearest the sender squared (bottom-left for theirs, bottom-right
16
+ * for mine), no tail. `meta` sits under it on the sender's side, `actions` beside it. Stack them in a Thread.
17
+ */
18
+ export declare function Bubble({ side, meta, actions, className, children, ...rest }: BubbleProps): import("react").JSX.Element;
@@ -0,0 +1,13 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { cx } from '../cx.js';
3
+ /** Thread — a stack of Bubbles: a conversation. */
4
+ export function Thread({ className, ...rest }) {
5
+ return _jsx("div", { className: cx('thread', className), ...rest });
6
+ }
7
+ /**
8
+ * Bubble — a chat message: a rounded block, its corner nearest the sender squared (bottom-left for theirs, bottom-right
9
+ * for mine), no tail. `meta` sits under it on the sender's side, `actions` beside it. Stack them in a Thread.
10
+ */
11
+ export function Bubble({ side = 'left', meta, actions, className, children, ...rest }) {
12
+ return (_jsxs("div", { className: cx('bubble', side === 'right' && 'bubble--right', className), ...rest, children: [_jsx("p", { className: "bubble__body", children: children }), actions != null && _jsx("span", { className: "bubble__actions", children: actions }), meta != null && _jsx("span", { className: "bubble__meta", children: meta })] }));
13
+ }
@@ -7,7 +7,7 @@ export interface AppBarProps extends Omit<ComponentPropsWithoutRef<'header'>, 't
7
7
  /** the second line under the title */
8
8
  sub?: ReactNode;
9
9
  /** the trailing control(s) — an IconBtn with a chevron-down to switch context; on the home bar a labelled
10
- * switch chip (ChipToggle, --tap tall here) and the wallet Btn, side by side (Lh 2026-09-13) */
10
+ * switch chip (ChipPick sign, --tap tall here) and the wallet Btn, side by side (Lh 2026-09-13) */
11
11
  action?: ReactNode;
12
12
  }
13
13
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usableapp/cardds",
3
- "version": "0.1.6",
3
+ "version": "0.2.0",
4
4
  "license": "UNLICENSED",
5
5
  "type": "module",
6
6
  "description": "card-first mobile design system, React-first: the components in src/ are thin wrappers over the CSS contract (css/*.css stays the only truth); gallery/ shows every story live (npm run dev), tests/ measures the geometry.",