@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/bin/deck.mjs ADDED
@@ -0,0 +1,219 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * deck — copy Deck's assets into a project.
4
+ *
5
+ * npx @echodial/deck init
6
+ * npx @echodial/deck init public/assets
7
+ * npx @echodial/deck init --min --bundle
8
+ * npx @echodial/deck starter public/index.html
9
+ *
10
+ * For people who are not using npm as a build tool and just want the files
11
+ * sitting in a folder, which is most of the people Deck is for.
12
+ */
13
+
14
+ import { copyFile, mkdir, readFile, writeFile, access } from 'node:fs/promises';
15
+ import { constants } from 'node:fs';
16
+ import path from 'node:path';
17
+ import { fileURLToPath } from 'node:url';
18
+
19
+ const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
20
+ const DIST = path.join(root, 'dist');
21
+ const pkg = JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8'));
22
+
23
+ // Piping into head/less closes stdout early; that is not a crash
24
+ process.stdout.on('error', err => { if (err.code === 'EPIPE') process.exit(0); });
25
+
26
+ const args = process.argv.slice(2);
27
+ const cmd = args[0];
28
+ const flags = new Set(args.filter(a => a.startsWith('--')));
29
+ const positional = args.slice(1).filter(a => !a.startsWith('--'));
30
+
31
+ const c = {
32
+ dim: s => `\x1b[2m${s}\x1b[0m`,
33
+ bold: s => `\x1b[1m${s}\x1b[0m`,
34
+ green: s => `\x1b[32m${s}\x1b[0m`,
35
+ red: s => `\x1b[31m${s}\x1b[0m`
36
+ };
37
+
38
+ const exists = async p => { try { await access(p, constants.F_OK); return true; } catch { return false; } };
39
+
40
+ function usage() {
41
+ console.log(`
42
+ ${c.bold(`Deck v${pkg.version}`)} ${c.dim('— a CSS framework with no build step')}
43
+
44
+ ${c.bold('npx @echodial/deck init')} ${c.dim('[dir]')} copy the assets (default: assets/deck)
45
+ ${c.bold('npx @echodial/deck starter')} ${c.dim('[file]')} write a starter HTML page
46
+ ${c.bold('npx @echodial/deck list')} show what is in the package
47
+
48
+ ${c.dim('--min')} copy the minified stylesheet only
49
+ ${c.dim('--bundle')} copy one combined script instead of three
50
+ ${c.dim('--css-only')} skip the scripts entirely
51
+ ${c.dim('--force')} overwrite files that already exist
52
+ `);
53
+ }
54
+
55
+ const SETS = {
56
+ css: ['deck.css', 'deck-icons.svg'],
57
+ cssMin: ['deck.min.css', 'deck-icons.svg'],
58
+ js: ['deck.js', 'deck-extras.js', 'deck-adapters.js'],
59
+ jsBundle: ['deck.bundle.min.js']
60
+ };
61
+
62
+ async function init() {
63
+ const target = path.resolve(process.cwd(), positional[0] || 'assets/deck');
64
+ const files = [
65
+ ...(flags.has('--min') ? SETS.cssMin : SETS.css),
66
+ ...(flags.has('--css-only') ? [] : flags.has('--bundle') ? SETS.jsBundle : SETS.js)
67
+ ];
68
+
69
+ await mkdir(target, { recursive: true });
70
+
71
+ let written = 0, skipped = 0, missing = 0;
72
+ for (const file of files) {
73
+ const from = path.join(DIST, file);
74
+ const to = path.join(target, file);
75
+ if (!(await exists(from))) { missing++; continue; }
76
+ if (await exists(to) && !flags.has('--force')) { skipped++; continue; }
77
+ await copyFile(from, to);
78
+ written++;
79
+ console.log(` ${c.green('+')} ${path.relative(process.cwd(), to)}`);
80
+ }
81
+
82
+ if (skipped) console.log(c.dim(` ${skipped} already there (use --force to overwrite)`));
83
+ if (missing) console.log(c.dim(` ${missing} not built (run \`npm run build\` in the package)`));
84
+
85
+ const rel = path.relative(process.cwd(), target).replace(/\\/g, '/');
86
+ // The folder on disk is not the folder in the URL. Strip the usual document
87
+ // roots so the snippet we print is actually pasteable.
88
+ const web = rel.replace(/^(public_html|public|web|htdocs|httpdocs|www|wwwroot|dist|static)\//, '');
89
+ const cssFile = flags.has('--min') ? 'deck.min.css' : 'deck.css';
90
+
91
+ console.log(`
92
+ ${c.bold('Add to your layout:')}
93
+
94
+ <link rel="stylesheet" href="/${web}/${cssFile}">${flags.has('--css-only') ? '' : `
95
+ <script src="/${web}/${flags.has('--bundle') ? 'deck.bundle.min.js' : 'deck.js'}" defer></script>`}
96
+
97
+ ${c.dim('deck-icons.svg sits beside deck.js and is found automatically.')}
98
+ ${c.dim('To load the sprite from somewhere else:')}
99
+
100
+ <script src="/${web}/deck.js" data-deck-icons="/your/path/deck-icons.svg" defer></script>
101
+ `);
102
+
103
+ return written;
104
+ }
105
+
106
+ async function starter() {
107
+ const file = path.resolve(process.cwd(), positional[0] || 'index.html');
108
+ if (await exists(file) && !flags.has('--force')) {
109
+ console.log(c.red(` ${path.relative(process.cwd(), file)} already exists. Use --force to overwrite.`));
110
+ return;
111
+ }
112
+
113
+ const html = `<!doctype html>
114
+ <html lang="en">
115
+ <head>
116
+ <meta charset="utf-8">
117
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
118
+ <title>Built with Deck</title>
119
+ <link rel="stylesheet" href="/assets/deck/deck.css">
120
+ <script src="/assets/deck/deck.js" defer></script>
121
+ <script src="/assets/deck/deck-extras.js" defer></script>
122
+ <style>
123
+ /* Your CSS goes in a layer and beats every Deck rule with no !important. */
124
+ @layer app.pages {
125
+ .hero { padding-block: var(--space-section); }
126
+ }
127
+ </style>
128
+ </head>
129
+ <body>
130
+
131
+ <a class="skip-link" href="#main">Skip to content</a>
132
+
133
+ <header class="sticky-top">
134
+ <div class="container">
135
+ <nav class="navbar">
136
+ <a class="navbar-brand" href="/">Your app</a>
137
+ <div class="navbar-links">
138
+ <a class="nav-link" aria-current="page" href="/">Home</a>
139
+ <a class="nav-link" href="/about">About</a>
140
+ </div>
141
+ <button class="btn btn-icon btn-ghost push" data-deck-theme aria-label="Switch theme">
142
+ <svg class="icon"><use href="/assets/deck/deck-icons.svg#moon"></use></svg>
143
+ </button>
144
+ </nav>
145
+ </div>
146
+ </header>
147
+
148
+ <main id="main" class="container section stack-8">
149
+
150
+ <section class="hero stack-5">
151
+ <h1 class="display">Start here.</h1>
152
+ <p class="lede">This page is using Deck. There is no build step, no config file,
153
+ and no dependencies. Change one number below and the whole page follows.</p>
154
+ <div class="cluster">
155
+ <button class="btn btn-primary btn-lg">Primary action</button>
156
+ <button class="btn btn-lg">Secondary</button>
157
+ </div>
158
+ </section>
159
+
160
+ <section class="stack-4">
161
+ <label class="label" for="hue">Brand hue</label>
162
+ <input id="hue" class="range" type="range" min="0" max="360" value="196"
163
+ oninput="Deck.hue(this.value)">
164
+ <div class="grid">
165
+ <div class="card"><div class="card-body">
166
+ <h3 class="card-title">Cards</h3>
167
+ <p class="text-sm text-muted">Every surface, badge, and focus ring follows the hue.</p>
168
+ </div></div>
169
+ <div class="card"><div class="card-body">
170
+ <h3 class="card-title">Forms</h3>
171
+ <div class="field">
172
+ <label class="label" for="email">Email</label>
173
+ <input class="input" id="email" type="email" placeholder="you@example.com">
174
+ </div>
175
+ </div></div>
176
+ <div class="card"><div class="card-body">
177
+ <h3 class="card-title">Feedback</h3>
178
+ <button class="btn btn-sm btn-soft"
179
+ onclick="Deck.toast({kind:'good', title:'It works'})">Fire a toast</button>
180
+ </div></div>
181
+ </div>
182
+ </section>
183
+
184
+ </main>
185
+
186
+ <footer class="footer">
187
+ <div class="container footer-bottom">
188
+ <span>Built with Deck</span>
189
+ </div>
190
+ </footer>
191
+
192
+ </body>
193
+ </html>
194
+ `;
195
+
196
+ await mkdir(path.dirname(file), { recursive: true });
197
+ await writeFile(file, html);
198
+ console.log(` ${c.green('+')} ${path.relative(process.cwd(), file)}`);
199
+ console.log(c.dim(`\n Run \`npx @echodial/deck init public/assets/deck\` if you have not copied the assets yet.\n`));
200
+ }
201
+
202
+ async function list() {
203
+ const { readdir, stat } = await import('node:fs/promises');
204
+ const files = (await readdir(DIST)).filter(f => !f.startsWith('.')).sort();
205
+ console.log(`\n${c.bold(`Deck v${pkg.version}`)} ${c.dim(DIST)}\n`);
206
+ for (const f of files) {
207
+ const s = await stat(path.join(DIST, f));
208
+ if (s.isDirectory()) { console.log(` ${f}/`); continue; }
209
+ console.log(` ${f.padEnd(26)} ${c.dim((s.size / 1000).toFixed(1) + ' KB')}`);
210
+ }
211
+ console.log();
212
+ }
213
+
214
+ switch (cmd) {
215
+ case 'init': await init(); break;
216
+ case 'starter': await starter(); break;
217
+ case 'list': await list(); break;
218
+ default: usage();
219
+ }
package/build.mjs ADDED
@@ -0,0 +1,338 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Deck build
4
+ *
5
+ * node build.mjs
6
+ *
7
+ * Everything under src/ is a source file. Everything under dist/ is generated
8
+ * and safe to delete: this script rebuilds all of it. src/*.css concatenates
9
+ * into dist/deck.css, src/js/* is copied and minified, and src/deck-icons.svg
10
+ * is copied across. Requires nothing. If esbuild happens to be installed it is
11
+ * used for minification because it is better at it; otherwise a conservative
12
+ * built-in minifier runs, so the build never depends on a toolchain being
13
+ * present. That is the same promise the framework itself makes.
14
+ */
15
+
16
+ import { readdir, readFile, writeFile, mkdir, copyFile } from 'node:fs/promises';
17
+ import { createHash } from 'node:crypto';
18
+ import { gzipSync, brotliCompressSync, constants as Z } from 'node:zlib';
19
+ import path from 'node:path';
20
+ import { fileURLToPath } from 'node:url';
21
+
22
+ const root = path.dirname(fileURLToPath(import.meta.url));
23
+ const SRC = path.join(root, 'src');
24
+ const JS = path.join(SRC, 'js');
25
+ const BRAND = path.join(SRC, 'brand');
26
+ const DIST = path.join(root, 'dist');
27
+
28
+ const pkg = JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8'));
29
+
30
+ const BANNER = `/*! ==========================================================================
31
+ * Deck v${pkg.version} — the CSS framework for Keel
32
+ * Mobile first. One file. No build step. No config. No dependencies.
33
+ *
34
+ * <link rel="stylesheet" href="/assets/deck.css">
35
+ *
36
+ * Retheme the entire app from one line:
37
+ * :root { --hue-brand: 265; }
38
+ *
39
+ * Layer order (so your app CSS always wins without !important):
40
+ * deck.reset, deck.tokens, deck.type, deck.layout, deck.components,
41
+ * deck.mobile, deck.motion, deck.effects, deck.utilities, deck.rtl,
42
+ * deck.print, then app.base, app.components, app.pages, app.overrides
43
+ *
44
+ * ${pkg.license} licence. ${pkg.homepage}
45
+ * ======================================================================== */
46
+
47
+ `;
48
+
49
+ /* -------------------------------------------------------------------------
50
+ Built-in CSS minifier.
51
+ Deliberately conservative: it walks the file character by character so that
52
+ strings, url() values, and data URIs are never touched, because a minifier
53
+ that mangles a data URI produces a stylesheet that looks fine and renders
54
+ wrong. It only removes comments, collapses runs of whitespace, and drops
55
+ whitespace that is provably optional.
56
+ ------------------------------------------------------------------------- */
57
+ function minifyCSS(css) {
58
+ let out = '';
59
+ let i = 0;
60
+ const n = css.length;
61
+ const NEEDS_SPACE = /[a-zA-Z0-9_%)\]'"-]/;
62
+
63
+ while (i < n) {
64
+ const c = css[i];
65
+
66
+ // Preserve /*! banner comments, drop the rest
67
+ if (c === '/' && css[i + 1] === '*') {
68
+ const bang = css[i + 2] === '!';
69
+ const end = css.indexOf('*/', i + 2);
70
+ const stop = end === -1 ? n : end + 2;
71
+ if (bang) out += css.slice(i, stop);
72
+ i = stop;
73
+ continue;
74
+ }
75
+
76
+ // Strings pass through untouched
77
+ if (c === '"' || c === "'") {
78
+ const quote = c;
79
+ let j = i + 1;
80
+ while (j < n && !(css[j] === quote && css[j - 1] !== '\\')) j++;
81
+ out += css.slice(i, j + 1);
82
+ i = j + 1;
83
+ continue;
84
+ }
85
+
86
+ // url(...) passes through untouched, data URIs included
87
+ if ((c === 'u' || c === 'U') && /^url\(/i.test(css.slice(i, i + 4))) {
88
+ let j = i + 4, depth = 1;
89
+ while (j < n && depth > 0) {
90
+ if (css[j] === '(') depth++;
91
+ else if (css[j] === ')') depth--;
92
+ else if (css[j] === '"' || css[j] === "'") {
93
+ const q = css[j++];
94
+ while (j < n && !(css[j] === q && css[j - 1] !== '\\')) j++;
95
+ }
96
+ j++;
97
+ }
98
+ out += css.slice(i, j).replace(/\s+/g, '');
99
+ i = j;
100
+ continue;
101
+ }
102
+
103
+ // Collapse whitespace, then decide whether any is still needed
104
+ if (/\s/.test(c)) {
105
+ let j = i;
106
+ while (j < n && /\s/.test(css[j])) j++;
107
+ const prev = out[out.length - 1] || '';
108
+ const next = css[j] || '';
109
+ const structural = '{}:;,>~+()[]';
110
+ const keep =
111
+ NEEDS_SPACE.test(prev) && NEEDS_SPACE.test(next) ||
112
+ // a descendant combinator, or the space before a ( in a media query
113
+ (prev === ')' && NEEDS_SPACE.test(next)) ||
114
+ // never glue "and(" in @media, or a calc operand
115
+ (/[+\-*/]/.test(next) && prev !== '(' && !structural.includes(prev));
116
+ if (keep) out += ' ';
117
+ i = j;
118
+ continue;
119
+ }
120
+
121
+ // Drop the semicolon before a closing brace
122
+ if (c === ';') {
123
+ let j = i + 1;
124
+ while (j < n && /\s/.test(css[j])) j++;
125
+ if (css[j] === '}') { i = j; continue; }
126
+ out += ';';
127
+ i++;
128
+ continue;
129
+ }
130
+
131
+ out += c;
132
+ i++;
133
+ }
134
+
135
+ return out.replace(/\s*([{};,])\s*/g, (m, ch) => ch === ',' ? ',' : ch).trim();
136
+ }
137
+
138
+ /* ------------------------------------------------------------------------- */
139
+
140
+ async function tryEsbuild() {
141
+ try { return (await import('esbuild')).default ?? (await import('esbuild')); }
142
+ catch { return null; }
143
+ }
144
+
145
+ /* Every published size comes from here. Brotli is what browsers actually
146
+ negotiate for text on Cloudflare, Vercel, Netlify, and nginx with
147
+ ngx_brotli; gzip is the fallback, so both are measured and both are
148
+ reported. Quality 11 with a size hint is what a CDN uses for a static
149
+ asset it caches. */
150
+ const brotliSize = buf => brotliCompressSync(buf, {
151
+ params: {
152
+ [Z.BROTLI_PARAM_QUALITY]: 11,
153
+ [Z.BROTLI_PARAM_SIZE_HINT]: buf.length,
154
+ },
155
+ }).length;
156
+
157
+ /* filename in dist/ -> { raw, gzip, brotli }. Written to dist/sizes.json at
158
+ the end of the build; the docs and the demo page read it from there rather
159
+ than carrying hand-typed numbers. */
160
+ const SIZES = {};
161
+
162
+ function measure(distName, buf) {
163
+ const b = Buffer.isBuffer(buf) ? buf : Buffer.from(buf);
164
+ SIZES[distName] = { raw: b.length, gzip: gzipSync(b).length, brotli: brotliSize(b) };
165
+ return SIZES[distName];
166
+ }
167
+
168
+ const kb = b => (b / 1000).toFixed(1).padStart(6) + ' KB';
169
+
170
+ function report(label, raw, min, distName) {
171
+ const s = measure(distName, min);
172
+ console.log(
173
+ ` ${label.padEnd(22)} ${kb(raw.length)} raw ${kb(s.raw)} min ` +
174
+ `${kb(s.gzip)} gzip ${kb(s.brotli)} brotli`
175
+ );
176
+ }
177
+
178
+ async function build() {
179
+ await mkdir(DIST, { recursive: true });
180
+ const esbuild = await tryEsbuild();
181
+ console.log(`\nDeck v${pkg.version} ${esbuild ? '(esbuild)' : '(built-in minifier)'}\n`);
182
+
183
+ // ---- CSS ----------------------------------------------------------------
184
+ const files = (await readdir(SRC)).filter(f => f.endsWith('.css')).sort();
185
+ const parts = await Promise.all(
186
+ files.map(f => readFile(path.join(SRC, f), 'utf8'))
187
+ );
188
+ const css = BANNER + parts.join('\n');
189
+ await writeFile(path.join(DIST, 'deck.css'), css);
190
+
191
+ // The layer statement has to stay first, so minify the body and re-attach
192
+ const minCss = esbuild
193
+ ? BANNER.trim() + '\n' + (await esbuild.transform(css, {
194
+ loader: 'css', minify: true, target: ['chrome117', 'safari17.4', 'firefox128']
195
+ })).code
196
+ : minifyCSS(css);
197
+ await writeFile(path.join(DIST, 'deck.min.css'), minCss);
198
+ report('deck.css', css, minCss, 'deck.min.css');
199
+
200
+ // Per-layer files, for anyone who wants only part of Deck
201
+ await mkdir(path.join(DIST, 'layers'), { recursive: true });
202
+ for (const [i, f] of files.entries()) {
203
+ await writeFile(path.join(DIST, 'layers', f.replace(/^\d+-/, '')), parts[i]);
204
+ }
205
+ console.log(` ${'layers/'.padEnd(22)} ${files.length} files`);
206
+
207
+ // ---- Static assets ------------------------------------------------------
208
+ // The sprite is a source file, not a generated one, so it is copied rather
209
+ // than transformed. dist/ stays entirely disposable.
210
+ const sprite = await readFile(path.join(SRC, 'deck-icons.svg'));
211
+ await writeFile(path.join(DIST, 'deck-icons.svg'), sprite);
212
+ const spriteSize = measure('deck-icons.svg', sprite);
213
+ console.log(
214
+ ` ${'deck-icons.svg'.padEnd(22)} ${kb(spriteSize.raw)} raw ${' '.repeat(9)} ` +
215
+ `${kb(spriteSize.gzip)} gzip ${kb(spriteSize.brotli)} brotli`
216
+ );
217
+
218
+ // ---- Brand --------------------------------------------------------------
219
+ // src/brand/ owns the logo. public_html/assets/images/ is a published copy of
220
+ // it and nothing else, so there is one drawing on disk and no second sprite.
221
+ //
222
+ // Most of src/brand/ is itself derived from the master lockup by
223
+ // tools/make-brand.mjs, which records the hash of every master it read. The
224
+ // rasters cannot be regenerated in-process without a renderer, so rather
225
+ // than silently shipping stale artwork the build stops when a master moves.
226
+ await mkdir(path.join(DIST, 'brand'), { recursive: true });
227
+ const brandFiles = (await readdir(BRAND)).filter(f => f !== 'sources.json').sort();
228
+ for (const f of brandFiles) {
229
+ await copyFile(path.join(BRAND, f), path.join(DIST, 'brand', f));
230
+ }
231
+ console.log(` ${'brand/'.padEnd(22)} ${brandFiles.length} files`);
232
+
233
+ const sha = t => createHash('sha256').update(t, 'utf8').digest('hex').slice(0, 16);
234
+ const manifest = JSON.parse(await readFile(path.join(BRAND, 'sources.json'), 'utf8'));
235
+ const stale = [];
236
+ for (const [rel, recorded] of Object.entries(manifest.masters)) {
237
+ const actual = sha(await readFile(path.join(root, rel), 'utf8'));
238
+ if (actual !== recorded) stale.push(`${rel} recorded ${recorded}, now ${actual}`);
239
+ }
240
+ if (stale.length) {
241
+ console.error('\n Brand assets are stale. These masters have changed since');
242
+ console.error(' tools/make-brand.mjs last ran:\n');
243
+ for (const line of stale) console.error(` ${line}`);
244
+ console.error('\n Run: node tools/make-brand.mjs\n');
245
+ process.exit(1);
246
+ }
247
+
248
+ // ---- JS -----------------------------------------------------------------
249
+ const scripts = ['deck.js', 'deck-extras.js', 'deck-adapters.js'];
250
+ const sources = await Promise.all(
251
+ scripts.map(f => readFile(path.join(JS, f), 'utf8'))
252
+ );
253
+
254
+ for (const [i, name] of scripts.entries()) {
255
+ // The unminified copy is what most people load, so ship it verbatim
256
+ await writeFile(path.join(DIST, name), sources[i]);
257
+ if (!esbuild) continue;
258
+ const { code } = await esbuild.transform(sources[i], {
259
+ loader: 'js', minify: true, target: 'es2022'
260
+ });
261
+ await writeFile(path.join(DIST, name.replace('.js', '.min.js')), code);
262
+ report(name, sources[i], code, name.replace('.js', '.min.js'));
263
+ }
264
+
265
+ // Everything in one request
266
+ const bundle = sources.join('\n');
267
+ await writeFile(path.join(DIST, 'deck.bundle.js'), bundle);
268
+ if (esbuild) {
269
+ const { code } = await esbuild.transform(bundle, { loader: 'js', minify: true, target: 'es2022' });
270
+ await writeFile(path.join(DIST, 'deck.bundle.min.js'), code);
271
+ report('deck.bundle.js', bundle, code, 'deck.bundle.min.js');
272
+ }
273
+
274
+ // ESM entry, for anyone importing Deck from a bundler
275
+ await writeFile(path.join(DIST, 'deck.esm.js'),
276
+ `/* Deck v${pkg.version} — ES module entry.\n` +
277
+ ` The scripts attach Deck to globalThis; this re-exports it so that\n` +
278
+ ` \`import Deck from '@echodial/deck'\` behaves the way you expect. */\n` +
279
+ bundle + '\nexport default globalThis.Deck;\nexport { globalThis as __deckGlobal };\n');
280
+
281
+ // ---- Sizes ---------------------------------------------------------------
282
+ // One place for every published figure. dist/sizes.json is the single source
283
+ // of truth: the demo page reads it at render time, and tools/sync-sizes.mjs
284
+ // writes it into README.md and llms.txt. Nothing is typed by hand.
285
+ const sum = (...names) => ({
286
+ gzip: names.reduce((n, f) => n + SIZES[f].gzip, 0),
287
+ brotli: names.reduce((n, f) => n + SIZES[f].brotli, 0),
288
+ });
289
+ const previous = await readFile(path.join(DIST, 'sizes.json'), 'utf8')
290
+ .then(JSON.parse).catch(() => ({}));
291
+ const sizes = {
292
+ version: pkg.version,
293
+ note: 'Generated by build.mjs. Do not edit. Brotli is quality 11 with a size hint.',
294
+ files: SIZES,
295
+ totals: {
296
+ // A page that loads the stylesheet, the sprite, and the optional core JS
297
+ core: sum('deck.min.css', 'deck-icons.svg', 'deck.min.js'),
298
+ // The same page with the full bundle instead of deck.min.js
299
+ bundle: sum('deck.min.css', 'deck-icons.svg', 'deck.bundle.min.js'),
300
+ },
301
+ // Owned by tools/icons/build-icons.mjs, which needs the font. Preserved
302
+ // across builds so a plain `npm run build` never drops it.
303
+ sample: previous.sample ?? null,
304
+ };
305
+ await writeFile(path.join(DIST, 'sizes.json'), JSON.stringify(sizes, null, 2) + '\n');
306
+
307
+ // ---- Documentation --------------------------------------------------------
308
+ // The class inventory is generated from src/ and every docs page is checked
309
+ // against it, so a class that is added, renamed, or removed cannot silently
310
+ // leave the documentation describing something that no longer exists.
311
+ try {
312
+ const { default: docsApi } = await import('./tools/docs/extract.mjs');
313
+ console.log(
314
+ ` ${'api.json'.padEnd(22)} ${String(docsApi.counts.classes).padStart(6)} classes, ` +
315
+ `${docsApi.counts.documented} documented, ${docsApi.counts.undocumented} outstanding`
316
+ );
317
+ await import('./tools/docs/usage.mjs');
318
+ await import('./tools/docs/classify.mjs');
319
+ const { verifyDocs } = await import('./tools/docs/verify.mjs');
320
+ verifyDocs();
321
+ } catch (err) {
322
+ if (err.code !== 'ERR_MODULE_NOT_FOUND') throw err;
323
+ }
324
+
325
+ try {
326
+ const { syncDocs } = await import('./tools/sync-sizes.mjs');
327
+ const touched = await syncDocs(sizes, root);
328
+ console.log(`\n ${'sizes.json'.padEnd(22)} ${Object.keys(SIZES).length} files measured`);
329
+ for (const [f, n] of touched) console.log(` ${f.padEnd(22)} ${String(n).padStart(6)} figures synced`);
330
+ } catch (err) {
331
+ // tools/ is not shipped to npm, so an install can still run this build.
332
+ if (err.code !== 'ERR_MODULE_NOT_FOUND') throw err;
333
+ }
334
+
335
+ console.log('\nDone.\n');
336
+ }
337
+
338
+ build().catch(err => { console.error(err); process.exit(1); });