@echodial/deck 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1327 -0
  3. package/bin/deck.mjs +219 -0
  4. package/build.mjs +338 -0
  5. package/dist/api-buckets.json +10486 -0
  6. package/dist/api.json +57896 -0
  7. package/dist/brand/deck-apple-touch-icon.png +0 -0
  8. package/dist/brand/deck-logo-dark.svg +1 -0
  9. package/dist/brand/deck-logo-light.svg +1 -0
  10. package/dist/brand/deck-logo-stacked.svg +1 -0
  11. package/dist/brand/deck-logo.svg +1 -0
  12. package/dist/brand/deck-mark.svg +7 -0
  13. package/dist/brand/deck-og.png +0 -0
  14. package/dist/collisions.json +223 -0
  15. package/dist/deck-adapters.js +482 -0
  16. package/dist/deck-adapters.min.js +23 -0
  17. package/dist/deck-extras.js +823 -0
  18. package/dist/deck-extras.min.js +5 -0
  19. package/dist/deck-icons.svg +184 -0
  20. package/dist/deck.bundle.js +2638 -0
  21. package/dist/deck.bundle.min.js +86 -0
  22. package/dist/deck.css +7904 -0
  23. package/dist/deck.esm.js +2644 -0
  24. package/dist/deck.js +1331 -0
  25. package/dist/deck.min.css +32 -0
  26. package/dist/deck.min.js +60 -0
  27. package/dist/layers/anchor.css +192 -0
  28. package/dist/layers/buttons.css +210 -0
  29. package/dist/layers/charts.css +337 -0
  30. package/dist/layers/combobox.css +248 -0
  31. package/dist/layers/components.css +868 -0
  32. package/dist/layers/container.css +199 -0
  33. package/dist/layers/datagrid.css +335 -0
  34. package/dist/layers/datepicker.css +305 -0
  35. package/dist/layers/forms.css +401 -0
  36. package/dist/layers/gradients.css +302 -0
  37. package/dist/layers/inputs.css +522 -0
  38. package/dist/layers/layers.css +87 -0
  39. package/dist/layers/layout.css +235 -0
  40. package/dist/layers/logical.css +226 -0
  41. package/dist/layers/media.css +400 -0
  42. package/dist/layers/mobile.css +245 -0
  43. package/dist/layers/motion.css +379 -0
  44. package/dist/layers/nav.css +771 -0
  45. package/dist/layers/perf.css +234 -0
  46. package/dist/layers/print.css +198 -0
  47. package/dist/layers/reset.css +103 -0
  48. package/dist/layers/space3d.css +280 -0
  49. package/dist/layers/toasts.css +201 -0
  50. package/dist/layers/tokens.css +229 -0
  51. package/dist/layers/type.css +114 -0
  52. package/dist/layers/utilities.css +241 -0
  53. package/dist/sizes.json +52 -0
  54. package/dist/usage.json +6340 -0
  55. package/package.json +94 -0
  56. package/src/00-layers.css +87 -0
  57. package/src/01-tokens.css +229 -0
  58. package/src/02-reset.css +103 -0
  59. package/src/03-type.css +114 -0
  60. package/src/04-layout.css +235 -0
  61. package/src/05-buttons.css +210 -0
  62. package/src/06-forms.css +401 -0
  63. package/src/07-components.css +868 -0
  64. package/src/08-mobile.css +245 -0
  65. package/src/09-utilities.css +241 -0
  66. package/src/10-datepicker.css +305 -0
  67. package/src/11-combobox.css +248 -0
  68. package/src/12-datagrid.css +335 -0
  69. package/src/13-toasts.css +201 -0
  70. package/src/14-charts.css +337 -0
  71. package/src/16-motion.css +379 -0
  72. package/src/18-container.css +199 -0
  73. package/src/19-logical.css +226 -0
  74. package/src/20-gradients.css +302 -0
  75. package/src/21-space3d.css +280 -0
  76. package/src/22-nav.css +771 -0
  77. package/src/23-inputs.css +522 -0
  78. package/src/24-media.css +400 -0
  79. package/src/25-anchor.css +192 -0
  80. package/src/26-perf.css +234 -0
  81. package/src/99-print.css +198 -0
  82. package/src/brand/deck-apple-touch-icon.png +0 -0
  83. package/src/brand/deck-logo-dark.svg +1 -0
  84. package/src/brand/deck-logo-light.svg +1 -0
  85. package/src/brand/deck-logo-stacked.svg +1 -0
  86. package/src/brand/deck-logo.svg +1 -0
  87. package/src/brand/deck-mark.svg +7 -0
  88. package/src/brand/deck-og.png +0 -0
  89. package/src/brand/sources.json +7 -0
  90. package/src/deck-icons.svg +184 -0
  91. package/src/js/deck-adapters.js +482 -0
  92. package/src/js/deck-extras.js +823 -0
  93. package/src/js/deck.js +1331 -0
