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