@usableapp/cardds 0.7.95 → 0.7.97

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,1049 +1,7 @@
1
1
  # cardds
2
2
 
3
- > **cardds is a UI framework, not one project's design system.** A project takes it and puts its own values and
4
- > content into the UI — tokens (`theme.css`), props, slots, composition — without touching cardds. When a project
5
- > cannot get what it needs from outside, that is a gap in the framework: report it; it is closed here as a
6
- > GENERAL extension point, never as that project's special case. A consumer project never edits or releases cardds.
7
- >
8
- > **For:** people and AI alike — this is the reference, the one place a rule of the system is written.
9
- > The other docs only point here: `CLAUDE.md` (AI working IN this repo — decisions and gotchas),
10
- > `docs/guides/building-with-cardds.md` (AI composing screens in React; shipped to Claude Design),
11
- > the `/cardds` skill (AI in another project — install + contract). To LOOK at the system: `npm run dev`.
3
+ A card-first UI framework for mobile web apps, in React. It is a set of components for phone-width screens: cards, bottom sheets, sheet stacks, steps, sticky notes, stamps and postcards. A project installs it as `@usableapp/cardds` and gives it its own look with a `theme.css`, without changing cardds itself. With no theme, it draws a grayscale wireframe.
12
4
 
13
- Card-first mobile design system, **React-first**: `src/` is the component
14
- library — one thin component per pattern, emitting exactly the markup the CSS
15
- documents. The CSS (`css/*.css`)
16
- stays the only truth: a component never styles anything, it only picks classes
17
- from props. The demo is the gallery (`npm run dev`):
18
- every component's stories, live, searchable.
5
+ The rules of the system are in [`docs/reference.md`](docs/reference.md). The gallery shows every component (`npm run dev`).
19
6
 