package/README.md ADDED
@@ -0,0 +1,1327 @@
1
+ # Deck
2
+
3
+ **Deck is a CSS framework that ships as one 26.7 KB Brotli (32.7 KB gzip) stylesheet: buttons, forms,
4
+ tables, a data grid, charts, overlays, an icon sprite, and a full color system. You add it
5
+ with one `<link>` tag. There is no build step, no config file, and zero dependencies.**
6
+
7
+ ```html
8
+ <link rel="stylesheet" href="/assets/deck/deck.css">
9
+ ```
10
+
11
+ That is the whole install. Nothing to compile, nothing to purge, nothing to configure.
12
+
13
+ - **No build step.** The file you download is the file the browser reads.
14
+ - **Retheme from one number.** Set `--hue-brand` to 0–360 and every brand color, focus
15
+ ring, badge, chart series, and shadow is recomputed — at runtime, no rebuild.
16
+ - **Your CSS wins.** Deck ships in cascade layers, so an ordinary unlayered rule
17
+ overrides it without a single `!important`.
18
+ - **RTL built in.** Written in logical properties end to end; `dir="rtl"` flips the
19
+ whole page with no second stylesheet.
20
+ - **Zero runtime dependencies.** The JavaScript is optional and dependency-free.
21
+
22
+ Version 0.1.0 · MIT · Chrome 117+, Edge 117+, Safari 17.4+, Firefox 128+
23
+
24
+ ## What Deck weighs
25
+
26
+ A page that loads the stylesheet, the icon sprite, and the optional JavaScript transfers **62.7 KB** Brotli, or **76.3 KB** gzip. Every browser Deck supports sends `br` in
27
+ `Accept-Encoding`, and Cloudflare, Vercel, Netlify, and nginx with `ngx_brotli` negotiate
28
+ it for text by default, so Brotli is what most users actually receive.
29
+
30
+ | File | Brotli | gzip |
31
+ | --- | --- | --- |
32
+ | `deck.min.css` | **26.7 KB** | 32.7 KB |
33
+ | `deck-icons.svg` | **27.1 KB** | 33.6 KB |
34
+ | `deck.min.js` | **8.9 KB** | 10.0 KB |
35
+ | **All three** | **62.7 KB** | **76.3 KB** |
36
+
37
+ The sprite is the largest single file, slightly bigger than the stylesheet — worth stating
38
+ plainly rather than leaving you to find it in devtools. Swapping `deck.min.js` for the full
39
+ `deck.bundle.min.js` (adds the date picker, combobox, data grid, toasts, QR encoder)
40
+ makes the JavaScript 17.3 KB and the total 71.1 KB Brotli (85.8 KB gzip).
41
+
42
+ ### The sprite is a manifest, not a fixed cost
43
+
44
+ The 75 icons are a default so the demo works out of the box, not a floor. `tools/icons/icons.txt`
45
+ lists what to extract — delete the lines you do not need and rebuild:
46
+
47
+ ```bash
48
+ # keep only the icons you use
49
+ $ cat > tools/icons/icons.txt <<'EOF'
50
+ check = check
51
+ search = search
52
+ settings = settings
53
+ @hand deck-mark
54
+ @hand deck-wordmark
55
+ EOF
56
+
57
+ $ npm run icons
58
+ ```
59
+
60
+ **A twelve-icon sprite measures 5.7 KB Brotli (6.9 KB gzip)** — generated and measured, not
61
+ estimated. Against 27.1 KB for the full set, trimming the manifest is the difference between the
62
+ sprite dominating page weight and disappearing into it.
63
+
64
+ This matters because an external sprite is all-or-nothing per request: the browser fetches
65
+ the whole file to resolve a single `<use>`, so an unused icon is not free the way an unused
66
+ CSS class is. That is why the manifest exists. Keep the two `@hand` lines — they carry the
67
+ brand marks through from the previous sprite, and dropping them drops the marks.
68
+
69
+ ### Everything in the package
70
+
71
+ | File | Brotli | gzip | What it is |
72
+ | --- | --- | --- | --- |
73
+ | `deck.min.css` | 26.7 KB | 32.7 KB | The whole framework |
74
+ | `deck.min.js` | 8.9 KB | 10.0 KB | Optional behaviour, no dependencies |
75
+ | `deck-extras.min.js` | 5.7 KB | 6.5 KB | Date picker, combobox, data grid, toasts, QR encoder |
76
+ | `deck-adapters.min.js` | 4.0 KB | 4.5 KB | Optional library integrations, inert unless one is loaded |
77
+ | `deck.bundle.min.js` | 17.3 KB | 19.5 KB | All three scripts in one file |
78
+ | `deck-icons.svg` | 27.1 KB | 33.6 KB | 152 symbols: 75 icons at two weights, plus two brand marks |
79
+ | `src/` | — | — | The 26 source stylesheets, concatenated to build `deck.css` |
80
+ | `dist/layers/` | — | — | One file per layer, if you only want part of Deck |
81
+ | `php/` | — | — | Optional PHP helper for Composer users |
82
+ | `bin/deck.mjs` | — | — | The `npx @echodial/deck` CLI |
83
+
84
+ Every size above is what `npm run build` prints, in decimal KB, and is written into this
85
+ file by the build rather than typed.
86
+
87
+ The component demo is `public_html/index.php` — every component on one page. Run it with
88
+ `npm run demo && npm start`.
89
+
90
+ ## Repository layout
91
+
92
+ ```
93
+ deck/
94
+ ├─ src/ everything hand-written
95
+ │ ├─ 00-layers.css … 26 stylesheets, concatenated in filename order
96
+ │ ├─ deck-icons.svg the sprite — GENERATED, see `npm run icons`
97
+ │ ├─ brand/ the logo: one master, the rest derived from it
98
+ │ └─ js/ deck.js, deck-extras.js, deck-adapters.js
99
+ ├─ dist/ entirely generated — safe to delete, `npm run build` rebuilds it
100
+ │ ├─ deck.css / .min.css
101
+ │ ├─ deck.js / -extras / -adapters, plus .min.js of each
102
+ │ ├─ deck.bundle.js / .min.js, deck.esm.js
103
+ │ ├─ deck-icons.svg
104
+ │ ├─ brand/ copy of src/brand/
105
+ │ └─ layers/ one file per layer, for partial adoption
106
+ ├─ php/ Deck.php and Installer.php (PSR-4: EchoDial\Deck\)
107
+ ├─ bin/deck.mjs the `npx @echodial/deck` CLI
108
+ ├─ public_html/ the Helm docroot — the demo site, not part of the package
109
+ │ ├─ index.php component demo
110
+ │ ├─ php-helper.php the PHP helper, demonstrated
111
+ │ └─ assets/ published copies, both gitignored
112
+ │ ├─ deck/ dist/
113
+ │ └─ images/ dist/brand/
114
+ ├─ build.mjs
115
+ ├─ tools/
116
+ │ ├─ make-brand.mjs regenerates the logo family from the master
117
+ │ └─ icons/ the icon toolchain: `npm run icons`
118
+ │ ├─ icons.txt the list of icons to extract
119
+ │ ├─ build-icons.mjs writes src/deck-icons.svg from the font
120
+ │ └─ Material_Symbols_Rounded/ build time source, never shipped
121
+ ├─ package.json npm; `files` ships src, dist, bin, build.mjs
122
+ ├─ .gitattributes `export-ignore` keeps tools/ out of source archives
123
+ ├─ composer.json Packagist; PSR-4 points at php/
124
+ └─ LICENSE
125
+ ```
126
+
127
+ Two rules keep this straight:
128
+
129
+ **`src/` is written, `dist/` is generated.** Never edit anything in `dist/` — the next
130
+ build overwrites it. `npm run clean && npm run build` should always reproduce it exactly.
131
+
132
+ **`dist/` is committed anyway.** Composer has no build step; Packagist just ships the
133
+ repository, so the built files have to be in it. That is the one place where the usual
134
+ "never commit build output" rule does not apply.
135
+
136
+ `public_html/` is the demo site for local development under Helm. It is not part of
137
+ either package — npm ships `files`, Composer ships `php/` and `dist/`. Its asset folder
138
+ is a published copy, so it is gitignored; run `npm run demo` after a clone to fill it.
139
+
140
+ ### Commands
141
+
142
+ ```bash
143
+ npm run build # src/ -> dist/
144
+ npm run demo # build, then publish dist/ into public_html/assets/
145
+ npm run brand # regenerate the logo family after changing the mark
146
+ npm run icons # regenerate src/deck-icons.svg from tools/icons/icons.txt
147
+ npm run clean # delete dist/
148
+ npm start # php -S localhost:4321 -t public_html
149
+ ```
150
+
151
+ ## Install
152
+
153
+ Deck ships three ways. Pick whichever matches how the project already works.
154
+
155
+ ### 1. Just the files
156
+
157
+ Download `deck.css` and `deck-icons.svg`, drop them next to your other assets, and add
158
+ one line. No package manager, no build step, no Node on the server.
159
+
160
+ ```html
161
+ <link rel="stylesheet" href="/assets/deck.css">
162
+ ```
163
+
164
+ Or let the CLI put them there for you — this does not install anything permanently:
165
+
166
+ ```bash
167
+ npx @echodial/deck init public/assets/deck
168
+ npx @echodial/deck starter public/index.html # a working page to start from
169
+ ```
170
+
171
+ ### 2. npm
172
+
173
+ ```bash
174
+ npm install @echodial/deck
175
+ ```
176
+
177
+ ```js
178
+ import '@echodial/deck/css';
179
+ import Deck from '@echodial/deck';
180
+ ```
181
+
182
+ Subpath exports, so you can take only what you need:
183
+
184
+ | Import | What it is |
185
+ |---|---|
186
+ | `@echodial/deck/css` | the whole stylesheet |
187
+ | `@echodial/deck/css/min` | minified |
188
+ | `@echodial/deck/icons` | the sprite |
189
+ | `@echodial/deck/js` | core behaviour |
190
+ | `@echodial/deck/extras` | datepicker, combobox, grid, toasts, QR |
191
+ | `@echodial/deck/adapters` | optional library integrations |
192
+ | `@echodial/deck/bundle` | all three in one file |
193
+ | `@echodial/deck/layers/tokens.css` | one layer at a time |
194
+ | `@echodial/deck/src/*` | the unconcatenated sources |
195
+
196
+ The `layers/` exports matter if you only want part of Deck. `layers/tokens.css` plus
197
+ `layers/reset.css` gives you the design system with none of the components, which is a
198
+ reasonable way to adopt it into an existing app one screen at a time.
199
+
200
+ ### 3. CDN
201
+
202
+ Publishing to npm makes the CDNs work with no extra step:
203
+
204
+ ```html
205
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@echodial/deck@0.1/dist/deck.min.css">
206
+ <script src="https://cdn.jsdelivr.net/npm/@echodial/deck@0.1/dist/deck.bundle.min.js" defer></script>
207
+ ```
208
+
209
+ ### 4. Composer
210
+
211
+ ```bash
212
+ composer require echodial/deck
213
+ ```
214
+
215
+ Assets cannot be served out of `vendor/`, so the package publishes them into your public
216
+ directory on install. Configure the destination in your own `composer.json`:
217
+
218
+ ```json
219
+ {
220
+ "extra": {
221
+ "deck": {
222
+ "publish-to": "public/assets/deck",
223
+ "auto-publish": true
224
+ }
225
+ }
226
+ }
227
+ ```
228
+
229
+ Or run it whenever you like:
230
+
231
+ ```bash
232
+ composer deck-publish
233
+ composer deck-publish -- public/static/deck
234
+ composer deck-publish -- --link # symlink during development
235
+ ```
236
+
237
+ Publishing skips files that have not changed, so a redeploy does not churn mtimes and
238
+ invalidate every cache-busting URL for nothing.
239
+
240
+ #### The PHP helper
241
+
242
+ Optional, framework-agnostic, and about two hundred lines. No container, no service
243
+ provider, no facade — it works in Keel, Laravel, Symfony, WordPress, or a single
244
+ `index.php`.
245
+
246
+ ```php
247
+ use EchoDial\Deck\Deck;
248
+
249
+ Deck::configure([
250
+ 'base' => '/assets/deck',
251
+ 'adapters' => true,
252
+ 'bundle' => true,
253
+ ]);
254
+ ```
255
+
256
+ ```php
257
+ <html <?= Deck::htmlAttributes(lang: 'en') ?>>
258
+ <head>
259
+ <?= Deck::head() ?>
260
+ </head>
261
+ ```
262
+
263
+ `Deck::head()` emits the viewport meta tag Deck's mobile-first layout assumes, the
264
+ stylesheet, the scripts in the right order, and the icon sprite path — with `?v=` cache
265
+ busting from the file mtime, so a deploy invalidates the browser cache and nothing else
266
+ does.
267
+
268
+ ```php
269
+ <?= Deck::icon('check-circle', 'icon icon-lg') ?>
270
+ <?= Deck::css() ?>
271
+ <?= Deck::js() ?>
272
+ ```
273
+
274
+ Per-tenant theming, which is the thing Tailwind needs a rebuild for:
275
+
276
+ ```php
277
+ <html <?= Deck::theme(hue: $tenant->brand_hue, mode: $user->theme) ?>>
278
+ ```
279
+
280
+ One inline style. No second stylesheet, no rebuild, no per-customer asset pipeline.
281
+
282
+ ### Building from source
283
+
284
+ ```bash
285
+ node build.mjs
286
+ ```
287
+
288
+ The build script requires nothing. If esbuild happens to be installed it is used for
289
+ minification because it is better at it; otherwise a conservative built-in minifier runs
290
+ that walks the file character by character so strings, `url()` values, and data URIs are
291
+ never touched. The build never depends on a toolchain being present, which is the same
292
+ promise the framework makes.
293
+
294
+
295
+ ## Theming
296
+
297
+ Every color in the framework derives from six hue numbers. Change one line and the
298
+ buttons, links, focus rings, badges, tab bar, and shadows all follow:
299
+
300
+ ```css
301
+ :root {
302
+ --hue-brand: 265; /* violet instead of harbor teal */
303
+ --chroma-brand: .14; /* more saturated */
304
+ }
305
+ ```
306
+
307
+ Per-tenant theming in a multi-tenant Keel app becomes a single inline style on
308
+ `<html>` — no rebuild, no separate stylesheet per customer.
309
+
310
+ Dark mode is automatic from the OS. To force it, set `data-theme="dark"` or
311
+ `data-theme="light"` on `<html>`.
312
+
313
+ ## Layers
314
+
315
+ Deck declares its cascade layers up front:
316
+
317
+ ```
318
+ deck.reset, deck.tokens, deck.type, deck.layout, deck.components, deck.mobile, deck.utilities
319
+ ```
320
+
321
+ Any CSS you write outside a layer beats all of them, so you override Deck by writing
322
+ a normal rule. No `!important`, no specificity arms race.
323
+
324
+ ## Icons
325
+
326
+ ```html
327
+ <svg class="icon"><use href="/assets/deck-icons.svg#check"></use></svg>
328
+ ```
329
+
330
+ Icons inherit `color` and scale with `font-size`, so they sit on the text baseline.
331
+ Sizes: `.icon-sm` `.icon` `.icon-lg` `.icon-xl`.
332
+
333
+ Set: check, check-double, x, plus, minus, chevron-down/up/left/right, arrow-right,
334
+ arrow-left, arrow-up-right, search, menu, more-horizontal, more-vertical, filter,
335
+ sort, refresh, home, grid, list, chart, trend-up, trend-down, user, users, settings,
336
+ log-out, bell, mail, phone, message, calendar, clock, file, folder, clipboard,
337
+ download, upload, trash, edit, copy, link, external, tag, image, camera, eye,
338
+ eye-off, lock, unlock, shield, star, heart, bookmark, info, alert-circle,
339
+ alert-triangle, check-circle, x-circle, help, credit-card, dollar, receipt, car,
340
+ truck, wrench, gauge, sun, moon, map-pin, send, sparkle.
341
+
342
+ ### Two cuts, not one stroke width
343
+
344
+ Every icon ships twice:
345
+
346
+ | Symbol | Weight | Use with |
347
+ | --- | --- | --- |
348
+ | `#check` | wght 400 | `.icon`, `.icon-lg`, `.icon-xl` |
349
+ | `#check-sm` | wght 500 | `.icon-sm` |
350
+
351
+ ```html
352
+ <svg class="icon icon-sm"><use href="/assets/deck-icons.svg#check-sm"></use></svg>
353
+ ```
354
+
355
+ The symbols are filled outlines, so `stroke-width` does nothing to them and `.icon-sm`
356
+ cannot thicken its way to legibility at 16px. The heavier cut is a real second drawing
357
+ instead. CSS cannot rewrite a `<use href>`, so the pairing has to live in the markup —
358
+ `.icon-sm` goes with the `-sm` symbol. Everything else takes the plain name.
359
+
360
+ Both cuts carry `fill="currentColor" stroke="none"`, which beats the `.icon` stroke
361
+ defaults by inheritance, so icons follow `color` and retune with `--hue-brand` exactly
362
+ as they always did.
363
+
364
+ ### Regenerating the sprite
365
+
366
+ `src/deck-icons.svg` is **generated**. Editing it by hand loses the edit on the next run.
367
+
368
+ ```
369
+ npm run icons
370
+ ```
371
+
372
+ That reads `tools/icons/icons.txt` — one `sprite-id = material-glyph-name` per line —
373
+ out of the Material Symbols Rounded variable font and writes both cuts of every listed
374
+ icon. To add, drop or swap an icon, edit that list and rerun. Any of the 4,025 glyphs in
375
+ the font is available; only the names on the list end up in the sprite, which is how a
376
+ 4,025 icon library ships as a 75 icon file — or a twelve icon one at 5.7 KB Brotli, if
377
+ that is all your project uses.
378
+
379
+ The font is a **build time source only**. It never reaches a browser: a webfont would be
380
+ a 15 MB download or a subsetting step, and Deck's whole premise is not having a build
381
+ step. It lives in `tools/`, which is excluded from the npm package (`files` in
382
+ `package.json`) and from GitHub source archives (`export-ignore` in `.gitattributes`).
383
+ Nobody installing Deck needs it — the generated sprite is committed.
384
+
385
+ For the same reason `npm run icons` is deliberately **not** part of `npm run build`. It
386
+ is a maintainer task, run on purpose:
387
+
388
+ ```
389
+ npm run icons # regenerate src/deck-icons.svg from the font
390
+ npm run build # everything else, needs no font
391
+ ```
392
+
393
+ If the font is missing, the script says so and stops. Download Material Symbols Rounded
394
+ from [fonts.google.com/icons](https://fonts.google.com/icons) and unzip it into
395
+ `tools/icons/`.
396
+
397
+ `#deck-mark` and `#deck-wordmark` are not in the font. They are Deck's own drawings,
398
+ marked `@hand` in the list, read out of the existing sprite and carried through verbatim;
399
+ the script fails rather than regenerate a sprite without them.
400
+
401
+ ### Attribution
402
+
403
+ The icon outlines are derived from **Material Symbols** by Google, used under the
404
+ [Apache License, Version 2.0](https://www.apache.org/licenses/LICENSE-2.0). The full
405
+ licence text is kept at `tools/icons/LICENSE.txt` and the notice is reproduced in the
406
+ header comment of `src/deck-icons.svg`, which is the file that actually ships — keep it
407
+ there. The two brand marks are not derived from the font and are not covered by it.
408
+
409
+ Deck's own code and stylesheets remain MIT, per `LICENSE`.
410
+
411
+ ## Logo
412
+
413
+ <picture>
414
+ <source media="(prefers-color-scheme: dark)" srcset="src/brand/deck-logo-dark.svg">
415
+ <img alt="Deck" src="src/brand/deck-logo-light.svg" width="300">
416
+ </picture>
417
+
418
+ The mark is three planks in perspective — a deck of cards, a deck of layers, the thing
419
+ the framework is named after. It is drawn on the same 24×24 grid as the icons and it
420
+ lives in the same sprite.
421
+
422
+ ### Which copy to use
423
+
424
+ This is the one rule that matters, and it is easy to get wrong:
425
+
426
+ | You want | Use | Because |
427
+ | --- | --- | --- |
428
+ | A logo that follows `--hue-brand` | `<use href="deck-icons.svg#deck-mark">` | A `<use>` against a sprite in the same document inherits `color` |
429
+ | A logo in an `<img>`, a `<link>`, or `og:image` | a file from `src/brand/` | An externally referenced SVG has no colour context, so `currentColor` resolves to black |
430
+
431
+ ```html
432
+ <!-- retunes with the palette -->
433
+ <svg class="icon icon-lg icon-fill" style="color: var(--brand)">
434
+ <use href="/assets/deck-icons.svg#deck-mark"></use>
435
+ </svg>
436
+
437
+ <!-- static, for a favicon or a social card -->
438
+ <link rel="icon" href="/assets/images/deck-mark.svg" type="image/svg+xml">
439
+ ```
440
+
441
+ Note `.icon-fill`. The brand marks are filled, not stroked, so the `.icon` stroke
442
+ defaults draw them as hollow outlines without it. `#deck-wordmark` is the lettering on
443
+ its own, at `0 0 91.81 24`, for when you are setting the lockup yourself.
444
+
445
+ ### The favicon is a different drawing
446
+
447
+ `deck-mark.svg` is the mark alone, never the lockup — a 124×24 lockup is a smear at
448
+ 16px. It carries its own `prefers-color-scheme` block, so it follows the browser chrome
449
+ instead of picking a side and disappearing in the other one.
450
+
451
+ ### Family
452
+
453
+ Two files are drawn by hand; `tools/make-brand.mjs` derives the rest from them.
454
+
455
+ | File | Drawn or derived | What it is |
456
+ | --- | --- | --- |
457
+ | `deck-logo.svg` | drawn | Horizontal lockup, `currentColor`, `0 0 123.81 24` |
458
+ | `deck-logo-stacked.svg` | drawn | Mark over wordmark, `currentColor`, `0 0 91.81 54` |
459
+ | `deck-logo-light.svg` | from the lockup | Explicit dark fill, for light backgrounds |
460
+ | `deck-logo-dark.svg` | from the lockup | Explicit light fill, for dark backgrounds |
461
+ | `deck-mark.svg` | from `#deck-mark` | Mark only, theme-aware, for `rel="icon"` |
462
+ | `deck-og.png` | from the lockup | 1200×630 social card, white on the brand teal |
463
+ | `deck-apple-touch-icon.png` | from `#deck-mark` | 180×180, for `rel="apple-touch-icon"` |
464
+
465
+ `npm run demo` publishes all of them to `public_html/assets/images/`, which is
466
+ gitignored for the same reason `assets/deck/` is: it is a copy, not a source.
467
+
468
+ ### Regenerating
469
+
470
+ ```bash
471
+ npm run brand # after changing the mark
472
+ ```
473
+
474
+ The two PNGs cannot be produced in-process — rasterising needs a renderer — so the
475
+ generator shells out to headless Chrome or Edge via its `--screenshot` flag, and the
476
+ results are committed. Deck itself stays dependency-free; the browser is only needed
477
+ when the drawing changes, not to build or use the framework.
478
+
479
+ Because those rasters are committed, they can go stale. `tools/make-brand.mjs` records
480
+ a hash of every master it read in `src/brand/sources.json`, and `build.mjs` recomputes
481
+ them and **fails the build** if one has moved:
482
+
483
+ ```
484
+ Brand assets are stale. These masters have changed since
485
+ tools/make-brand.mjs last ran:
486
+
487
+ src/brand/deck-logo.svg recorded b44a710d0269aada, now a81ac26bc87ec010
488
+
489
+ Run: node tools/make-brand.mjs
490
+ ```
491
+
492
+ Hashes rather than timestamps, because a fresh clone gives every file the same mtime.
493
+
494
+ ## Emoji
495
+
496
+ `.emoji` pins the emoji font stack and the baseline so they render consistently on
497
+ Windows, iOS, and Android. Also `.emoji-lg`, `.emoji-xl`, `.emoji-hero`,
498
+ `.emoji-tile`, `.emoji-grid`, `.reaction`.
499
+
500
+ ## Layout primitives
501
+
502
+ `.container` `.stack` `.cluster` `.bar` `.grid` `.split` `.center` `.section`
503
+ `.scroller` `.sticky-top` `.app-shell` `.cq`
504
+
505
+ `.grid` auto-fits by content width, so most layouts need no breakpoints at all.
506
+
507
+ ## Components
508
+
509
+ Buttons, forms (input, textarea, select, check, radio, switch, range, file, input
510
+ group, search, fieldset), card, panel, badge, chip, alert, avatar, table (restacks
511
+ below 640px), list rows, tabs, segmented control, accordion, breadcrumb, pagination,
512
+ progress, ring, spinner, skeleton, tooltip, menu, modal, bottom sheet, toast, empty
513
+ state, stat, timeline, navbar, sidebar, tab bar, FAB.
514
+
515
+ ## Mobile specifics
516
+
517
+ - Every interactive control clears a 44px touch target
518
+ - Inputs render at 16px on coarse pointers, so iOS never zooms on focus
519
+ - `env(safe-area-inset-*)` handled on the tab bar, FAB, sticky form bar, and sheets
520
+ - `100dvh` instead of `100vh`, so the URL bar doesn't cut off the last row
521
+ - Bottom sheet on a phone becomes a centered dialog at 640px and up
522
+
523
+ ## Browser support
524
+
525
+ Chrome/Edge 117+, Safari 17.4+, Firefox 128+. Deck uses `oklch()`, `light-dark()`,
526
+ `@layer`, `:has()`, `@starting-style`, popover, and `field-sizing`. Older browsers
527
+ still get a usable page — they lose the entry animations and auto-growing textareas,
528
+ not the layout.
529
+
530
+
531
+ ## Date picker
532
+
533
+ ```html
534
+ <div class="datefield" data-deck-datepicker data-mode="range" data-months="2" data-presets>
535
+ <input class="input" name="period">
536
+ </div>
537
+ ```
538
+
539
+ Attributes: `data-mode="single|range"`, `data-format="mdy|dmy|iso"`, `data-min`,
540
+ `data-max` (ISO dates), `data-months`, `data-week-start`, `data-presets`.
541
+
542
+ Fires `deck:change` on the input with `{ start, end }` as ISO strings. Under 480px
543
+ the panel becomes a bottom sheet.
544
+
545
+ ## Combobox
546
+
547
+ ```html
548
+ <div class="combo" data-deck-combo data-multi data-create data-placeholder="Add people">
549
+ <select name="assignees[]" multiple hidden>
550
+ <option value="rissa" selected>Rissa Molina</option>
551
+ <option value="ken" data-sub="Fixed ops" data-group="Managers">Ken Spence</option>
552
+ </select>
553
+ </div>
554
+ ```
555
+
556
+ The real `<select>` stays in the DOM and stays in sync, so a normal PHP form post
557
+ works with nothing extra on the server. `data-sub` adds a second line, `data-group`
558
+ groups options, `data-create` allows adding new values, `data-multi` gives tokens.
559
+
560
+ For a remote source, set `data-url="/api/repos?q="` — deck.js appends the query,
561
+ debounces (`data-debounce`, default 220ms), and expects JSON rows of
562
+ `{ value, label, sub, group, disabled }`. Use `data-min-chars` to hold off until
563
+ the user has typed enough.
564
+
565
+ Events: `deck:change` with `{ values }`, `deck:create` with `{ value }`.
566
+
567
+ ## Data grid
568
+
569
+ ```html
570
+ <div class="dg-wrap" data-deck-grid style="--dg-height:360px">
571
+ <table class="dg dg-zebra">
572
+ <thead><tr>
573
+ <th class="dg-check dg-pin-start">…</th>
574
+ <th class="dg-pin-start-2" data-sort="text" data-resize>Order</th>
575
+ <th class="dg-num" data-sort="num">Total</th>
576
+ <th class="dg-actions dg-pin-end"></th>
577
+ </tr></thead>
578
+ ```
579
+
580
+ - `dg-pin-start` / `dg-pin-start-2` / `dg-pin-end` freeze columns. The drop shadow
581
+ only appears once the grid is actually scrolled sideways.
582
+ - `data-sort="text|num|date"` makes a header sortable. Put `data-value` on a cell
583
+ when the display text isn't sortable (formatted currency, relative dates).
584
+ - `data-resize` adds a drag grip to a column.
585
+ - `dg-compact` / `dg-comfy` change row density; `dg-zebra` adds striping.
586
+ - `tfoot` sticks to the bottom for totals.
587
+ - `dg-cards` plus `data-label` on each `td` restacks the grid into cards below 44rem.
588
+
589
+ Events: `deck:sort`, `deck:select`.
590
+
591
+ ## Toasts
592
+
593
+ ```js
594
+ Deck.toast('Deploy succeeded');
595
+
596
+ Deck.toast({
597
+ kind: 'warn', // good | warn | bad | info | loading | ''
598
+ title: 'Project archived',
599
+ text: 'You can undo this.',
600
+ duration: 8000, // 0 keeps it until dismissed
601
+ actions: [{ label: 'Undo', onClick: () => restore() }]
602
+ });
603
+
604
+ const t = Deck.toast({ kind: 'loading', title: 'Submitting…', duration: 0 });
605
+ t.update({ kind: 'good', title: 'Submitted', duration: 4000 });
606
+ t.dismiss();
607
+
608
+ Deck.toasts.clear();
609
+ ```
610
+
611
+ Toasts stack rather than stringing down the screen. Hovering the stack fans it out
612
+ and pauses every timer. Drag or swipe one sideways to dismiss. Position the region
613
+ with `.toast-region-start`, `.toast-region-center`, or `.toast-region-top`.
614
+
615
+ ## Charts
616
+
617
+ Bars and donuts are CSS driven by `--value` (0–100). Lines are inline SVG you style
618
+ with classes. Series colors `s1`–`s6` are derived from `--hue-brand`, so charts
619
+ retheme with everything else.
620
+
621
+ ```html
622
+ <div class="chart-columns">
623
+ <div class="chart-col s1" style="--value:73" data-label="Jun" data-value="146"></div>
624
+ </div>
625
+
626
+ <div class="chart-bar">
627
+ <span class="chart-bar-label">Cooler line</span>
628
+ <span class="chart-bar-track"><span class="chart-bar-fill s1" style="--value:92"></span></span>
629
+ <span class="chart-bar-value">92</span>
630
+ </div>
631
+
632
+ <div class="donut" style="--stops: var(--c1) 0 62%, var(--c3) 62% 84%, var(--c4) 84% 100%"></div>
633
+
634
+ <svg class="chart-svg" viewBox="0 0 300 120" preserveAspectRatio="none">
635
+ <path class="chart-area s1" d="…"/>
636
+ <path class="chart-line s1" d="…"/>
637
+ </svg>
638
+ ```
639
+
640
+ Also: `.chart-col-stack`, `.chart-group`, `.chart-meter`, `.chart-heat`,
641
+ `.sparkline`, `.chart-legend`, `.chart-x`, `.chart-y`, `.chart-gridline`.
642
+
643
+ ## Print
644
+
645
+ Printing is handled in the `deck.print` layer. Nav, tab bar, buttons, toasts,
646
+ pickers, and menus drop out. The grid unfreezes and prints every column with the
647
+ header repeated on each page. Mobile card fallbacks revert to real tables. Dark mode
648
+ is forced back to light. External link targets are printed in parentheses.
649
+
650
+ Helpers: `.page-break`, `.page-break-after`, `.keep-together`, `.no-print`,
651
+ `.print-only`, `.print-keep` (for a button you do want on paper), `.no-print-url`
652
+ (suppress the printed href), `.print-header`, `.print-footer`.
653
+
654
+ Change the paper size in one place:
655
+
656
+ ```css
657
+ @page { size: A4; margin: 18mm 15mm; }
658
+ ```
659
+
660
+ ## JS API
661
+
662
+ ```js
663
+ Deck.init(container) // wire up anything with data-deck-* inside container
664
+ Deck.toast(opts) // returns { update, dismiss }
665
+ Deck.toasts.clear()
666
+ Deck.theme('dark') // 'light' | 'dark', persisted to localStorage
667
+ Deck.theme() // read current
668
+ Deck.hue(265) // retint the whole app at runtime
669
+ Deck.iconSprite // path to deck-icons.svg
670
+ ```
671
+
672
+
673
+ ## Motion
674
+
675
+ Nothing animates unless you ask for it by class. Everything is wrapped in
676
+ `prefers-reduced-motion: no-preference`, with one deliberate exception: spinners,
677
+ skeletons, and progress bars keep moving under reduced motion, just slower. A frozen
678
+ spinner reads as broken, and progress feedback is information rather than decoration.
679
+
680
+ ### Transition utilities
681
+
682
+ `.transition` `.transition-colors` `.transition-move` `.transition-size`
683
+ `.transition-opacity`, sized with `.dur-1` through `.dur-5`, timed with `.ease-out`
684
+ `.ease-in` `.ease-spring` `.ease-bounce` `.ease-overshoot` `.ease-linear`, offset with
685
+ `.delay-1` `.delay-2` `.delay-3`. `.no-motion` opts a single element out.
686
+
687
+ The bounce and overshoot easings are `linear()` springs, so you get a real spring
688
+ curve with no physics library.
689
+
690
+ ### Entrances
691
+
692
+ `.enter` `.enter-rise` `.enter-drop` `.enter-start` `.enter-end` `.enter-pop`
693
+ `.enter-blur`. Put `.stagger` on the parent and children sequence in; the first twelve
694
+ are pure CSS and deck.js sets the index past that. `--stagger-step` controls the gap,
695
+ `--travel` controls how far things move.
696
+
697
+ ### Scroll reveals
698
+
699
+ ```html
700
+ <div class="card reveal">…</div>
701
+ <div class="scroll-progress"></div>
702
+ ```
703
+
704
+ `.reveal` `.reveal-fade` `.reveal-pop` use `animation-timeline: view()`, so the
705
+ animation is tied to scroll position with no IntersectionObserver at all. deck.js adds
706
+ an observer fallback for browsers that don't support it yet. `.scroll-progress` is a
707
+ reading-progress bar driven by `scroll(root block)`, and `.shrink-on-scroll` condenses
708
+ a sticky header past 120px.
709
+
710
+ ### Attention
711
+
712
+ `.shake` `.flash` `.flash-good` `.pulse` `.ping` `.nudge`. A `.field.is-invalid`
713
+ shakes once on its own and won't repeat, and deck.js clears the state as soon as the
714
+ input becomes valid.
715
+
716
+ ### Micro-interactions
717
+
718
+ `.lift` `.press` `.sweep` (underline draws in), `.icon-follow` (arrow steps forward
719
+ when its button is hovered), and `.ripple` — add the class and deck.js handles the ink
720
+ from the pointer position.
721
+
722
+ ### Expand and collapse
723
+
724
+ ```js
725
+ Deck.toggle(panel);
726
+ ```
727
+
728
+ A real `height: auto` transition using `interpolate-size`. No measuring in JavaScript,
729
+ no `max-height` guess that clips long content.
730
+
731
+ ### View transitions
732
+
733
+ This is the one that matters most for Keel. Add this to your app CSS:
734
+
735
+ ```css
736
+ @view-transition { navigation: auto; }
737
+ ```
738
+
739
+ Full page loads in a plain PHP multi-page app now cross-fade like a single page app —
740
+ no router, no JavaScript, no client-side rendering. Deck styles what the browser
741
+ generates: content moves, and the header and tab bar hold still.
742
+
743
+ Give the same `view-transition-name` to matching elements on both pages and the
744
+ browser tweens between them — a row in a list morphing into a detail page header:
745
+
746
+ ```html
747
+ <!-- list page --> <tr style="view-transition-name: order-1042">
748
+ <!-- detail page --> <h1 style="view-transition-name: order-1042">
749
+ ```
750
+
751
+ Helpers: `.vt-header` `.vt-main` `.vt-tabbar`, and `.vt-hold` with `--vt` for a
752
+ dynamic name. Back navigations slide the other way; deck.js sets the direction on
753
+ popstate.
754
+
755
+ For same-page DOM changes, wrap the update:
756
+
757
+ ```js
758
+ Deck.transition(() => row.remove());
759
+ Deck.transition(() => list.prepend(newRow), { direction: 'back' });
760
+ ```
761
+
762
+ It falls back to running the change immediately where unsupported or where the person
763
+ asked for reduced motion.
764
+
765
+ ### Ticker
766
+
767
+ `.marquee` with two identical `.marquee-track` children scrolls a status strip —
768
+ recalls, backordered parts, campaign notices. It pauses on hover and the duration is
769
+ `--marquee-dur`.
770
+
771
+ ### JS additions
772
+
773
+ ```js
774
+ Deck.play(node, 'shake') // one-shot class, cleans up after itself, returns a promise
775
+ Deck.toggle(node, force) // height:auto expand/collapse
776
+ Deck.transition(fn, opts) // view-transition wrapper with fallback
777
+ Deck.reduced() // true when the person asked for reduced motion
778
+ ```
779
+
780
+ Mark a number with `data-deck-tick` and it animates up green or down red whenever its
781
+ text changes.
782
+
783
+
784
+ ## Cascade layers
785
+
786
+ The whole cascade contract lives in `00-layers.css`, declared before any rule exists.
787
+ Order is decided there — not by file order, not by specificity, never by `!important`.
788
+
789
+ ```css
790
+ @layer
791
+ deck.reset, deck.tokens, deck.type, deck.layout,
792
+ deck.components, deck.mobile, deck.motion, deck.effects,
793
+ deck.utilities, deck.rtl, deck.print,
794
+
795
+ app.base, app.components, app.pages, app.overrides;
796
+ ```
797
+
798
+ Four `app.*` layers are reserved and left empty for you. A rule in `app.pages` beats
799
+ every Deck rule with a single class selector — no `.page .card .btn` chains, no
800
+ escalation. Anything you write **outside** a layer beats all layers, so a one-off rule
801
+ in a Keel view template always wins.
802
+
803
+ Wrap vendor CSS so it stops fighting you:
804
+
805
+ ```css
806
+ @import url("vendor/thing.css") layer(vendor);
807
+ ```
808
+
809
+ `00-layers.css` also registers the typed custom properties (`@property`) that make
810
+ angles, colors, and lengths interpolable — that's what lets a gradient angle or a tilt
811
+ animate at all. Registered: `--g-angle`, `--g-from`, `--g-to`, `--g-stop`, `--sheen`,
812
+ `--tilt-x`, `--tilt-y`, `--depth`.
813
+
814
+ ## Container queries
815
+
816
+ Deck already used `container-type` for the `.cq` helper; this is the full set.
817
+
818
+ ```html
819
+ <div class="cq">
820
+ <article class="card card-flex">…</article>
821
+ </div>
822
+ ```
823
+
824
+ The same markup goes horizontal in a wide column and stays stacked in a narrow rail,
825
+ without either one knowing where it was placed.
826
+
827
+ - **Declaring:** `.cq`, `.cq-size`, and named containers `.cq-panel` `.cq-pane`
828
+ `.cq-row` `.cq-shell`.
829
+ - **Container units:** `.text-cq` `.display-cq` `.pad-cq` `.gap-cq` scale with `cqi`,
830
+ so a heading in a sidebar stays small on a 32-inch monitor.
831
+ - **Adaptive components:** `.card-flex` `.stat-cq` `.metarow` `.field-row-cq`
832
+ `.actions-cq` `.dg-cq`.
833
+ - **Breakpoint utilities:** `.cq-sm\:row` `.cq-md\:hidden` `.cq-lg\:grid-2` and so on.
834
+
835
+ `.field-row-cq` and `.dg-cq` are strictly better than their media-query versions: a
836
+ two-up field row or a data grid inside a modal or sheet is narrow no matter how wide
837
+ the screen is.
838
+
839
+ **Style queries.** Set `--tone` on a container and children adapt with no extra classes:
840
+
841
+ ```html
842
+ <div class="cq-tone" style="--tone: critical">
843
+ <div class="card tone-surface"><span class="tone-text">Out of coverage</span></div>
844
+ </div>
845
+ ```
846
+
847
+ Tones: `clear`, `caution`, `critical`. Where style queries aren't supported the
848
+ fallback is simply no change.
849
+
850
+ ## Logical properties and RTL
851
+
852
+ Deck is written in logical properties end to end — `inline-size`, `block-size`,
853
+ `inset-inline-start`, `padding-block`, `border-start-start-radius`. A full right-to-left
854
+ flip needs nothing but `dir="rtl"` on `<html>`:
855
+
856
+ ```js
857
+ Deck.dir('rtl');
858
+ ```
859
+
860
+ What logical properties can't fix by themselves is content, so `19-logical.css` handles
861
+ the rest in the `deck.rtl` layer: pointing icons mirror (chevrons, arrows, send, log-out)
862
+ while checkmarks, wrenches, and clocks don't; the select arrow, search icon, switch knob,
863
+ grid pin shadows, chart fills, marquee, and entrance animations all flip; and the
864
+ breadcrumb separator swaps.
865
+
866
+ - **Explicit direction:** `.dir-ltr` `.dir-rtl` `.bidi-isolate` `.bidi-plaintext`.
867
+ `.code-ltr`, `.mono`, `code`, and `.nums` are isolated by default — an identifier like
868
+ a commit hash or an order number reads left to right in every language and must not
869
+ scramble the text around it. Reach for `.code-ltr` when you need to force the direction
870
+ on something that is not already monospace.
871
+ - **Mirroring control:** `.mirror-rtl` to mirror, `.no-flip` to never mirror.
872
+ - **Logical utilities:** `.mis-*` `.mie-*` `.pis-*` `.pie-*` `.bis` `.bie`
873
+ `.inset-is-0` `.r-start` `.r-end`. For the block axis reach for `.mt-*` and `.mb-*`,
874
+ which are named after the physical edge but declare `margin-block-start` and
875
+ `margin-block-end`, and for full sizes `.w-full` and `.h-full`, which declare
876
+ `inline-size` and `block-size`. Deck used to ship logically-named spellings of those
877
+ as well; two names for one declaration is worse than one name that needs a sentence.
878
+ - **Writing modes:** `.writing-vertical` `.writing-upright` `.writing-sideways`, and
879
+ `.th-vertical` for a rotated column header that still measures correctly.
880
+
881
+ ## Gradients
882
+
883
+ Every gradient derives from `--hue-brand` and interpolates in oklab, which avoids the
884
+ grey dead zone sRGB produces when blending two saturated colors. The hue slider retunes
885
+ all of them.
886
+
887
+ - **Surfaces:** `.g-surface` `.g-sunken` `.g-brand` `.g-brand-soft` `.g-dark`
888
+ - **Mesh:** `.g-mesh` `.g-mesh-subtle` `.g-mesh-drift` — three soft radial blooms, no
889
+ image, no SVG filter, and no `blur()` over a large area, which is expensive.
890
+ - **Text:** `.g-text` `.g-text-shine` — clipped to the glyphs with a real color
891
+ underneath so the text survives if the clip fails.
892
+ - **Borders:** `.g-border` `.g-border-soft` `.g-border-spin` — two clip boxes rather
893
+ than `border-image`, so it works with any `border-radius`. The spin animates because
894
+ `--g-angle` is registered.
895
+ - **Scrims:** `.g-scrim` `.g-scrim-top` — an eased floor under a caption, instead of a
896
+ flat overlay that dulls the whole image.
897
+ - **Fade masks:** `.g-fade-inline` `.g-fade-end` `.g-fade-block` `.g-fade-more` — for
898
+ content that runs off an edge or is collapsed.
899
+ - **Sheen:** `.g-sheen` — a highlight sweeping on hover, driven by the registered
900
+ `--sheen` percentage so it eases instead of jumping.
901
+ - **Patterns:** `.g-grid-lines` `.g-dots` `.g-stripes` `.g-hatch` — gradients standing
902
+ in for images, so they cost nothing to download and retint automatically.
903
+ - **Status and accents:** `.g-good` `.g-warn` `.g-bad` `.g-conic` `.g-ring`
904
+ `.g-ring-spin` `.g-shimmer` `.g-bar-fill`
905
+
906
+ `.chart-area-g` expects an SVG gradient def with `id="deck-area-gradient"` in the
907
+ document. Engines without `oklch()` fall back to the flat brand color rather than a
908
+ broken gradient.
909
+
910
+ ## 3D transforms
911
+
912
+ Depth when it carries meaning: a card with two sides, a stack-depth you're working down
913
+ through, a control that physically depresses.
914
+
915
+ Everything uses `rotate`, `translate`, and `scale` as **individual properties** rather
916
+ than the `transform` shorthand, so two effects on one element compose instead of
917
+ overwriting each other.
918
+
919
+ - **Scene:** `.scene` `.scene-near` `.scene-far` set the vanishing point; `.space`
920
+ applies `preserve-3d`.
921
+ - **Flip:** `.flip` / `.flip-x` with `.flip-front` and `.flip-back` stacked in one grid
922
+ cell, so the card is exactly as tall as its taller side. `data-deck-flip` on a button
923
+ wires it up and marks the hidden face `inert` so it's off the keyboard path.
924
+ - **Tilt:** `.tilt` with `data-tilt="10"`. deck.js writes a single rotation about a
925
+ computed axis. `.tilt-lift` floats content above the face on Z.
926
+ - **Depth stack:** `.pile` for a stack-depth of records, `.is-fanned` to spread it,
927
+ `Deck.advance(stack)` to dismiss the top card.
928
+ - **Coverflow:** `.coverflow` — scroll snap does the mechanics, 3D only does the read.
929
+ - **Cube:** `.cube` with six faces and `data-face="front|back|start|end|top|bottom"`,
930
+ or `.cube-spin`.
931
+ - **Depressible:** `.btn-3d` — the face moves down into its own shadow.
932
+ - **Parallax:** `.parallax` with `.parallax-back` `.parallax-mid` `.parallax-front` —
933
+ true Z-depth parallax on the compositor, no scroll handler and no jank.
934
+ - **Page turn:** `.turn-out` / `.turn-in`, pairs with `Deck.transition()`.
935
+
936
+ Under reduced motion, flips still flip (the state change is the information) but the
937
+ tilt, cube, and coverflow rotations are dropped.
938
+
939
+ ### JS additions
940
+
941
+ ```js
942
+ Deck.dir('rtl') // read or set direction, persisted
943
+ Deck.flip(card, true) // flip a card
944
+ Deck.advance(stack) // dismiss the top card of a depth stack
945
+ Deck.face(cube, 'top') // rotate a cube to a face
946
+ ```
947
+
948
+
949
+ ## Extended components
950
+
951
+ `deck-extras.js` is optional and loads after `deck.js`. Everything below has CSS that
952
+ works without it; the script adds behaviour.
953
+
954
+ ### Carousel
955
+
956
+ ```html
957
+ <div class="carousel carousel-peek" data-deck-carousel data-autoplay="6000">
958
+ <button class="carousel-arrow carousel-prev">…</button>
959
+ <div class="carousel-track">
960
+ <div class="carousel-slide">…</div>
961
+ </div>
962
+ <button class="carousel-arrow carousel-next">…</button>
963
+ <div class="carousel-dots"></div>
964
+ </div>
965
+ ```
966
+
967
+ Scroll snap does the work, so it swipes correctly with JavaScript off — arrows and dots
968
+ are enhancement. Variants: `.carousel-peek` shows a sliver of the next slide,
969
+ `.carousel-multi` shows three. Autoplay pauses on hover, on focus, and when the tab is
970
+ hidden, and never starts under reduced motion. Fires `deck:slide`.
971
+
972
+ ### Drawer, mega menu, speed dial, banner
973
+
974
+ - `.drawer` / `.drawer-end` — a side panel on `<dialog>`, so focus trapping and escape
975
+ are the browser's. `data-deck-drawer="#id"` on a trigger; `data-drawer-close` on any
976
+ button inside. Clicking the backdrop closes it.
977
+ - `.mega` — a wide popover panel with `.mega-grid` `.mega-col` `.mega-item`
978
+ `.mega-feature` `.mega-footer`. `data-deck-mega="#id"` adds hover intent on pointer
979
+ devices and click everywhere else.
980
+ - `.speed-dial` — a FAB that fans out into labelled actions, with a staggered entrance
981
+ and the plus rotating into a close. Sits above the tab bar and the safe area.
982
+ - `.banner` / `.banner-bottom` — a sticky announcement strip. Add `data-dismiss-key="x"`
983
+ and the dismissal persists in localStorage.
984
+
985
+ ### Back to top
986
+
987
+ ```html
988
+ <span id="top" tabindex="-1"></span>
989
+
990
+ <a class="back-to-top" href="#top" aria-label="Back to top">
991
+ <svg class="icon"><use href="/assets/deck-icons.svg#chevron-up"></use></svg>
992
+ </a>
993
+ ```
994
+
995
+ A **link**, not a button. `#top` is a real target at the head of the document, so the
996
+ browser moves focus there along with the scroll. A button calling `scrollTo()` scrolls
997
+ the page and leaves a keyboard user parked at the bottom of it — they press Tab and land
998
+ back in the footer. Give the target `tabindex="-1"` so it can receive that focus.
999
+
1000
+ Show and hide is a scroll-driven animation on `scroll(root block)`, ranged `400px 520px`
1001
+ — the same mechanism as `.scroll-progress`. There is no scroll listener anywhere.
1002
+ `visibility` is part of the keyframe on purpose: it takes the link out of the tab order
1003
+ and out of the accessibility tree while it is off screen, which is what `aria-hidden` is
1004
+ reaching for and which CSS can do on its own.
1005
+
1006
+ Where `animation-timeline` is missing, `deck.js` marks the link with `data-deck-btt` and
1007
+ toggles `.is-visible` from an **IntersectionObserver** on a 400px sentinel at the top of
1008
+ the document — still not a scroll handler. Unmarked, with no JavaScript at all, the link
1009
+ simply stays visible and still works.
1010
+
1011
+ It parks at the bottom inline-end corner and stacks over whatever else is there:
1012
+ `body:has(.fab, .speed-dial)` lifts it a FAB's height, `body:has(.tabbar)` lifts it a tab
1013
+ bar's height, and both together lift it over both. Every branch clears
1014
+ `env(safe-area-inset-bottom)`. Written in logical properties, so `dir="rtl"` moves it to
1015
+ the other corner with no extra rule. It never prints.
1016
+
1017
+ Under `prefers-reduced-motion: reduce` the reveal animation does not apply at all, so the
1018
+ link is simply always there with no entrance, and `02-reset` has already put `html` back
1019
+ to `scroll-behavior: auto` — the jump is instant.
1020
+
1021
+ ### Stepper
1022
+
1023
+ `.stepper` with `.step`, `.step-marker`, `.step-label`, `.step-note`. States are
1024
+ `.is-done` and `.is-current`. Numbers come from a CSS counter, so inserting a step
1025
+ renumbers everything. `.stepper-vertical` always stacks; `.stepper-auto` stacks below
1026
+ 40rem, which is what five steps need on a phone.
1027
+
1028
+ The stepper is for a process you're moving through. The timeline in `07-components` is
1029
+ for recording what already happened — they aren't the same component.
1030
+
1031
+ ### Inputs
1032
+
1033
+ - **Floating label** — `.float` with `placeholder=" "` on the input. Pure CSS via
1034
+ `:placeholder-shown`, so the label can never get out of sync with the value.
1035
+ `.float-outline` notches the label into the border.
1036
+ - **Number** — `.number` with real buttons instead of the native spinner, press-and-hold
1037
+ to repeat, min/max disabling, and an optional `.number-unit`.
1038
+ - **Phone** — `.phone` with a country select welded to the field. `data-mask="(###) ###-####"`
1039
+ formats as you type; `data-code` and `data-flag` drive the prefix. The number stays LTR
1040
+ and bidi-isolated even in an RTL document. Fires `deck:change` with `{ code, number, e164 }`.
1041
+ - **Rating** — `.rating` over real radio inputs, so it posts a value and works with the
1042
+ keyboard. `.rating-static` with `--value` shows a partial fill for an average. Use the
1043
+ solid `#star-fill` symbol, not `#star`: both components tell a selected star from an
1044
+ empty one by colour alone, so an outlined glyph leaves the two states identical.
1045
+ - **Range selector** — `.range-pair` with two native range inputs stacked. Real inputs
1046
+ mean real keyboard support and a real form post; `data-gap` keeps the handles apart.
1047
+ Fires `deck:change` with `{ min, max }`.
1048
+ - **Copy** — `.copy` with `.copy-btn data-deck-copy`, or `.copy-inline` for an icon
1049
+ beside an identifier in a table. Falls back to `execCommand` on http origins where the
1050
+ clipboard API is unavailable.
1051
+
1052
+ ### WYSIWYG editor
1053
+
1054
+ `.editor` with `.editor-toolbar`, `.editor-content`, `.editor-footer`. Buttons carry
1055
+ `data-cmd`; `data-target="#hidden-input"` keeps a hidden field in sync for a normal form
1056
+ post. Paste arrives as plain text, so a paste out of Word doesn't drag its styling in.
1057
+ `data-limit` drives the character counter. Fires `deck:change` with `{ html, text }`.
1058
+
1059
+ ### Video, gallery, lazy loading
1060
+
1061
+ - `.video` — a responsive frame for `<video>` or an embed, with `.video-poster` and
1062
+ `.video-play` for click-to-load. Ratios: `.video-square` `.video-portrait` `.video-wide`.
1063
+ - `.masonry` — CSS columns by default, upgrading to real `grid-template-rows: masonry`
1064
+ where supported, which preserves row order. Use it when the images have different
1065
+ shapes and you would rather not crop them; use `.gallery` below when they should all
1066
+ be the same size.
1067
+ - `.lazy` — a frame that holds its aspect ratio so nothing shifts, shimmers while
1068
+ waiting, and fades the image in on decode. Put the URL in `data-src` and
1069
+ `deck-extras.js` loads it 200px before it enters view.
1070
+
1071
+ ### Chat
1072
+
1073
+ `.chat` with `.msg` / `.msg-out`, `.bubble`, `.bubble-meta`, `.bubble-name`,
1074
+ `.bubble-attachment`, `.bubble-system`, `.bubble-typing`, and `.chat-composer`.
1075
+ Consecutive messages from one side group automatically — only the last bubble in a run
1076
+ keeps its tail, and repeated avatars hide themselves.
1077
+
1078
+ ### QR code
1079
+
1080
+ ```html
1081
+ <div class="qr" data-deck-qr="https://example.com/orders/1042" data-ecl="M"></div>
1082
+ ```
1083
+
1084
+ ```js
1085
+ const svg = Deck.qr.svg('1FTFW1E85MFA12345', 'H');
1086
+ ```
1087
+
1088
+ A complete encoder, written for this framework: byte mode, versions 1 through 10, error
1089
+ correction L / M / Q / H, Reed-Solomon over GF(256), all eight masks scored by the four
1090
+ standard penalty rules, and BCH format and version information. No library, no network
1091
+ call, no canvas — it emits SVG with horizontal runs merged into rects, so the markup
1092
+ stays small even at version 10.
1093
+
1094
+ It's verified by round-trip: encode, then read the matrix back out through the format
1095
+ information, the mask, the zig-zag, and the block de-interleave, and confirm the
1096
+ original string comes back. Six cases across all four correction levels and versions 1
1097
+ through 10 pass.
1098
+
1099
+ Sizes: `.qr-sm` `.qr` `.qr-lg`, or set `--qr-size`. `--qr-fg` and `--qr-bg` control the
1100
+ colors — keep the contrast high or scanners will struggle. `.qr-logo` punches a mark
1101
+ out of the middle, which is only safe at correction level Q or H.
1102
+
1103
+ Content longer than a version 10 code can hold throws with a readable message rather
1104
+ than rendering something unscannable. If you hit it, link to the content instead of
1105
+ embedding it.
1106
+
1107
+ ### Indicators
1108
+
1109
+ `.indicator` with `-good` `-warn` `-bad` `-brand` `-lg` `-ring`, `.status-line` for a
1110
+ dot plus a label, and `.with-indicator` + `.indicator-badge` for a count on an icon
1111
+ (`.indicator-badge-dot` for a bare dot).
1112
+
1113
+ ### Jumbotron and footer
1114
+
1115
+ `.jumbotron` / `.jumbotron-center` / `.jumbotron-media` for a hero, and `.footer` with
1116
+ `.footer-grid` `.footer-brand` `.footer-col` `.footer-heading` `.footer-bottom`
1117
+ `.footer-social`.
1118
+
1119
+ ### Sidebar
1120
+
1121
+ ```html
1122
+ <aside class="cq-shell">
1123
+ <nav class="panel sidebar" aria-label="Workspace">
1124
+ <span class="sidebar-group">Issues</span>
1125
+ <a class="sidebar-link" aria-current="page" href="/issues">
1126
+ <svg class="icon">…</svg><span>Open</span><span class="badge push">42</span>
1127
+ </a>
1128
+ </nav>
1129
+ </aside>
1130
+ ```
1131
+
1132
+ A bare nav list: `.sidebar-group` for a heading, `.sidebar-link` for a row, `.push` to
1133
+ shove a count to the far end, `aria-current` for the active one. It brings no width and
1134
+ no chrome of its own, so put it in whatever rail your shell already has — a `.split`
1135
+ rail, a `.drawer`, a `.panel`.
1136
+
1137
+ Put `.cq-shell` on that rail and the links collapse to icons below 15rem. That is a
1138
+ container query keyed to the rail, not a media query keyed to the window, so a sidebar
1139
+ in a narrow column collapses on a 32 inch monitor and the same markup in a wide column
1140
+ does not. The rule lives in `18-container.css` as the worked example of a named
1141
+ container.
1142
+
1143
+ ### Tooltips
1144
+
1145
+ There are two, and the difference matters.
1146
+
1147
+ ```html
1148
+ <button class="btn btn-icon tooltip" data-tip="Re-run failed jobs" aria-label="Re-run failed jobs">…</button>
1149
+
1150
+ <button class="btn" popovertarget="tipBuild">Why did this build fail?</button>
1151
+ <div class="tip" id="tipBuild" popover>The integration suite timed out.<span class="tip-arrow"></span></div>
1152
+ ```
1153
+
1154
+ - `.tooltip` is a `::after` on the trigger reading `data-tip`. No extra markup, no
1155
+ JavaScript, nothing to keep in sync. It is pinned above the trigger and **cannot
1156
+ flip**, so near the top of a scrollport it runs off the edge, and it hides itself
1157
+ under `(pointer: coarse)` because a hover tip never worked on a phone anyway.
1158
+ - `.tip` is a real popover placed with CSS anchor positioning (`25-anchor.css`), so it
1159
+ **can** flip — `position-try-fallbacks: flip-block, flip-inline` — and it carries a
1160
+ `.tip-arrow` that stays pointed at its anchor. `deck.js` pairs the trigger and the
1161
+ panel automatically from `popovertarget`.
1162
+
1163
+ Reach for `.tooltip` for a short label on an icon button in the middle of a page. Reach
1164
+ for `.tip` when the text is longer, has to survive an edge, or should open on click.
1165
+
1166
+ ### Gallery
1167
+
1168
+ ```html
1169
+ <div class="gallery">
1170
+ <a class="span-2" href="…"><img src="…" alt="Dashboard screenshot"></a>
1171
+ <a href="…"><img src="…" alt="Logo on a light background"></a>
1172
+ <a class="span-wide" href="…"><img src="…" alt="Social card, 1200 by 630"></a>
1173
+ </div>
1174
+ ```
1175
+
1176
+ Equal square tiles on `auto-fill`, so nine photos and three photos both come out tidy
1177
+ without a breakpoint. `.span-2` promotes a tile to 2&times;2 and `.span-wide` to
1178
+ 2&times;1, which is how you lead with the shot that matters. Images cover their cell and
1179
+ scale slightly on hover when the tile is a link. `.masonry` is the other choice — use it
1180
+ when the images have different shapes and you would rather not crop them.
1181
+
1182
+ ### JS additions
1183
+
1184
+ ```js
1185
+ Deck.qr.svg(text, 'M') // SVG string
1186
+ Deck.qr.build(text, 'M') // { modules, size, version } for your own renderer
1187
+ Deck.copy(text) // clipboard write with a fallback, returns a promise
1188
+ ```
1189
+
1190
+
1191
+ ## Libraries
1192
+
1193
+ Deck's core is zero-dependency and that is deliberate — it is the one thing Tailwind
1194
+ cannot claim, and it is why Deck drops into a Keel view with a single `<link>` tag. So
1195
+ a dependency has to earn its place by doing a job Deck genuinely does worse.
1196
+
1197
+ `deck-adapters.js` is the mechanism. Each adapter activates **only** if the library is
1198
+ already on the page. Load none of them and nothing changes. Load one and Deck hands
1199
+ that job over while keeping its own markup, classes, and styling.
1200
+
1201
+ ```html
1202
+ <script src="/assets/deck.js" defer></script>
1203
+ <script src="/assets/deck-extras.js" defer></script>
1204
+ <script src="/assets/deck-adapters.js" defer></script>
1205
+
1206
+ <!-- add only what you want -->
1207
+ <script src="https://cdn.jsdelivr.net/npm/@floating-ui/dom" defer></script>
1208
+ <script src="https://cdn.jsdelivr.net/npm/sortablejs" defer></script>
1209
+ ```
1210
+
1211
+ | Job | Deck alone | With a library | Verdict |
1212
+ |---|---|---|---|
1213
+ | Placement | CSS anchor positioning | Floating UI (~9 KB) | Library only where anchor positioning is missing |
1214
+ | Rich text | `execCommand`, deprecated | Tiptap or Quill | Use the library |
1215
+ | Charts | CSS charts that retheme | Chart.js, themed by Deck | CSS for tiles, Chart.js for real axes |
1216
+ | Drag and drop | Native HTML DnD, poor on touch | SortableJS | Use the library |
1217
+ | Icons | 74-icon sprite | Lucide (1500 icons) | Sprite covers Deck; Lucide for the rest |
1218
+ | Long lists | `content-visibility` | A virtualizer (~5 KB) | Keep the browser |
1219
+ | Dates and locales | `Intl` | date-fns and friends | Keep `Intl` |
1220
+
1221
+ `Deck.adapters.report()` names what is actually doing each job on the current page.
1222
+ Worth running when a component behaves differently between two environments.
1223
+
1224
+ ### Placement
1225
+
1226
+ Every floating thing in Deck was positioned by hand with `getBoundingClientRect`, which
1227
+ does not flip at the bottom of the window, does not shift back inside at an edge, and
1228
+ does not follow its anchor inside a scrolling container. `25-anchor.css` fixes that with
1229
+ CSS anchor positioning — natively, on the compositor, with no listeners.
1230
+
1231
+ `deck.js` pairs every `popovertarget` with its panel and generates a unique
1232
+ `anchor-name`, so you write no extra markup. Where the browser lacks it, the Floating UI
1233
+ adapter takes over with `flip`, `shift`, `size`, and `arrow`; where neither is present,
1234
+ Deck's own placement runs as before.
1235
+
1236
+ New anchored components: `.tip` (a tooltip that can flip and carry an arrow, unlike the
1237
+ `::after` one) and `.pop` (a popover card with a title, body, and actions).
1238
+
1239
+ The real win is subtler: an anchored panel goes in the top layer, which is the fix for
1240
+ the bug that bites every combobox nested inside a modal or an `overflow: hidden` card.
1241
+
1242
+ ### Rich text
1243
+
1244
+ `document.execCommand` is deprecated and inconsistent, and rewriting onto Selection and
1245
+ Range means building a document model — which is what Tiptap and Quill already are. The
1246
+ adapter hands `.editor-content` over to whichever is present and keeps Deck's toolbar
1247
+ chrome, so the markup and CSS are unchanged. Toolbar `data-cmd` values are mapped to
1248
+ each library's command set, and active state still lights the buttons.
1249
+
1250
+ Deck's built-in editor stays as the fallback so a form still works with no library.
1251
+
1252
+ ### Charts
1253
+
1254
+ Deck's CSS charts retheme with the hue slider and cost nothing, which is right for
1255
+ dashboard tiles. What they cannot do is a time axis, a crosshair, a zoom, or twenty
1256
+ thousand points. The adapter sets Chart.js defaults from Deck's tokens — fonts, grid
1257
+ color, tooltip surface, point styles — and re-reads them when the theme or hue changes,
1258
+ so a Chart.js canvas follows the slider like everything else.
1259
+
1260
+ ```js
1261
+ Deck.chart(canvas, config); // same as new Chart(), with the series palette applied
1262
+ ```
1263
+
1264
+ ### Drag and drop
1265
+
1266
+ Deck's CSS already uses SortableJS's default class names (`sortable-ghost`,
1267
+ `sortable-chosen`, `sortable-drag`), so no configuration is needed. Add
1268
+ `data-deck-sortable="groupname"` and optionally `data-handle=".drag-handle"`. Without
1269
+ the library it falls back to native HTML drag and drop, which works on a desktop and is
1270
+ poor on touch — that's the honest reason to load SortableJS.
1271
+
1272
+ Comes with `.kanban`, `.kanban-col`, `.kanban-head`, `.kanban-body`, `.kanban-card`, and
1273
+ `.kanban-empty`. Fires `deck:reorder` with the new order as an array of `data-id` values,
1274
+ which is what you POST back to Keel.
1275
+
1276
+ ### Long lists
1277
+
1278
+ The usual answer to a ten thousand row grid is a virtualization library: measure the
1279
+ viewport, render a window, position a spacer, reconcile every scroll frame. It works and
1280
+ it breaks find-in-page, printing, accessibility tree order, and selection across the
1281
+ boundary.
1282
+
1283
+ `content-visibility: auto` does the same job in the engine. Off-screen subtrees are
1284
+ skipped during layout, style, paint, and hit testing but stay in the DOM, so Ctrl+F still
1285
+ finds them and the print stylesheet still prints them. One line of CSS, no JavaScript.
1286
+
1287
+ ```html
1288
+ <table class="dg dg-virtual">
1289
+ ```
1290
+
1291
+ Also `.list-virtual`, `.virtual` (with `--item-size`), and `.defer` for whole sections
1292
+ below the fold. The print stylesheet forces all of them back to `visible`, or half a
1293
+ report comes out blank.
1294
+
1295
+ `.contain` and `.contain-paint` are the companion: on a dashboard with twenty cards,
1296
+ containment is the difference between one layout pass and twenty.
1297
+
1298
+ ### Dates and locales
1299
+
1300
+ Month names, weekday names, and the first day of the week now come from `Intl` rather
1301
+ than a hardcoded English array. It is built into every browser and correct in every
1302
+ locale, so a date library adds nothing.
1303
+
1304
+ ```html
1305
+ <div class="datefield" data-deck-datepicker data-locale="de-DE" data-format="dmy">
1306
+ ```
1307
+
1308
+ `data-locale` overrides the document language. Week start comes from
1309
+ `Intl.Locale.getWeekInfo()` — Sunday in the US, Monday across most of Europe — and can
1310
+ still be forced with `data-week-start`. Day cell labels use `dateStyle: 'full'`, so a
1311
+ screen reader reads a properly localized date.
1312
+
1313
+ ```js
1314
+ Deck.locale('es-MX') // { months, monthsShort, days, weekStart, long, full }
1315
+ ```
1316
+
1317
+ ### Icons
1318
+
1319
+ The 74-icon sprite covers what the framework itself needs plus the automotive set it was
1320
+ built for. Past that, the cheapest move is to add the name to `tools/icons/icons.txt` and
1321
+ run `npm run icons` — any of the 4,025 Material Symbols glyphs is one line away.
1322
+
1323
+ If you would rather not regenerate, write `<span data-icon="briefcase" class="icon">` and
1324
+ the Lucide adapter swaps in the path data, keeping Deck's `.icon` sizing rules. No
1325
+ adapter, no swap, and the sprite still works. Be aware that Lucide draws real strokes
1326
+ while the sprite is filled outlines, so the two do not match at close range; use one or
1327
+ the other in a given screen.