20
- Repo: **https://github.com/everysundays/cardds** (private — this is the
21
- source of truth; every consuming project installs the npm package
22
- `@usableapp/cardds`, never the other way round).
23
-
24
- **Wireframe by default** — grayscale with visible outlines. Color is opt-in,
25
- lives only in one file, applied with one attribute. Two faces ship with it
26
- (`fonts/`, `css/fonts.css`): PK Nonthaburi for text, FC Pride for headings
27
- and big numbers — both Thai + Latin, both single-weight demo/non-commercial
28
- licences (`fonts/LICENSES.md`).
29
-
30
- ## Use
31
-
32
- ```bash
33
- npm install
34
- npm run dev # the gallery on http://localhost:5174 — every story, searchable
35
- npm run build # the library: icons → src/type/icons.ts · tsc → dist/ (ESM + .d.ts) · dist/cardds.css (flattened)
36
- npm test # Playwright: the tokens and the step/sheet/centre geometry on tests/fixtures/, the gallery's stories and behaviours
37
- ```
38
-
39
- ```tsx
40
- import { Screen, TopBar, Step, Page, Sheet, SheetBody, CardHead, Field, Pin, ActionBar, Btn } from '@usableapp/cardds';
41
- import '@usableapp/cardds/dist/cardds.css';
42
- import './theme.css'; // the project's own look, after the system's — see "A project's theme"
43
- ```
44
-
45
- **The viewport meta cardds expects** (one way for every app):
46
-
47
- ```html
48
- <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content">
49
- ```
50
-
51
- `viewport-fit=cover` = the app runs edge to edge under the phone's own bars; cardds publishes their room as
52
- `--safe-top` / `--safe-bottom` (from `env(safe-area-inset-*)`, 0 without cover) and keeps what the member reads or taps
53
- clear of them — the bottom bar's buttons, a sheet's peeked head, its foot and the end of its body, the float bar, the FAB,
54
- a stage's foot; a fill `Screen` starts under the status bar — while every ground still runs to the edge. A
55
- component of the app's own pinned to an edge reads the same two tokens. `interactive-widget=resizes-content` =
56
- Android shrinks the page for the keyboard (`--kb` stays 0 there; iOS publishes it).
57
-
58
- Every behaviour is the component's own React code — the `Sheet`'s handle (tap, drag,
59
- keyboard) with the focus and on-screen-keyboard raises, the `SheetStack` opening and
60
- closing, the `Pin` auto-advance, the `Modal`'s leaving — each controlled like an input
61
- (`x` + `onXChange`, or `defaultX`). `src/index.ts` imports `cardds.js` for the environment
62
- only: the keyboard's height (`--kb`) and `.bleed`'s scrollbar. `Icon` inlines the 61
63
- symbols of `icons.svg` at build time, so no sprite file ships.
64
-
65
- **The gallery** (`gallery/`, `npm run dev` → http://localhost:5174): Vite + React Router, `cardds`
66
- aliased to `src/`, so the gallery and the package share one truth with no build between them.
67
- `/` every story of every component, `/:Name` one component — each `.design-sync/previews/<Name>.tsx`
68
- story live in a 375×812 phone cell, grouped by `src/<group>/`, searchable; switches for the palette
69
- (`own` = a colour well per colour base), inspect (box outlines + dimensions), a mock keyboard (focus a field:
70
- it comes up over the phone cell and publishes `--kb` there — to SEE what a keyboard leaves of the screen; it
71
- types nothing), text size, and a slider per size base. Nothing is hand-listed: add a preview file or a story and it shows.
72
-
73
- **On a real phone** — the mock keyboard shows the room a keyboard takes; the REAL one (its kind by `inputMode`: `numeric` /
74
- `tel` = digits, `email`, `text`; its language is the member's own setting) only shows on a device. The gallery's server is
75
- open on the Wi-Fi (`server.host: true`): `npm run dev` prints a `Network:` address — open it on a phone on the same
76
- Wi-Fi. A project using cardds does the same in its own `vite.config` (`server: { host: true }`, or `vite --host`).
77
- The iOS Simulator's Safari shows the iOS keyboard too (⌘K if it hides); desktop device modes show none. Installing as a
78
- PWA needs https — a tunnel (`cloudflared tunnel --url http://localhost:<port>`), then Add to Home Screen. `host: true`
79
- lets anyone on the same network open the dev server: use it on a network you trust.
80
-
81
- `tests/fixtures/*.html` are the pages the geometry tests measure — not a demo. Nothing here is verified by eye.
82
-
83
- ### In another project
84
-
85
- **React (the main way, 2026-09-16):** the package is `@usableapp/cardds` on npm
86
- (published from this machine: `npm run release` sends every `v*` tag the registry lacks, oldest first).
87
-
88
- ```bash
89
- npm i @usableapp/cardds
90
- ```
91
-
92
- ```tsx
93
- import { Card, CardHead, Btn } from '@usableapp/cardds';
94
- import '@usableapp/cardds/dist/cardds.css'; // the CSS, fonts resolve from the package
95
- import './theme.css'; // the project's look, after cardds.css
96
- ```
97
-
98
- **One way in, one step** (2026-09-29): `npx cardds init` — writes the project's `theme.css` (a copy of the
99
- template), adds `npm run cardds:check`, and says what to import. Then a screen is a template (`Walkthrough` · `Home` ·
100
- `CallSheet` · `Splash`) or components composed — IMPORTED, never their markup copied: the behaviour lives in the
101
- component. `npx cardds check [dir]` runs the rules (`lint/rules.mjs`): a hand-written cardds class is an error; a
102
- `Sheet` that cannot move (`state` without `onStateChange`) or leaves by vanishing (on a condition without
103
- `onSettled`) is a warning; a bar after `</Screen>` or pinned by hand is an error. The same rules in ESLint (flat
104
- config): `import cardds from '@usableapp/cardds/eslint'` → `cardds.configs.recommended`. A deliberate exception:
105
- `// cardds-ignore-next-line`. Adjust from outside only: `theme.css` → knobs → props / slots.
106
-
107
-
108
- **เบราว์เซอร์** (เช็ก 2026-09-24): iPhone ตั้งแต่ **iOS 16.4** (มี.ค. 2023) ขึ้นไป
109
-
110
- | เบราว์เซอร์ | ขั้นต่ำ | ออกเมื่อ | เครื่องที่ใช้ได้ |
111
- |---|---|---|---|
112
- | Safari / iOS | 16.4 | มี.ค. 2023 | iPhone 8 / X (2017) ขึ้นไป ที่อัปเดตแล้ว |
113
- | Chrome | 114 | พ.ค. 2023 | Android ส่วนใหญ่ที่ Chrome อัปเดตอยู่ |
114
- | Firefox | 128 | ก.ค. 2024 | — |
115
-
116
- iOS 16.4–16.x ไม่มี popover ในตัว — cardds ใส่ `@oddbird/popover-polyfill` มาให้ (ทำงานเฉพาะเครื่องที่ไม่มี)
117
- เก่ากว่า 16.4: ระยะของ sheet และสีเพี้ยน (`@property`, `color-mix()`) ส่วนเบราว์เซอร์ที่ผ่านขั้นต่ำแต่ยังไม่ใช่รุ่นล่าสุด
118
- เมนูจะขึ้นกลางจอแทนที่จะอยู่ใต้ปุ่ม
119
-
120
- สำหรับโปรเจกต์ที่อยากเช็กเบราว์เซอร์ก่อนเริ่ม (เช่นหน้าติดตั้ง): เช็กเฉพาะฟีเจอร์ที่**จำเป็น**
121
- ตัวอื่น cardds มีตัวสำรองไว้แล้ว ไม่ต้องเช็ก
122
-
123
- | จำเป็น: ไม่มีแล้วหน้าจอพัง | วิธีเช็ก |
124
- |---|---|
125
- | `@property` | `'registerProperty' in CSS` |
126
- | `color-mix()` | `CSS.supports('color', 'color-mix(in srgb, red, blue)')` |
127
- | container queries | `CSS.supports('container-type: size') && CSS.supports('height: 1cqh')` |
128
- | `:has()` | `CSS.supports('selector(:has(a))')` |
129
-
130
- มีตัวสำรองแล้ว ไม่ต้องเช็ก: popover (polyfill), anchor positioning, view transitions, `@starting-style`, `text-wrap`
131
- iOS 16.4–18.2 เช็กผ่าน แต่ยังรองรับไม่เต็ม เมนูขึ้นกลางจอแทนใต้ปุ่ม
132
-
133
- Working with Claude in another project? The `/cardds` skill
134
- (`~/.claude/skills/cardds/`) carries the install and the contract, and points
135
- back here. It is deliberately thin — **this repo stays the source of truth**,
136
- so new components and rule changes go here, and the skill only gets updated
137
- if the contract itself changes.
138
-
139
- ### For an AI session: the catalog
140
-
141
- A session asked for a prototype need not read `src/`: the package ships a catalog (`dist/catalog.json`, built by
142
- `npm run build`) of every component — props, slots — and every story with its code and its rendered HTML. Ask it first:
143
-
144
- ```bash
145
- npx cardds find [words…] [--represents person] [--shape group] [--interact pick|none]
146
- # by the AXES first (docs/roles.md: what it stands for · one or a group · what the member can do), words second;
147
- # the screens already drawn, then components, then what is used with them (nothing → a hint, not a list)
148
- npx cardds scaffold <id> # a whole file from a screen: tsx (the code) or --html (a static document linking cardds.css)
149
- npx cardds get <Name> [--html] # one component: its role, props (a ReactNode prop is a slot), what it composes, its knobs + variations with code
150
- npx cardds list # every name by what it is for: one · group-brief · group-detail, the show-only first, then by what the member can do
151
- npx cardds check <dir> # the rules, before a screen is called done
152
- npx cardds guide # the few rules no checker sees, in one short text
153
- ```
154
-
155
- `get` lists the component's **knobs** — each `--name` with its default, what it sets and the selector that reads it, joined by the classes the
156
- component emits, so a knob named for an old name (`--action-note-*`) is found under the component that has it now (`BarNote`). It says what the
157
- component **composes** (the catalog names its own source renders or calls: `Fab` → `IconBtn`, `Icon`) and what it is **composed by** (the inverse).
158
- `find` matches the role's note and what a component stands in for as well as its name and description, so a question by intention ("a setting
159
- turned on or off", "a code digit by digit") finds the piece (`Toggle`, `Pin`).
160
-
161
- `list` · `get` · `find` · `scaffold` take `--json` (the function's result) and `--root <path>` (a cardds checkout: rebuilt first, so never stale). The same
162
- five answers over MCP — `list_components` · `get_component` · `find_components` · `scaffold_screen` · `check_source`:
163
-
164
- ```json
165
- { "mcpServers": { "cardds": { "command": "npx", "args": ["cardds", "mcp"] } } }
166
- ```
167
-
168
- The MCP server hands a session the same text as `npx cardds guide` as its instructions, when it starts.
169
-
170
- A scaffolded tsx may import `./_story` or `./_*Art`: story glue, not in the package — replace or drop it.
171
-
172
- ### React adapter — how a component is written
173
-
174
- `src/<group>/<Name>.tsx`, one export per class family; sub-parts are their own
175
- components (`CardHead`, `KvRow`, `TrackStep`, `SheetBody`). A component
176
- chooses classes from props (`tone`, `state`, `primary`, `sm`…) and lays the
177
- contract's children in order. A component that has STATE is controlled like an input
178
- (`state` / `open` / `value` / `flipped` / `selected` + its `on…`, or a `default…` twin), keeps its own behaviour
179
- inside itself and only ever asks through that callback; it never knows another component — the app wires them
180
- (2026-09-21; `cardds.js` is down to `--kb`). Never fix a look in a wrapper: fix the CSS, the
181
- wrapper follows. **Claude Design** builds with these components through
182
- `/design-sync` (`.design-sync/`: the sync config, the authored preview
183
- stories, the conventions the design agent reads) — a new component needs a
184
- preview story there.
185
-
186
- ## Sheet stack markup
187
-
188
- The sheet stack (`.sheet-stack`, `stack.css` — the menu, see *Anatomy*) is the
189
- one component whose markup has rules, since a sheet's place in the stack comes
190
- from its position among its siblings. A stacked sheet is written as a `.card`:
191
- the card is the material, the sheet is what it is in the stack.
192
-
193
- ```html
194
- <div class="sheet-stack sheet-stack--tap"> <!-- drop --tap for a static stack -->
195
- <article class="card card--2">
196
- <div class="card__head">
197
- <button class="icon-btn icon-btn--sm sheet-stack__back" aria-label="Back">…</button>
198
- <h2 class="t-h2 t-caps">Shopping</h2>
199
- <span class="icon-row">…</span> <!-- optional trailing icons -->
200
- </div>
201
- …the sheet's own content…
202
- </article>
203
- <article class="card card--3">…</article>
204
- <article class="card">…</article> <!-- last = the front sheet -->
205
- </div>
206
- ```
207
-
208
- - **Sheets must be direct children** of `.sheet-stack`. Order is the stack
209
- order: first = back, last = the front sheet that shows its full content.
210
- - **`--closed`** (with `--tap`): nothing open on arrival — every sheet, the
211
- front one too, peeks at the stack's bottom; the headroom above is the base's.
212
- An optional `<div class="sheet-stack__base">` — written AFTER the sheets, so
213
- it takes no sheet slot — is what the base shows there, behind every sheet.
214
- Loose things in it rest against the heads, with the same room under the
215
- last one as between two of them (`--stack-base-gap`); one `Page` reads from
216
- the top, one `Centre` stands in the middle. A card standing there is flat:
217
- the base clips at its edges, so a shadow would end in a straight line.
218
- A room too short for the loose things in it squeezes nothing: the upper
219
- thing shows whole from the room's top, the rest lies under the heads, and
220
- the room scrolls up until the last thing stands clear of the heads by
221
- `--stack-base-gap`. `SheetStack` measures this and renders `is-long` on the
222
- base; only then does the base scroll and run under the first sheet's round
223
- corners. A room that holds its content does not scroll, and a finger on it
224
- moves nothing.
225
- A tap, or a pull up, opens any sheet over it. The open title or its back
226
- button, tapped or pulled down, drops it home. A pull down in the open
227
- content, already at the top, asks `onRefresh` — omit it and that pull only
228
- scrolls. A tab in the head stays a click. Every sheet wears a grip — four
229
- small dots at the middle of its top edge, a mark that it slides, not a
230
- control. `mark="line"` on the `SheetStack` (or `Home`) draws one line
231
- there instead, as a single `Sheet`'s; while a sheet is `refreshing`, either
232
- mark changes colour the same way. Right after a fold, while the
233
- pile is still coming back, a tap is answered by where each head RESTS: the
234
- head that will stand under the finger opens, whatever is passing there.
235
- An open sheet stops under the top bar. `rise="flood"` on the `SheetStack` (or
236
- `Home`) — `sheet-stack--flood` in markup — lets it rise over the bar and fill
237
- the whole screen instead: the stack is then the whole screen, the bar a layer
238
- under the sheets (hidden while one is open), nothing resizes. At the screen's
239
- top the open sheet's top corners are square; folded, they are round again. It needs
240
- `--closed` in a `Screen fill`.
241
- - **`Home` takes a sheet's tab from the app.** Each of `Home sheets` keeps its
242
- own tab (`defaultTab`, else the first) unless the app gives it `tab` — then
243
- that tab shows, and a tapped tab asks `onTabChange(tab)`. Something outside
244
- the stack (a card in the base) brings a sheet up on a named tab by setting
245
- that sheet's `tab` and the Home's `open` together. Story `Home · Feed`.
246
- - **Five sheets max.** Slots are declared per `:nth-child` in `stack.css`; a
247
- sixth would render at slot 0, on top of the first.
248
- - **Every head needs its own `.sheet-stack__back`** if the stack is `--tap`. It stays
249
- hidden until that sheet is open, then slides into a reserved header slot.
250
- - **No ids, no wiring.** `SheetStack` holds which sheet is open (`open` + `onOpenChange`, or `defaultOpen`). A pull down in the open content asks `onRefresh(index)`. The stack does not fetch: the app reloads (that sheet alone, or more — its call) and says so with `refreshing` (that index, then `null` when the data is in) — that sheet's grip dots dissolve white to black meanwhile, and a pull there does not ask again.
251
- - **`.card-stack` is gone.** That was this component's first name and the wrong
252
- one — the members are sheets and the stack is the menu, not a list of cards.
253
- Never bring it back, not even as an alias.
254
-
255
- ## Screen: top · content · bottom
256
-
257
- **Every screen is three parts in ONE flex column** (Lh 2026-09-23) — the words used to ask for a screen, and the props
258
- of `Screen`:
259
-
260
- | Part | Prop | What fills it |
261
- |---|---|---|
262
- | **top** | `top` | `TopBar` · `AppBar` · `"lead"` (no bar: the content starts `--screen-lead`, 15% of the screen, down) |
263
- | **content** | children | a `Step` (`Pages` · `Page` · `Page` + `Sheet`) · `Centre` · `SheetStack` … |
264
- | **bottom** | `bottom` | `ActionBar` (rows: `controls` · the one `cta` row — the pager bar ← → or one wide button · a `ctaLink`) · `FloatBar` · nothing |
265
-
266
- ```tsx
267
- <Screen fill top="lead" bottom={<ActionBar variant="pager">…</ActionBar>}>
268
- <Step><Pages>…</Pages><Pager at={1} of={3} /></Step>
269
- </Screen>
270
- ```
271
-
272
- - **Top and content are one flex column; the bottom is a LAYER** (Lh 2026-09-23). In `Screen fill` the content runs to the
273
- screen's bottom edge; the bottom (`ActionBar`, with the walk's `Pager` as its first row) lies over it at the foot and
274
- covers what is under it — on purpose, nothing is added to the content to make room. The keyboard lifts the layer (`--kb`).
275
- - **The bar is INSIDE the screen** — `bottom`, never a sibling after `</Screen>`, never pinned with a hand-set `position`.
276
- A `FloatBar` in `bottom` floats the same way.
277
- - **A screen with a `Sheet` has no bottom.** The sheet finishes on its own `CardFoot`; `Screen` warns once when both
278
- are there.
279
- - **Checked:** `validateScreen` (`scripts/validate.mjs`, run on every story and template by
280
- `tests/composition.spec.js`) fails a bar after `</Screen>` or a pinned bar, warns on a Sheet + ActionBar.
281
- - **Templates** are `Screen`s with the three parts filled (`Walkthrough` = top `lead` · content `Pages` · bottom
282
- `ActionBar`); the gallery draws those parts on a template's page. The gallery's nav groups a screen's
283
- *top* and *bottom*, then each frame with the pieces that fill it: *card* (Card, its head, body and foot),
284
- *sheet*, *stack*, *step*. A piece that also fits another frame says so on its page.
285
-
286
- ## Step markup
287
-
288
- A step is one screen, one task (see *Anatomy*). It is the other component
289
- whose markup has rules, because the sheet's box is the step itself:
290
-
291
- ```html
292
- <!-- reads: the words only -->
293
- <section class="step">
294
- <section class="page">
295
- <div class="card__head"><h2 class="t-h2">…</h2></div>
296
- <p class="t-body">…</p>
297
- </section>
298
- </section>
299
- <div class="action-bar action-bar--pager">…</div> <!-- the screen's bottom: inside .screen, after the step -->
300
-
301
- <!-- acts: the words + a sheet -->
302
- <section class="step">
303
- <section class="page">…the words…</section>
304
- <article class="card sheet sheet--half">
305
- <button class="sheet__handle" type="button" aria-label="ปรับความสูง"></button>
306
- <div class="card__head"><h2 class="t-h2">…</h2><span class="chip">…</span></div>
307
- <div class="sheet__body">
308
- …cardds controls (.field / .pin / .composer / .rows + .check / .segment /
309
- .chip-grid), or small content cards (.card--sm)…
310
- </div>
311
- <div class="card__foot"><button class="btn btn--primary btn--block">…the one button that finishes the sheet…</button></div>
312
- </article>
313
- </section>
314
- <!-- no .action-bar: a bar is the base's, never in front of a sheet -->
315
- ```
316
-
317
- - **`.step` needs a bounded column**: a direct child of `.screen--fill`, or
318
- of an app section with a definite height. It is a flex child that fills the
319
- column between the top bar's reserve and the bar's, and clips at its own
320
- bottom edge — which is what makes the sheet stop above the bar. In a plain
321
- scrolling `.screen` it has nothing to fill.
322
- - **The sheet is full-width; the card keeps the inset.** The step bleeds to
323
- the screen's edges (reading `--screen-pad`, as `.sheet-stack` does) and the
324
- card takes the inset back as its side margins — so the card and the bar's
325
- buttons stand on the inset line, and the sheet runs edge to edge like the
326
- bar and like a sheet in a `.sheet-stage`.
327
- - **`.page` first, `.sheet` last.** The block carries the step's words
328
- ON THE BASE — no frame, no area colour (it was a `.card` until 2026-09-16;
329
- Lh: a step reads like a page, not a card in a page). It keeps a card's
330
- rhythm: a `.card__head`, `--card-gap` between things. The sheet is
331
- **handle · head · body**: the handle, then a `.card__head` title row (that
332
- row is what `peek` shows — heading left, the contextual thing right), then
333
- `.sheet__body` with what the member acts on. The handle and the head are
334
- pinned; the body is the one thing that scrolls, to the card's own edges. The
335
- handle is a line, or four dots (`mark="dots"`): both sit in the card's top
336
- padding, the same distance from the edge, so the title stays put. `refreshing`
337
- (off unless the app sets it) changes the mark's colour while the app reloads
338
- what the sheet shows, as a stack sheet's does; the sheet does not fetch. `paper` makes
339
- the content white while the head keeps the sheet's colour (`tone`, as a card).
340
- The same prop on a `StackSheet`, or on a `Card` in a stack: the head keeps the
341
- tone, the body and the foot are white, and the gap under the head stays the tone.
342
- While that sheet is closed, the corners the next sheet does not cover show the
343
- card's colour. That colour fades in as the sheet returns, and is done when it stops.
344
- A sheet with no head (the dim page sheet) is a body alone.
345
- - **The step follows its top bar directly, and touches it.** `full` means
346
- the sheet's top edge at the bar's bottom edge — nothing above it but the
347
- bar. So the room the screen would have left between the bar and the step
348
- moves inside the step, above the card: `.topbar + .step` cancels the
349
- screen's gap (`--screen-gap`), `.appbar + .step` cancels the reserve's
350
- breathing (`--header-gap`), and the card takes that room as its lead. A
351
- step that follows anything else keeps the screen's gap, and the sheet
352
- stops under that thing.
353
- - **The bar is after the step, inside the screen** (the screen's bottom — *Screen: top · content · bottom*). In a
354
- `.screen--fill` the bar is a layer over the content's foot. On a SCROLLING screen (not fill, 0.7.0) the screen is a
355
- column at least one phone tall and the bar its last row, `position: sticky` at the viewport's foot (above `--kb`):
356
- it stays in reach while the words scroll and sits under them when they end. Either way the bar is written INSIDE
357
- the Screen (`bottom`) — a bar after `</Screen>` is an error (`validateScreen`).
358
- - **Every sheet has one shape** (Lh 2026-09-21): the handle and the head stay at the top and never scroll · the
359
- foot's button stands at the bottom — above the keyboard when there is one (`--kb`: a step gives the keyboard
360
- its room, a stage sheet gives it up itself as `--_lift`) · everything between them scrolls, as far as it
361
- needs to: the body's bottom padding is the part that slid under (`--_cover`), so the last row comes up to
362
- the button at half and 3q as it does at full.
363
- - **Layers: the base, the sheet over it, the top bar** (Lh 2026-09-20). The `.action-bar` belongs to the
364
- BASE — the move of a page of words (a pager bar, or one wide button). It is never in front of a sheet. A
365
- screen with a sheet finishes ON the sheet: a `.card__foot`, the sheet's last child — ONE row, the page's
366
- conclusion. The foot stands on the screen's bottom edge at half, 3q and full alike (a layer as tall as
367
- the sheet, slid up by the share the sheet slid down — `sheet.css`), and is gone at peek. Nothing inside
368
- the sheet's body commits on its own (a `Composer` there takes no `send`). This is how cardds's own screens are
369
- composed — guidance; the framework does not enforce it: a component never changes because another one is there.
370
- - **Never write a sheet's height, and never a `.sheet-stage` inside a step.**
371
- State is a class (`sheet--peek` / `--half` / `--full`); the sheet is always
372
- the step's full height and only slides. In React the APP owns it: `<Sheet state={s}
373
- onStateChange={setS}>` (controlled) or `<Sheet defaultState="half">` (it keeps its own). The sheet's own behaviour —
374
- the handle's tap and drag, the focus raise, the climb when the keyboard moves the cut — lives in the component and
375
- only ever ASKS through `onStateChange(state, reason)`; nothing outside the sheet is touched.
376
- A sheet always ARRIVES and LEAVES by sliding: mounted already up, it starts `away` for one frame and slides in. To
377
- take it off the screen set `state="away"` and unmount it in `onSettled` (`s === 'away'`) — unmounting it while it is up
378
- only makes it vanish (dev warning). Story: `Sheet · MountedOnDemand`.
379
- - **A part that becomes active has its field waiting with the cursor** (Lh 2026-09-29) — a `Sheet` come up, a `SheetStep`
380
- opened, a `SheetStack` sheet opened, a `Modal` arrived. Once it has settled: the field marked `data-autofocus`, else its
381
- first field, else its heading (a step's title, a modal's question) or, for a sheet come onto the screen, its handle. A field
382
- that already has the cursor keeps it. One mechanic for all of them (`focusWhenActive`, src/state.ts); an app writes no focus code.
383
- Another field than the first: `<Field data-autofocus … />`. Off: `data-standby="off"` on any box around the part — no field
384
- is given the cursor (the heading or the handle still is). The gallery sets it on its stories; its Cursor button (C) turns it on.
385
- - **The signal set — four colours, four meanings, everywhere** (Lh 2026-10-02): `--signal-ok` green (it went right) ·
386
- `--signal-warn` yellow (look out) · `--signal-error` red (it went wrong) · `--signal-info` blue (for your information).
387
- The wireframe has them too. A theme may retune a shade, never a meaning. Each is dark enough for words and edges;
388
- `--signal-*-soft` is its light ground (the signal washed 14% into the surface). Never the colour alone: an icon or
389
- words say it too. Read by: `Field`/`Pin`/`FormNote` error, `Dot tone` / `Chip dot="ok|warn|error|info"`, `Snackbar` (ok).
390
- - **Good news is a `Snackbar`, a warning or a question is a `Modal`** (Lh 2026-10-02): a `Snackbar` is a quick line over the
391
- screen — something went well, said in passing. A child of the `Screen`; it slides in from the top (or `bottom`, above the
392
- bottom layer), stays `duration` (4 s), slides out, never takes the cursor, never stops the member. `open` +
393
- `onOpenChange(open, reason)` or `defaultOpen`; unmount it in `onClosed`. One at a time: the queue and the notification
394
- list are the app's — the app shows its newest notification in it, `onTap` opens that notification.
395
- - **Mount a `Modal` only when it is to show.** A `Modal` first mounted with `open={false}` is drawn in place, then leaves,
396
- and calls `onClosed` after about 700ms. Known fault, not fixed: under React StrictMode (any dev build) `onClosed` is
397
- never called for a `Modal` mounted that way. Keep the app's own `mounted` flag: set it when the question is asked,
398
- clear it in `onClosed`.
399
- - **cardds names its own controls, in the page's language** (Lh 2026-09-29): a handle, a back button, a Pin's cells… read
400
- their name from `src/words.ts` by `<html lang>` (en, th ship; `setWords(lang, {…})` adds one). An app passes no label that
401
- only repeats these words; a prop still wins.
402
-
403
- ## Centred card markup
404
-
405
- One card at the human centre — the introduction screens, and any screen with
406
- one thing to read and one move. The heading above, the bar below, the card
407
- between at 40% down the screen (see *Anatomy*, "Where the centre is"):
408
-
409
- ```html
410
- <div class="screen screen--fill">
411
- <header class="topbar"><h1 class="t-h1">ธนาคารเวลายินดีต้อนรับ</h1></header>
412
- <article class="card card--centre">
413
- <div class="card__head"><div><h2 class="t-h2">ปรับขนาดตัวหนังสือ</h2></div></div>
414
- <p class="t-body">…</p>
415
- </article>
416
- </div>
417
- <div class="action-bar action-bar--tiers">
418
- <div class="action-bar__tier"><label class="slider"><input class="slider__range" type="range" min="100" max="160" step="15" aria-label="ขนาดตัวหนังสือ"></label></div>
419
- <div class="action-bar__tier action-bar__tier--pager">
420
- <button class="icon-btn" aria-label="ย้อนกลับ">…</button>
421
- <button class="icon-btn icon-btn--invert" aria-label="ถัดไป">…</button>
422
- </div>
423
- </div>
424
- ```
425
-
426
- - **The card floats in the fill screen** (`.screen--fill` is its box), hugs its
427
- words, keeps the screen's inset, and is capped so it never reaches the title
428
- bar above it nor the bar's room below — past that it scrolls inside. Never
429
- give it a height; never move it with a margin. The cap is twice the room
430
- above the centre (2 × (40% − the title bar)): a card taller than that is
431
- not a centred card — it is a plain scrolling `.screen` (the detail view).
432
- - **A small group at the same centre** — an empty state; a card with its quiet
433
- second button and the `Pager` dots under it — is `.screen__centre` (React: `Centre`), a block in the fill
434
- screen after the topbar. Same placement and cap; it runs edge to edge with
435
- the inset as padding, and stacks its children at the
436
- screen's gap. Never hand-lay `top: 40%` on a screen.
437
- - **The bar is the screen's bottom.** Plain `.action-bar` for one move; with a
438
- control that belongs to the screen (a slider), `.action-bar--tiers` stacks a
439
- control tier above the buttons (it publishes the taller `--bar-reserve` the centred card still reads for its cap).
440
- - **The pager bar** is `.action-bar--pager` (or an `.action-bar__tier--pager`)
441
- holding two peers at the two edges, a skip link between or nothing: two round
442
- `.icon-btn`s, or two `.btn`s that name the step each leads to with the arrow at the
443
- round pager's size ("← คำขอ" · "วันเวลา →", Lh 2026-10-17; the 2026-09-16 "never words" is lifted).
444
- Worded, each hugs its edge at its own size with open ground between: left and right, never two halves. The names are steps; the step that
445
- commits ("Done") still goes back to one wide button (rule 3).
446
- - **Page that moves** (Lh 2026-09-20) — two ways, never both on one screen:
447
- - *Pages of words:* `.step > .pages > .page` (one `aria-current="step"`, the rest `inert`) — every page
448
- one height, the round pager bar turns them (`slideTo()`), the words slide like a page. No sheet there.
449
- ONE SCREEN TO THE NEXT (a route change) slides the WHOLE screen the same way: `slideScreen(() => navigate(to), 'next' | 'back')`,
450
- and `installScreenSlide()` once at app start for the back button (story `Step · WalksOn`). `data-slide="off"` on a Screen = no slide.
451
- ONE OUTCOME, DRAWN ONCE (Lh 2026-10-01): a screen that can come out more than one way (signed in or not, a list or
452
- its empty state, a redirect) is NOT drawn until the app knows which — `<Screen ready={known}>`. Not ready = nothing of it
453
- is drawn; arrived at through `slideScreen` / the back button, the screen before stays until it is ready (at most 3 s), then
454
- the right one slides in. Never one outcome first and another after (story `Step · ArrivesReady`). On a first load there is
455
- no screen before: hold the `Splash` (`done`) until the first screen is known.
456
- THE BACK STACK (Lh 2026-10-01): back takes ONE thing off — a screen (a route), or a LAYER: what lies over a screen, a whole
457
- bottom sheet or a modal. A layer is one history entry, kept in the entry's STATE (the address stays the page's own, so the
458
- screen behind does not slide): `const way = useLayer('way')` → `way.value` (null = not up) · `way.open(value)` (one entry;
459
- already up = the same entry shows the new value) · `way.close()` (the part's own close: the handle, the scrim, a button —
460
- back one entry, or, when it came with the page, the entry without it) · `way.arrive(value)` (a link straight to an open
461
- sheet: read `?way=` once, no entry added, the query dropped). `useLayers().closeAll(then)` takes every layer off, then moves
462
- (signing out). NOTHING INSIDE A LAYER TAKES AN ENTRY — a step, typed text, a sheet's rest, a tab: back at any step takes the
463
- whole sheet down, and opened again it starts anew (a new `key` per opening). ONE LAYER PER TICK: two layers opened in
464
- the same handler lose the first (`a.open(1); b.open(2)` gives `a.value` null and `b.value` 2). Open the second in a
465
- later tick. The project hands its router over once:
466
- `<LayerHistory state push replace back>` around its screens — react-router: `location = useLocation()`, `state = location.state`,
467
- `push = (s, search) => navigate({ pathname: location.pathname, search: search === 'drop' ? '' : location.search }, { state: s })`, `replace = (s, search) => navigate({ pathname: location.pathname, search: search === 'drop' ? '' : location.search }, { replace: true, state: s })`,
468
- `back = (n) => navigate(-n)`. cardds never calls the History API itself. Story `LayerHistory · Back`.
469
- A FINISHED PROCESS IS NOT GONE BACK INTO: history cannot be deleted, so (a) inside a process every move on is a `replace`,
470
- never a push — when it ends, back skips all of it; (b) a page that can no longer be returned to guards itself: it checks on
471
- arrival (`Screen ready={false}` meanwhile) and, when its work is already done, replaces itself with the right screen — that
472
- covers forward, an old link, and entries left ahead after `closeAll`.
473
- - *Sheets the app calls:* the base offers WAYS IN — sign in · new phone · join — as buttons (`.cta-pack`); each
474
- way has a sheet that holds its WHOLE form and its own submit on its foot, starting `.sheet--away` (not on the
475
- screen: slid out, hidden once gone, `inert`). **The wiring is the app's, not cardds's**: a `useState` of which
476
- sheet is on and where, each button sets it, each `<Sheet state onStateChange>` reads it — one at a time, the same
477
- button raises a peeked sheet, the handle stops at peek (`states="peek full"`). Story `Step · Calls` is those few
478
- lines; it could as well be a route, which is what makes the phone's back button close a sheet. CSS's only part:
479
- behind a peeked sheet the words keep `--sheet-peek` of room (the box declares the token), so the buttons under
480
- it scroll clear.
481
- **Never a form taken apart** — one sheet per field, a "send" left on the base: a form lives whole in ONE sheet.
482
- Fixture `tests/fixtures/calls.html`, tests `tests/calls.spec.js`, stories `Step · Walks` / `Step · Calls`.
483
- - **A pager bar turns pages of words, never a screen with a sheet** (Lh 2026-09-20). A
484
- sheet means this screen has work that cannot be walked past: no ← →, no bar at all — the sheet's
485
- own foot finishes it. (Guidance — not enforced.)
486
- - A screen is **top · content · bottom** on the base (*Screen: top · content · bottom*). The base is the
487
- surface; top and bottom are the chrome above and below (`.topbar` / `.appbar`,
488
- `.action-bar` / `.float-bar`); content is everything between.
489
- - Content has three homes: **on the base**, **in a card**, **in a bottom
490
- sheet**. Content normally comes in cards; static data (a blurb, a legal
491
- note, help steps) may sit on the base directly.
492
- - **A card is head · content · foot.**
493
- - **Head** (`.card__head`): the heading on the left; hints, actions or a
494
- more-menu — the contextual info — at the far right. With nothing at
495
- the far right it runs all the way across. The heading wraps freely —
496
- no line budget, no squeeze, no clamp (Lh 2026-09-16): a height rule on
497
- words only distorts them. The head is FLAT markup — heading, then an
498
- optional caption as a span, then the far-right thing; no
499
- wrapper element, no `<p>`: the head wraps, the caption takes the row
500
- below (`order`), the gap tokens do the rest (Lh 2026-09-16).
501
- - **Content**: whatever the card frames — rows, key-values, a gauge, a
502
- paragraph. Frameless, per rule 9.
503
- - **Foot** (`.card__foot`): the card's action buttons on one line. One
504
- button takes the width; up to four share it in even parts; from three
505
- on they drop their side padding so the labels keep the room. A panel of
506
- up to four icon buttons (`IconBtnGroup`) takes the foot the same way; with
507
- `form` it is as wide as its buttons (one tap each) and stands in the middle,
508
- as `Btn form` does — in a foot, a `Page`, a `Centre` or a `CtaPack` — and
509
- with `form labelled` its `label` is SHOWN as words at its left, the two
510
- centred as one line (Lh 2026-10-10).
511
- - In React the head and the foot are the card's **slots**:
512
- `<Card heading caption end foot footLink>` with the content as children —
513
- the card writes the order (head first, foot last). A head of its own shape
514
- is a `<CardHead>` child instead; never both (Lh 2026-09-27).
515
- - **A card that goes on is pressed whole** (Lh 2026-10-09). A card whose ONLY
516
- control is its foot's button, or the end button of a row card, is pressed
517
- anywhere: a touch off the button runs that button's own action once, and the
518
- whole card dims under the finger (`.card--go`, the card-wide press answer of
519
- a row or a card link). `Card` does it by itself — the app wires the button
520
- and nothing else, and there is no prop to turn it off. The keyboard and a
521
- screen reader meet the button only. Every other card is only read: two or
522
- more controls anywhere in it (a field, a choice, a nested card's button),
523
- the one button in the head or in the content, a disabled button, a sheet, a
524
- stack's sheet, a modal. A card that is itself a link or a label was pressed
525
- whole already. Story `Card · PressedWhole`.
526
- - **A feed is a queue of cards, all in sight** (Lh 2026-10-09; the whole
527
- queue 2026-10-15). `CardFeed` takes the WHOLE queue as `FeedCard`
528
- children, in the app's order, and draws every one: ONE is BIG (heading,
529
- content, its button) and the rest are SMALL (the same card cut down to its
530
- head — no content, no button). A touch anywhere on a small one makes it
531
- the big one and the big one small; none changes place. The small card is
532
- itself the button (named by its heading, Enter and Space) and what it does
533
- not show cannot be pressed, focused or read; the big card is a plain card,
534
- pressed whole by the rule above. The queue is the app's, and so is its
535
- length (5, then 10…): near the end the feed asks for more with
536
- `onMore()`, once per queue, when the card with `ahead` (default 2) cards
537
- under it comes into view; the app appends, and the feed asks again at the
538
- new queue's card. When the app says the queue is complete it passes `end`
539
- (the words drawn after the last card) and the feed asks no more. When a
540
- card LEAVES or the order changes, what is left slides up and the UPPER card
541
- is big, whichever was big before; cards appended at the end fade in and ask
542
- nothing, so a member who scrolled down stays there. One card alone is big;
543
- no card, nothing is drawn. Which card is big is controlled like an input:
544
- `big` (a card's `id`) + `onBigChange(id, 'tap' | 'queue')`, or
545
- `defaultBig`. A `FeedCard` is `id` · `heading` · `caption` · `icon` (its
546
- thumb, in both sizes) or `lead` (anything at the head's start instead: a
547
- `Stamp` is the small stamp, 48 × 60, an `Avatar`; it wins over `icon`) ·
548
- `tone` · `foot` (one `Btn`: the big card is pressed whole; two or more:
549
- only the buttons act) · children (the content); its size is the feed's,
550
- never a prop. Knobs `--card-feed-gap`, `--feed-card-head-h`. Reduced
551
- motion: nothing slides or fades. Stories `CardFeed · Default`, `More`,
552
- `StampLead`, `Home · Feed`.
553
- - A page with **one feature and one CTA** (an install page, a single
554
- decision) may be contained in one card: head, the pitch, foot.
555
- - **A bottom sheet holds what the member acts on** — controls, or small
556
- content cards to pick from or read more — and it opens to the full height
557
- of its box. **Cards are content the system offers**: the words, the
558
- explanation, the thing to read. Do not put the step's explanation in a
559
- sheet.
560
- - **The sheet is the paper the member fills in** (Lh 2026-10-18). Every
561
- control the member fills in or picks stands on a sheet: a `Sheet`'s
562
- `SheetBody`, a `SheetStep`, or a stack sheet. That is `Field`, `Composer`
563
- and `ComposerArea`, `Pin`, `Dropdown`, `Slider`, `Toggle`, `Check`,
564
- `ChipPick`, `Calendar`, `DayStrip` and `Day`, `Picker`, `Mood`,
565
- `AvatarPick`, `FileBtn`, `AddRow`, `MapPick`, a `Tile pick` and a
566
- `Tile upload`. A card is for reading and pressing buttons only; never put
567
- a form, or one control of it, in a card. `cardds check` rule
568
- `field-in-card` (error) flags a control written inside a `<Card>`. A `Card`
569
- that is a sheet of a `SheetStack` is that stack's sheet, so it may hold
570
- them. Seen in timebank's help-ask prototype: a Composer, a Dropdown and a
571
- map on white cards inside the help sheet, the cards the colour of the
572
- ground.
573
- - **Two sheet systems, one material.** *Sheet single* (`.sheet`, `sheet.css`)
574
- is ONE sheet the member acts in, whatever it holds: a short entry in front
575
- of a step's card, a place on a map, a payment on a dim page — in a `.step`
576
- or a `.sheet-stage`, and its state is how far it slid (peek / half / 3q / full).
577
- *Sheet stack* (`.sheet-stack`, `stack.css`) is MANY sheets, each a main
578
- section of the app, stacked like paper at the middle of the home screen:
579
- the menu. Heads peek, a tap brings one sheet to the front, back returns it;
580
- each sheet is the metaphor for the section it opens. Both are cut from the
581
- same material — a stacked sheet is written as a `.card`, and the single
582
- sheet is `.card.sheet` — and both obey rule 8: rigid, full height, moved
583
- by `translate` only. (The stack was first named "card stack": wrong, and
584
- retired.) See them live in the gallery: `Sheet`, `SheetStack`.
585
- - **A step is one screen, one task: `.step`.** What the member reads is
586
- words on the base (`.page` — the same block as anywhere on the base; it was `.step__card` until 2026-09-16); what the member does, if the step asks for it, is a
587
- `.sheet` in front of it; the screen's one move is `.action-bar`, the screen's
588
- bottom, after the step. A step where the member only READS is the card alone.
589
- A step where the member ACTS (types, picks, shoots a photo, records) adds
590
- the sheet: peek shows its handle and title row, half shows half of it,
591
- full slides up until its top edge touches the top bar — and at every one
592
- of those it stops above the bottom bar, because the step is the box it
593
- slides in: it starts where the top bar ends and ends where the bar's room
594
- begins. Nobody lays a step out by hand and nobody writes a
595
- sheet's height. In short: **member acts → card + sheet, member reads →
596
- card.** Markup: *Step markup*. Live: the gallery's `Step` page.
597
-
598
- - **Where the centre is.** To the person holding a phone, the bottom fifth of
599
- the screen is the thumb's — the bar, the controls — so what is meant to be
600
- read sits at the middle of the four fifths above it: **the human centre is
601
- 40% down the screen, not 50%** (`--screen-centre`, Lh 2026-09-10). A screen
602
- with one card and nothing else to read puts it there (`.card--centre`, the
603
- introduction screens); a splash puts its mark there.
604
-
605
- Live: every screen in the gallery (`npm run dev`).
606
-
607
- ## Sticky notes
608
-
609
- A sticky note is a square of paper with a few words on it. The family is in `src/sticky/`, its CSS in `css/sticky.css`.
610
-
611
- - **One note, one size.** Every `Sticky` is one square, 168 by default, with square corners. It never grows with its
612
- words. Words it cannot hold at its size are written one size smaller (`--sticky-small-font`, the label size), and
613
- the note then holds six lines instead of four and a half. Words longer than that are cut where their room ends, with
614
- no ellipsis. A `StickyField` stops at that room: the note refuses the input that would not fit, and the app's
615
- `onChange` is not called for it. One knob sets the side, `--sticky-size`; set it on an element to resize the notes
616
- inside that element only.
617
- - **The fold says unread.** A folded bottom-right corner means the note has not been read. `flat` means it has. A
618
- folded note also says so to a screen reader after its words: cardds's own word by the page's language, or
619
- `unreadLabel`.
620
- - **`Sticky`** — `tone` is one of the nine papers in `STICKY_TONES`. `head`, `date`, `foot` are small rows above and
621
- under the words. `onClick` makes the whole note a button. `stuck` holds what is stuck on the paper (`StampOn`).
622
- `tilt` turns the one note a start screen hands over. A `Chip sm` in the `foot` is a name label on the paper: it
623
- stands at the start, what follows it (a time) at the end, on one line.
624
- - **`StickyBoard`** — notes stuck on a page. Rows wrap by the width: two on a phone, three from 504 of room, a screen about 540 wide.
625
- The notes stand edge to edge and each is turned by one of four angles in turn (`--sticky-board-turn-1` to `-4`), so
626
- no two notes that touch lean alike. Later is on top, always: the right note over the left, the lower over the
627
- upper. `--sticky-board-lap` (0) is how far each lies over the one before. Where two notes do not fit side by side,
628
- each lies over the one before by what is missing, up to half the fold; past that there is one note in a row. A
629
- note's `foot` is laid over the note's foot and takes no room from the words, so a note reads the same on the page
630
- as where it is opened.
631
- - **`StickyThread`** — notes as a conversation, in two ways. With head rows: each `StickyPost` shows `who`, `name`
632
- and `time` above its notes, and `rail` joins the avatars for a feed. `lapped`: a chat with no head rows. Theirs
633
- stand at the left and mine (`side="right"`) at the right, each turn lying over a corner of the one before
634
- (`--sticky-thread-lap` down, `--sticky-thread-lap-x` across), the time close under its note, the name only heard.
635
- Two turns from one side stand `--sticky-thread-same-gap` apart.
636
- - **`StickyHeading`** — what a chat told in notes is about, for as long as that lasts: a `title` and a short `caption`
637
- (when) written on what it stands on, a dotted rule under it; information only. It stands in two places and no other
638
- component has a prop for it. In a `Sticky`'s `stuck` (the list) it lies across the top of that chat's note, and the
639
- note's words start under it and show two lines at most. As the first child of a `StickyThread` (the room, with
640
- `as="h2"`: the sub-heading under the screen's h1) it stays at the top while the notes run under it. The app stops
641
- rendering it when the subject ends. Knobs: `--sticky-heading-title-font`, `-caption-font`, `-gap`, `-room`.
642
- - **`StickyAnswer`** — the note you answer on. Its child is a `Sticky flat` holding a `StickyField`. At rest it waits
643
- at the screen's foot near the right, three quarters of it in sight (`--sticky-answer-show`). A touch on it puts the
644
- cursor in the field and the holder asks to write: `writing` + `onWritingChange(writing, reason)`, or
645
- `defaultWriting`. While writing, the note stands at the top, the screen behind is washed pale and takes no touch,
646
- and `tools` (the paper chooser, the send button) and `tray` (the writer's stamp) stand under it. A touch outside or
647
- Escape asks to stop. The words, the paper and the stamps on the note are the app's. It is a child of the `Screen`.
648
- - **`StickyPile`** — one to six notes lying one over another, as the newest notes of a feed do. It is a feed item of
649
- its own, with no card frame. The first child is on top, so pass the newest first. Each note under it lies a step
650
- right and down (`--sticky-pile-step-x`, 24, and `--sticky-pile-step-y`, 8), turned by the board's four angles by
651
- its place, so the top note turns the same whatever the count. Only the top note draws its `foot`. At 375 a pile of
652
- one is 184 × 184, of three 232 × 200, of six 304 × 224 (`--sticky-pile-pad`, 8, is the room round the notes for
653
- their turn). The pile stands in the middle of its column and is never wider than it: a narrow column shortens the
654
- steps. A seventh note is not drawn, and the console says so once. With `onClick` the whole pile is one button, and
655
- `label` is its name (required with `onClick`): one Tab stop, a ring round the pile. Where the press goes is the
656
- app's. A `StampOn` on the top note keeps its own touch; the stamps on the notes under it are inert. A pile's notes
657
- all wear their stamp at one size.
658
- - **`StickyField`** is the bare textarea on a note. **`StickyPick`** is the paper chooser: `value` + `onChange`,
659
- `tones` for the ones on offer.
660
-
661
- Whose colour a note wears is the app's rule. The gallery's `StickyScreens` shows one: a neighbour's note wears the
662
- ground of that neighbour's stamp, and my note wears the paper I picked while writing, from a writing set plus the
663
- ground of my own stamp.
664
-
665
- ## Stamps
666
-
667
- A stamp is a small paper picture a member keeps and sticks on a note or a postcard for a neighbour. Sending never uses
668
- it up. cardds draws the paper; the pictures, their words and which stamp a member owns are the app's.
669
-
670
- - **`Stamp`** — a perforated 4:5 stamp. `pictures` is one to four nodes, the app's own art (an svg, an img), in placement
671
- order: the stamp places, sizes and turns them by how many there are. `tone` is the ground, one of `STICKY_TONES` (the
672
- same list and the same `--sticky-<tone>-bg` knobs as a note's paper). `name` is the big line on the picture's foot,
673
- `words` the small line above it. `tilt` turns it, `onClick` makes the whole stamp a button. Every inner length follows
674
- the stamp's width.
675
- - **A stamp is one size wherever it stands** (Lh 2026-10-11). A `Stamp` and a `StampPlace` are `--stamp-size` wide (two
676
- taps: 96 × 120) in a `Centre`, a sheet, a page, a `StampBook` and a card's head. What holds a stamp only places it. A
677
- holder narrower than a stamp shrinks it, and the stamp is still shown whole. Set `--stamp-size` on `:root` for every
678
- stamp, or on an element for the stamps inside it. There is one other size, the small one: a stamp in a `StampOn` and a
679
- stamp in an `AvatarStack` are `--stamp-sm-size` wide (one tap: 48 × 60), on any surface and in any row. The stamps in
680
- a `PostDesk`'s tray are the standard size. One exception, by the owner's decision: the stamp at a postcard's corner is
681
- the postcard's own size, 72 × 90 (`--post-corner-stamp-w`), in neither. `words` is not drawn on a stamp 96 wide or
682
- narrower, so at the standard size only `name` shows, and it is larger.
683
- - **`StampPlace`** — an empty place the size of a stamp, its edge a ring of round dots: a word inside, `onClick` for "make
684
- yours", `faint` for a gap nobody is asked to fill. The edge is a drawn stroke, so the dots are yours: `--stamp-place-dot`
685
- is how wide each is (default `--rule-w`, 2px), `--stamp-place-gap` the distance from one dot's centre to the next
686
- (default 6px), and a `--stamp-place-dash` above 0 stretches each dot into a round-ended dash. Its corner is
687
- `--stamp-place-radius`. The dots' outer edge is the box's edge whatever their width.
688
- - **`StampBook`** + **`StampCell`** — the album page: cells three across on an album ground, a `label` under each stamp,
689
- the free places drawn up to `places`. A cell with `onClick` is a button; `on` marks it pressed. Each stamp stands in
690
- the middle of its third. `bare` is the page with no ground, no padding and no round. `strip` is one line of stamps
691
- that scrolls sideways, the tray a member picks from. `fill` is the stamp's third size: each stamp as wide as
692
- its third, 4:5, the gaps one `--screen-pad` both ways (`--stamp-book-fill-gap`), nothing at the row's sides; with `bare`
693
- at 375 a stamp is 103.67 × 129.58. A `false` or `null` child holds no place.
694
- - A stamp stands for a person in three more places. In a `Centre` — a `Stamp` or a `StampPlace` — it stands in the
695
- middle and the words and link under it centre. In a card's head — `<Card heading={name} caption={greeting}
696
- end={<Stamp bare …/>} foot={<Btn primary block>…</Btn>} />` — the words stand at the left and the stamp at the right,
697
- beside both lines; the card's one button presses it whole. In an `AvatarStack` it is a head, the small size
698
- (`more="+9"` for the count).
699
- - **`StampOn`** — a stamp stuck on a surface. `at` is the stamp's middle as a share of the surface (`{ x, y }`, 0 to 1),
700
- `turn` its angle in degrees. With `onAtChange` the member drags it (the arrow keys move it too) and it asks with the
701
- new place when the finger lets go; without it the stamp is still. The surface is the nearest positioned box:
702
- `Sticky stuck={…}` is one. The stamp is the small size, 48 × 60 whatever the surface's size (`--stamp-sm-size`), and
703
- on a note it may lie over the words. With `arrive` the stamp sticks itself: as it comes onto the screen it is struck
704
- onto its place, and `onArrived` tells the app when it has landed (at once where motion is off), so the app does what
705
- follows. A `Card` and a `Sheet` are positioned boxes, so a `StampOn` written inside one lies on it.
706
- `--stamp-on-arrive-time` sets how long the strike takes.
707
- `main` makes it the stamp's main size, 96 × 120 (`--stamp-size`; 90 × 112.5 on a screen 360 wide), to cover what
708
- it lies on. With `onRemove` the stamp can be taken off: when a drag ends or an arrow key moves it, a cross stands on
709
- its top right corner for `removeFor` ms (default 3000). A press on the cross, or Delete / Backspace on the focused
710
- stamp, asks, and the app takes the `StampOn` away. A stamp with `onRemove` and no `onAtChange` shows the cross on a
711
- press. The cross is a 24 disc (`--stamp-on-remove-size`, `--stamp-on-remove-bg` #141414, `--stamp-on-remove-ink`
712
- #ffffff) in a 44 target, centred on the corner and upright whatever the stamp's `turn`. Its name is cardds's word
713
- ("Remove" / "เอาออก"), or `removeLabel`. A press or a drag on a removable stamp never reaches what lies under it.
714
- A stamp that is still arriving cannot be taken off yet: its cross waits until it has landed.
715
-
716
- What an app stores for a stamp: the picture ids (1 to 4, in order), the ground tone, the name, the small line. Making
717
- one is the app's screen: `ChipPick` checkboxes that each show a picture, the app refuses a fifth, and the app draws
718
- ONE of the picked chips at random for the ground and keeps it, so the stamp renders the same every time. The gallery's
719
- `StampScreens` page has the four screens: the first stamp on a home, one from a neighbour, make your own, a note with
720
- stamps.
721
-
722
- **`Mark`** is a highlighter stroke over a word inside running text: `<Text level="body"><Mark>ธนาคารเวลา</Mark> ส่ง…</Text>`.
723
- Green (`--signal-ok`) unless `tone` names another signal; `--mark-bg` / `--mark-ink` retune it. It is information, never
724
- a control, and never a chip or a line of its own.
725
-
726
- ## Postcards
727
-
728
- A postcard is the one piece of mail with no envelope: the written side is the card, and the corner (a stamp, the postmark
729
- struck over its edge) proves the trip. Three components, one for each thing a member does with one. None of them flips;
730
- the older `Postcard` (a member's cover card that turns over) is another component and shares nothing with these.
731
-
732
- - **`PostLetter`**: reading one that arrived. Two upright 2:3 sheets, one in front. The picture side is `picture` (the
733
- app's img or svg) in a white margin with `caption` under it; the written side is the address (`kicker`, `to` lines) over
734
- the message (`children`, set large, `sign` at its end), with `stamp` and `postmark` on the corner. The tab over the
735
- card, or a tap on the card, turns it: the sheet in front swings to its edge and the other swings in. `face` +
736
- `onFaceChange`, or `defaultFace`. `voice` is a voice note's time as the app words it; with it the picture side has a
737
- play control (`playing` + `onPlayingChange`). cardds never plays sound: the control asks, the app plays and passes the
738
- time. `journey` is one line under the card.
739
- - **`PostDesk`**: writing one. The paper is landscape and twice the screen, and the phone looks at one half (`half` +
740
- `onHalfChange`): the message on ruled lines, then the address (`address`, a few `PostDeskLine`s) and the stamp box.
741
- Typing stops when the half is full; `onLinesChange(left, fits)` and `onFull` let the app word its `note`. `stamps` is
742
- the sender's own stamps (`{ id, stamp, label }`), shown under the card; `stamp` + `onStampChange` is the one in the
743
- box. Sending is the screen's button, never the desk's: the app sets `state="struck"`, the `postmark` lands, the desk
744
- asks for `sent`, the card leaves upward and `sent` stands in its place.
745
- - **`PostBox`** + **`PostBoxCard`**: keeping many. Cards whole and landscape, one behind another, each showing its top
746
- edge (`from`, `when`, `stamp`, `postmark`). The last child stands in front, nearest the thumb. Pressing an edge parts
747
- the box at that card (`open` is its `id`, `null` is the front card; `onOpenChange`).
748
- - **`Postmark`**: the round mark, `place` and `date`. Information, nothing to press.
749
-
750
- The stamp is always a `Stamp` the app hands over. On a card's corner it is the postcard's own size, 72 × 90
751
- (`--post-corner-stamp-w`; it is not one of the stamp's two sizes), and shows its pictures only (pass `bare`; the corner
752
- drops the words by itself too); the postmark is struck over it by 0.4 of its own size. The empty stamp box in a
753
- `PostDesk` is the same size and shows its word. In the desk's tray a stamp is the standard size, 96 × 120
754
- (`--stamp-size`), and keeps its words; the tray is one line that scrolls sideways when the stamps are wider than the
755
- screen (three fit a 343 column). `--post-desk-stamps-gap` is the space between them. `PostLetter` and `PostDesk` size
756
- themselves to the
757
- screen they are in, so they go in a `Step` > `Page bleed`. **`Page bleed`** runs the page to the screen's edges and keeps
758
- the inset as its own padding, so a card that leans, slides or casts a shadow is cut at the glass, not at the inset.
759
- The gallery's `PostScreens` page has the three finished screens (read, write, keep) to start from.
760
-
761
- ## Rules of the system
762
-
763
- 1. **One card = one boundary.** A card frames one thing: a category, a stat
764
- group, a list row. Tone slots: `.card` (card-1), `.card--2`, `.card--3`.
765
- 2. **Sections are not cards.** Page sections = frameless on-surface content
766
- (`.page`) + a lone/packed CTA (`.cta-pack`).
767
- 3. **Surrounding UI is separate.** `.topbar`, `.filter-row`, `.chip-row`,
768
- `.action-bar` live outside cards, above/below. The bottom bar has two
769
- arrangements and they mean different things: the default pack (one wide
770
- CTA, optional round icon) is **one action** on this screen, and
771
- `.action-bar--pager` (two equal buttons at the edges, indicator between)
772
- is **a sequence you can walk both ways**. A wide button next to a small
773
- round one reads as an action plus an afterthought — wrong whenever back
774
- and next are peers. The step that commits goes back to the default pack.
775
- 4. **Components reference role tokens only** (`--surface`, `--card-1-bg`,
776
- `--accent`…), never hex. Derived tints are tokens too (`--outline`,
777
- `--hairline`, `--ink-tint`, `--ink-rail`, `--accent-soft`, `--scrim`), and
778
- masks use `currentColor` as "opaque". So palettes swap freely.
779
- 5. **`.screen` carries the horizontal inset and nothing vertical.** The inset
780
- is `--screen-pad`; cards and on-surface UI sit flush inside it, with no
781
- per-component side padding. Top and bottom are zero: vertical room only
782
- is taken by what is in the column. The bottom bar is always the screen's last child (0.7.1 — the bar fixed to
783
- the viewport from outside and its reserves are gone): in a `.screen--fill` a layer over the content's foot, on a
784
- scrolling screen a `sticky` last row. `--bar-reserve` stays published only for a `.card--centre`'s cap.
785
- **Padding is published, not copied.** `.screen` sets `--screen-pad` and
786
- `--screen-gap`, its appbar reserve sets `--header-gap`, and `.card` sets
787
- `--card-pad` and `--card-gap`; anything that has to cancel a container's
788
- padding or gap reads the token rather than repeating its value. `.sheet-stack` uses it to
789
- break out full-bleed when the column touches the viewport edges (≤27em /
790
- 432px), and `.bleed` uses it for a scrolling track inside a card:
791
-
792
- ```html
793
- <div class="avatar-pick bleed">…</div>
794
- ```
795
-
796
- The track runs to the card's edges so it reads as scrollable, while the
797
- first and last item stay on the same line as the heading. Works for any
798
- horizontal strip — `.chip-row`, a tile grid — in any container that
799
- publishes `--card-pad`.
800
- 6. **The sheet stack is the menu, and a sheet is rigid material.**
801
- `.sheet-stack` (`stack.css`) sheets are tall: heads peek (`--stack-step`,
802
- up to 5 layers), each head = title + `.icon-row` functions (head text =
803
- icon height, 28px). On a `.screen--fill` the stack stretches and bleeds
804
- off the bottom edge — bottom corners square. Markup rules: see *Sheet
805
- stack markup* above; the two sheet systems: *Anatomy*.
806
-
807
- `.screen--fill` is exactly one phone height — a height, not a minimum, so
808
- a `.step` inside it has a bound to fill and its card and sheet scroll inside
809
- rather than growing the page — and it clips (`overflow: clip`, never
810
- `hidden`): a clipped box is not a scroll container, so nothing, not even
811
- the browser revealing a focused field, can shift the screen up.
812
-
813
- In a `.sheet-stack--tap`, a sheet is rigid material — it moves in, moves
814
- out, or is covered, but **never changes size**. Every sheet is the same
815
- full height, set once; a peek is not a short sheet, it's a full sheet the
816
- next one covers. So `translate` is the only animated property, and
817
- **z-index is the resting `--level` order and is never touched** — because
818
- whatever sits in front of the tapped card is exactly what has to leave.
819
- Opening slides the tapped card up by its own `--slot` × `--stack-step`;
820
- cards after it move out below (`.is-after`); cards before it tuck one
821
- step down, staggered nearest-first via `--stagger` (`.is-before`), and
822
- get covered. Closing reverses it in two phases: the after-pack returns
823
- while the card drops into its slot, then the before-pack slides back up
824
- from behind. Slots, offsets and paint order are all CSS `:nth-child`, so
825
- a stack renders correctly with no script. The app owns which index is
826
- open and whether that sheet's data is in. The card renders what those
827
- mean (`is-open`, `is-before`, `is-after`, `is-loading`, the grip, the
828
- pull) from the `stack` prop `SheetStack` passes it. Don't reintroduce
829
- height, `display` or z-index switching here — every past bug in this
830
- animation came from exactly that. Don't stamp those classes onto the
831
- card after it has rendered.
832
- 7. **Everything shrinks to 320px.** `.screen > *`, `.card` and `.card > *`
833
- carry `min-width: 0`, so no child's min-content (an input, a fixed row)
834
- can widen a card's grid track past its padding; topbar titles ellipsize;
835
- segment items share width. Never give a component a fixed width that can
836
- beat the viewport — let it `flex: 1` up to a `max-width` token instead.
837
- 8. **A single sheet is rigid material, like a sheet in the stack.** It is always the full
838
- height of the box that holds it (`inset: 0`, set once, never written
839
- again) and a state is nothing but how far that full card slid:
840
- `sheet--full` slid nowhere, `sheet--3q` slid down by `--sheet-3q` (a
841
- quarter of itself — three quarters show), `sheet--half` by `--sheet-half`
842
- (50%), `sheet--peek` slid down until only `--sheet-peek` shows — the
843
- handle and the title row. A tap on the handle toggles between the sheet's
844
- own state and peek (never a step-by-step climb, Lh 2026-09-16); a drag
845
- snaps to any listed state, and where it lands is what a tap returns to. `translate` is the only animated property
846
- (`--motion-sheet`), and nothing toggles height, display or z-index for a
847
- state — the stack's rule, inherited. The box clips whatever slid below its
848
- bottom edge, so a sheet at any height is still a card: rounded top, a
849
- straight cut at the box's edge — and its shape is handle · head · body:
850
- the handle and the title row are pinned, `.sheet__body` scrolls beneath
851
- them, and the body's `scroll-padding-bottom` is the slid-under part (in
852
- the sheet's own container units, `--_cover`), so a focused control is
853
- revealed above the cut, not under it. `--sheet-half` is a share of the
854
- sheet's height (`0.5`), one number serving both the translate and that
855
- padding. Two boxes hold sheets and the states
856
- mean the same in both: `.step` (an app screen — Anatomy)
857
- and `.sheet-stage` (a page-level overlay: a sheet over a map, the dim page
858
- stage `--dim` + `.modal` = `__lift` + `__drawer` — a sheet always stands on the floor, never raised).
859
- A stage keeps the top bar's line free (`--_top`), so `--full` stops under
860
- `--header-h` and keeps its corners. Both sheet tokens are published — a
861
- sheet with a taller head retunes `--sheet-peek` — and registered in
862
- `tokens.css`, which is how `Sheet` reads them to snap a drag. Live: the
863
- gallery's `Sheet` page.
864
- 9. **Elements inside a card are frameless.** Rows, key-values, pickers,
865
- gauges and tiles never draw a second card boundary; hairlines
866
- (`--hairline`) separate rows, `--outline` keeps controls legible on
867
- colored palettes. Data goes in as unitless custom properties
868
- (`--value`, `--done`, `--total`); the math lives in CSS.
869
- 10. **The dark slot re-points the accent.** `.card--3` sets `--accent` /
870
- `--on-accent` to its own ink and bg, so every accent consumer (buttons,
871
- checks, toggles, rings, selected days) inverts without extra rules.
872
-
873
- **A screen is scanned, not read** (Lh 2026-09-24) — a member arrives with one
874
- question and hunts for its answer. Rules 11–13 serve that:
875
-
876
- 11. **Everything holds to edges.** Every element touches at least two edges —
877
- the card's, the column's, or a line made by the thing above it (a row, the
878
- avatar + name line). Words to READ are aligned start, values end (the
879
- receipt: items left, prices right; `.card__head`, `.kv`, `.row`); centred
880
- only what is not read — dots, a lone number, an icon, a mark. Empty room is
881
- not filled with more content; a new edge is made instead (line the buttons
882
- up with the avatar). One exception: a small control with its own words
883
- (`IconBtnGroup form labelled`) is one centred line — the words name the
884
- control, they are not content to be read.
885
- 12. **Show, don't explain.** Where a known picture carries it — an avatar for a
886
- person, an icon, a `Ring`/`Track`/`NumberBand`, a `Chip` for a category — use it
887
- instead of words. Never add a label, a caption line, a comparison or a
888
- tooltip to explain what the picture already says (the retired overline was
889
- that mistake). Long lists are differentiated, not spaced out: group them
890
- (`Rows heading` per group), put the avatar where a person is named.
891
- 13. **Emphasis is the difference from the neighbours.** An untouched default
892
- is quiet (grey, thin border, no fill — `Chip muted`, `Dropdown muted`);
893
- only what the member changed, or the one thing asked of them, takes the
894
- accent. To make one thing stand out, calm what surrounds it — never
895
- bolder, never a second colour.
896
- 14. **A frame says "tap me".** Inside a card, only a control draws its own
897
- edge (a chip, a field, a pick tile, a small `Card sm` to pick). What is
898
- only READ never gets a frame — no card in a card: a stat tile stands on
899
- the card's ground, a note is a `Banner` (a light band, a rule above and
900
- below, edge to edge), a count is words (`Text caption muted`), never a
901
- chip. A chip or tile that looks pressable and is not lies.
902
-
903
- ## A project's theme
904
-
905
- A project retunes cardds from ONE file of its own, and nothing else. Copy
906
- `css/theme.template.css` into the project as `theme.css`, load it **after**
907
- `cardds.css`, change values:
908
-
909
- ```css
910
- :root {
911
- --sp-base: 0.3rem; /* roomier */
912
- --r-base: 0.5rem; /* tighter corners */
913
- --color-surface: #faf6ee; --color-ink: #1f1b16; --color-paper: #fff;
914
- --color-brand: #c2410c; --color-on-brand: #fff;
915
- }
916
- ```
917
-
918
- ```html
919
- <html data-palette="own"> <!-- colours of your own = coloured mode; any value no stock palette uses -->
920
- ```
921
-
922
- That file is where a project's look lives. Bases and roles:
923
-
924
- | what | tokens | moves |
925
- |---|---|---|
926
- | 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 |
927
- | type | `--fs-base` (1.375rem = 22 — the body) | every `--fs-*`, `--badge`, `--icon-xs` |
928
- | corners | `--r-base` (1.3rem = 20.8) | every `--r-*`; `0` = square. `--r-chip` stays fully round. One card radius (`--r-card`) for every card, tile, menu, sheet, fab, but a small card keeps the big card's PROPORTION, not its size: a row card is `--r-card-row` (13) |
929
- | 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) |
930
- | faces | `--font-ui`, `--font-display` | |
931
- | 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` |
932
- | 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}`, `--signal-{ok,warn,error,info}` | a role set directly wins over its derivation; the tints derive from the roles |
933
-
934
- Rules:
935
-
936
- - **Three levels, widest first — use the widest that says what you mean.**
937
- 1. a **base** moves a whole family (`--sp-base`, `--fs-base`, `--r-base`, `--tap-base`, the five `--color-*`): the system keeps its proportions.
938
- 2. a **role / root token** moves one meaning everywhere (`--accent`, `--card-1-bg`, `--btn-h`, `--chip-h`, `--header-h`, `--screen-w`, `--motion-sheet`…).
939
- 3. a **knob** moves ONE component (`--btn-pad-x`, `--chip-bg`, `--icon-btn-size`, `--appbar-pad-x`…) — on `:root` for everywhere, or on any element for
940
- the components inside it. A knob is declared nowhere; its default is the fallback in the rule. The full list is generated from the CSS:
941
- [`docs/knobs.md`](docs/knobs.md) (`npm run knobs`). Missing the knob you need? That is a gap in cardds — report it; it is added as a knob, never as your special case.
942
- - **Never a multiplier, never a step of the scale** (`--sp-4`, `--fs-h1`, `--r-card` — retune the base, or the component's knob), never a private
943
- (`--_*`) or a published value (`--card-pad`, `--bar-reserve`, `--sheet-*`: other rules do arithmetic with them).
944
- - **On `:root` only.** The derived tokens are computed where they are declared
945
- (`:root`), so a base set on a subtree does not reach them.
946
- - **After `cardds.css`.** A stock palette's block and a project's `:root` weigh
947
- the same; the later file wins.
948
- - **Coloured mode is the attribute.** `data-palette`, whatever its value, is what
949
- trades the wireframe's borders for the card's shadow and the controls' soft
950
- edge (`palette.css`, `forms.css`). Colours of your own → `<html data-palette="own">`.
951
- Without it the colours still apply, on wireframe borders.
952
- - **cardds does not judge the colours.** No contrast check, no warning — a
953
- theme's values are the project's call. The derivations are only starting points;
954
- any role can be set outright.
955
- - The member's text-size setting is the same `--fs-base`, written on `<html>`
956
- at run time by `setTextScale(percent)` (a share of the theme's base; 100 = the
957
- theme's own): the words grow, and the things grow HALF as much (`--tap-base`; `controlsFor(percent)` — 130% text → 115% controls, 160% → 130%), so a larger text still fits what holds it. (`--text-scale`, which multiplied the root and zoomed the UI too, is gone.)
958
-
959
- Try it live: the gallery's nav (`npm run dev`) has a slider per size base, and
960
- palette `own` shows a colour well per colour base.
961
- `tests/tokens.spec.js` holds the defaults to the px they always were and the
962
- template to the defaults; the step / centre geometry runs at two extreme
963
- themes (`tight`, `loose`).
964
-
965
- ## New palette
966
-
967
- Copy any block in `palette.css`, rename, retune:
968
-
969
- ```css
970
- [data-palette="mine"] {
971
- --surface: …; --on-surface: …;
972
- --card-1-bg: …; --card-1-ink: …; /* + card-2, card-3 */
973
- --accent: …; --on-accent: …;
974
- --action-bg: …; --action-ink: …;
975
- }
976
- ```
977
-
978
- ## Finish
979
-
980
- The finish of a card's paper — a style apart from colour, beside any palette or none:
981
-
982
- ```html
983
- <html data-palette="orchard" data-finish="print"> <!-- or on any ancestor; no attribute = the flat look -->
984
- ```
985
-
986
- Four values, one attribute (`css/finish.css`; the header names each and its knobs):
987
-
988
- | `data-finish` | the paper | knobs (set in `theme.css`, as any knob) |
989
- |---|---|---|
990
- | `lit` | lit from above: a lit top edge, a hairline ring, a layered shadow that grows with height; filled buttons stand up and go down under the finger; fields sunk | the shadows every component reads (`--shadow-card`, `--shadow-float`, `--shadow-sheet`) |
991
- | `print` | printed paper: an ink line in the card's own ink, a hard misregistered offset, grain over the card, a dotted rule under a lone card's head | `--print-off` (the offset, 3px) · `--print-grain` (the grain's opacity, 0.32) |
992
- | `sticker` | a die-cut sticker: a white cut line, a short hard drop, a lone card leans (never one in a stack, a list or a feed), buttons with a bottom edge that press down | `--sticker-cut` (5px) · `--sticker-tilt` (0.8deg; `0deg` = straight) |
993
- | `ink` | a poster / comic print: a thick line in the surface's ink round every boundary and control, a hard offset in the same ink | `--ink-w` (2.5px) · `--ink-off` (5px) |
994
-
995
- Under every finish the wireframe's two borders step back (`--card-border`, `--action-border`; ink puts its own line there), a card in a closed stack's base stays flat, and the information inside a card stays flat — only a container or a control gets an edge. Colours are mixes of the roles, so a finish sits in any palette. The gallery's nav has a finish switch (key `f`); `Card · Finishes` shows the four side by side, `Home · Finish` a home under `print`.
996
-
997
- ## Scale: everything is rem
998
-
999
- Every length token resolves to rem — a multiplier × one of four rem bases
1000
- (`--sp-4` = `--sp-base` × 4 = 1rem, `--tap` = `--tap-base` = 3rem, `--r-card` =
1001
- `--r-base` × 2, `--fs-h1` = `--fs-base` × 1.2; see "A project's theme") — so the
1002
- whole system scales with the root font size. `base.css` keeps it at `100%` (the browser's setting, 16px by
1003
- default) and steps narrow phones (≤360px) to `93.75%`, which keeps the
1004
- column, its 48px taps and its type in proportion on 320–360 screens. Only
1005
- `--border-w`, `--rule-w` and the focus ring stay in px, so edges stay crisp.
1006
- An app with its own text-size setting calls `setTextScale(percent)`, which writes
1007
- `--fs-base` on `<html>` from the theme's base (100% = the theme's; 160% = 2.2rem by default): the type and its holders (the count
1008
- badge, the inline icon) grow, the controls grow half as much (`--tap-base`), spacing and radii keep their size.
1009
- Components never write a raw length: spacing from `--sp-*`, control sizes from
1010
- `--tap`, `--tap-lg`, `--btn-h`, `--bar-h`, `--header-h`, `--fab`, `--badge`, container
1011
- padding from `--screen-pad` / `--card-pad`, radii from
1012
- `--r-*`, type from `--fs-*` / `--type-*`. Data (progress, counts) enters as
1013
- unitless custom properties. One measured length comes from script: `--kb`,
1014
- the height an on-screen keyboard covers, published on `:root` by `cardds.js`
1015
- from the visual viewport — as a px length, since only the browser knows it,
1016
- so no component ever writes `px` to consume it (`.action-bar` rides up by it,
1017
- `.step` grows its room by it). The narrow-phone step is an `em` breakpoint,
1018
- so it follows the text size too: at 131% a 390px phone counts as narrow.
1019
-
1020
- ## Icons & touch targets
1021
-
1022
- Icons come from `icons.svg` (Lucide, ISC — monochrome stroke, currentColor):
1023
-
1024
- ```html
1025
- <svg class="icon" aria-hidden="true"><use href="icons.svg#bell"/></svg>
1026
- ```
1027
-
1028
- `.icon` = `--icon` (1.75rem / 28px), `.icon--xs` = 1rem inside captions and chips,
1029
- `.icon--lg` = 2rem for empty states. Never raw emoji/unicode glyphs — they
1030
- render as colored emoji on phones. Add icons by appending `<symbol>`s
1031
- (grab from lucide.dev, paste path data).
1032
-
1033
- Touch targets: `--tap` 3rem (`.icon-btn`), `--tap-sm` 2.75rem floor (`.icon-btn--sm`,
1034
- selects, add-row), `--tap-lg` 3.5rem for hero controls. `.btn` ≥ `--btn-h` (3.25rem).
1035
- Everything thumb-first.
1036
-
1037
- The two bars across the top of a screen — `.topbar` (screen title) and
1038
- `.appbar` (app context) — share one rule and one floor, `--header-h` (3.75rem /
1039
- 60px), so a title-only bar stands as tall as one carrying an icon button, a
1040
- dropdown or a `.btn`. `.screen` reserves its top padding from the same token.
1041
- `.topbar__actions` takes at most TWO controls (Lh 2026-09-22): a third and beyond
1042
- is not drawn (`TopBar` warns once) — more moves go in a `Menu` behind one button.
1043
-
1044
- ## Typography levels
1045
-
1046
- `t-display` 34–42/800 · `t-h1` 28 · `t-h2` 22 · `t-title` 17 · `t-body` 15 ·
1047
- `t-label` 13 · `t-caption` 12 · `t-stat` big tabular
1048
- numbers (`t-stat--mono`, `t-stat--xl`). Pixel values are at the default root;
1049
- each is an `--fs-*` rem token. Swap `--font-display` per app for character.
7
+ Licence: none granted. The source is public to read, not to use. See [`LICENSE`](LICENSE).