@fusion.dev/helix-theme 0.206.0 → 0.207.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,32 +1,72 @@
1
1
  # @fusion.dev/helix-theme
2
2
 
3
- The theme derivation engine. Computes a complete, contrast-guaranteed token set
4
- from a small set of intent inputs.
3
+ The theme derivation engine. Computes a complete token set from a small set of
4
+ intent inputs, and measures every text and background pair in its surface model
5
+ against its contrast target.
5
6
 
6
7
  ```sh
7
8
  npm install @fusion.dev/helix-theme
8
9
  ```
9
10
 
11
+ Give it what you mean, a brand color and whatever else you have an opinion
12
+ about, and it returns the tokens with each text color solved against the
13
+ backgrounds it lands on. A pair that cannot meet its target, such as one with a
14
+ color you pinned, is reported in `violations`. `foreground-subtle` is not
15
+ solved: it is a fixed step of the stylesheet's gray ramp, and its contrast is
16
+ not guaranteed. With no brand, the theme is ink: near-black on a light page and
17
+ near-white on a dark one.
18
+
19
+ ## Load the engine
20
+
21
+ The engine is WebAssembly. The module is in the package at
22
+ `wasm/fusion_helix_theme_bg.wasm`, exported as
23
+ `@fusion.dev/helix-theme/wasm/fusion_helix_theme_bg.wasm`. Serve that file from
24
+ your app, and pass its URL to `initTheme`:
25
+
10
26
  ```js
11
27
  import { initTheme } from '@fusion.dev/helix-theme'
12
28
 
13
- const engine = await initTheme('/helix/theme.wasm')
14
- const theme = engine.derive({ brand: '#3b82f6' }, [':root'])
29
+ const engine = await initTheme(wasmUrl)
30
+ ```
15
31
 
16
- document.head.append(Object.assign(document.createElement('style'), {
17
- textContent: theme.css[':root'],
18
- }))
32
+ With Vite, a `?url` import is one way to serve it. Vite copies the file into
33
+ your build and gives you its URL:
34
+
35
+ ```js
36
+ import wasmUrl from '@fusion.dev/helix-theme/wasm/fusion_helix_theme_bg.wasm?url'
19
37
  ```
20
38
 
21
- Give it what you mean, a brand color and whatever else you have an opinion
22
- about, and it returns every token the system needs with the contrast
23
- relationships already satisfied. With no brand, the theme is ink: near-black on
24
- a light page and near-white on a dark one.
39
+ In Node, pass the file's bytes:
40
+
41
+ ```js
42
+ import { readFile } from 'node:fs/promises'
43
+ import { initTheme } from '@fusion.dev/helix-theme'
44
+
45
+ const engine = await initTheme(
46
+ await readFile(new URL(import.meta.resolve('@fusion.dev/helix-theme/wasm/fusion_helix_theme_bg.wasm'))),
47
+ )
48
+ ```
25
49
 
26
50
  **It has to be awaited and there is no way around that.** WebAssembly
27
51
  instantiates asynchronously, so the engine is handed back from `initTheme`
28
52
  rather than being importable: you cannot hold one without having waited for it.
29
53
 
54
+ ## Theme a page
55
+
56
+ ```js
57
+ const { css, dark, light } = engine.deriveModes({ brand: '#3b82f6' }, [':root'])
58
+
59
+ document.head.append(Object.assign(document.createElement('style'), {
60
+ textContent: css[':root'],
61
+ }))
62
+ ```
63
+
64
+ `deriveModes` derives the intent in light and dark mode and renders one block
65
+ with each color as a `light-dark()` pair, so the page follows the reader's color
66
+ mode. Each mode's theme comes back as well, as `light` and `dark`, with its own
67
+ `violations`. `derive` derives one mode, for a theme that has only one, such as
68
+ a section in a color of its own.
69
+
30
70
  The derivation is Rust and ships as WASM, so the same arithmetic runs in a
31
71
  browser, in a build step and in the CLI, rather than two implementations
32
72
  disagreeing by construction.
package/dist/index.d.ts CHANGED
@@ -5,10 +5,15 @@ export interface ThemeEngine {
5
5
  /**
6
6
  * Derives a theme, and renders it under each selector asked for.
7
7
  *
8
+ * One mode, the intent's `scheme`, which is light when it names none. For a
9
+ * page with both modes, `deriveModes` derives each and renders them as one
10
+ * block.
11
+ *
8
12
  * @throws when the engine refuses the intent, which it does for anything that
9
- * would produce a palette reporting itself clean without having been measured:
10
- * an unreadable brand, accent or pinned status, a non-finite number, or one
11
- * outside the range it is meaningful in.
13
+ * would produce a palette reporting itself clean without having been measured,
14
+ * or a block CSS cannot read: an unreadable brand, accent or pinned status, a
15
+ * non-finite number, one outside the range it is meaningful in, or a namespace
16
+ * that is not a token prefix.
12
17
  */
13
18
  derive(intent: ThemeIntent, selectors?: readonly string[]): DerivedTheme;
14
19
  /**
@@ -55,5 +60,9 @@ export type Source = BufferSource | Response | URL | string;
55
60
  * startup get one instantiation and both wait on it. Caching the resolved value
56
61
  * instead would let the second call start a second download before the first
57
62
  * finished.
63
+ *
64
+ * **A load that fails is not kept.** Every caller already waiting on it gets
65
+ * the failure, and the next call loads again, so a retry with the right source
66
+ * can succeed.
58
67
  */
59
68
  export declare function initTheme(source: Source): Promise<ThemeEngine>;
package/dist/index.js CHANGED
@@ -1,10 +1,31 @@
1
1
  import initWasm, { derive, deriveModes, hexToOklch as readHex } from '../wasm/fusion_helix_theme.js';
2
2
  let loading;
3
+ function finite(value, at) {
4
+ if (typeof value === 'number' && !Number.isFinite(value)) {
5
+ throw new Error(`${at} must be a finite number, received ${value}`);
6
+ }
7
+ if (typeof value !== 'object' || value === null) {
8
+ return;
9
+ }
10
+ const list = Array.isArray(value);
11
+ for (const [key, inner] of Object.entries(value)) {
12
+ finite(inner, list ? `${at}[${key}]` : at === '' ? key : `${at}.${key}`);
13
+ }
14
+ }
15
+ function written(intent) {
16
+ finite(intent, '');
17
+ return JSON.stringify(intent);
18
+ }
3
19
  export function initTheme(source) {
4
- loading ??= initWasm({ module_or_path: source }).then(() => ({
5
- derive: (intent, selectors = []) => JSON.parse(derive(JSON.stringify(intent), JSON.stringify(selectors))),
6
- deriveModes: (intent, selectors = []) => JSON.parse(deriveModes(JSON.stringify(intent), JSON.stringify(selectors))),
20
+ loading ??= initWasm({ module_or_path: source })
21
+ .then(() => ({
22
+ derive: (intent, selectors = []) => JSON.parse(derive(written(intent), JSON.stringify(selectors))),
23
+ deriveModes: (intent, selectors = []) => JSON.parse(deriveModes(written(intent), JSON.stringify(selectors))),
7
24
  hexToOklch: (hex) => JSON.parse(readHex(hex)),
8
- }));
25
+ }))
26
+ .catch((failure) => {
27
+ loading = undefined;
28
+ throw failure;
29
+ });
9
30
  return loading;
10
31
  }
package/dist/types.d.ts CHANGED
@@ -62,6 +62,14 @@ export type Seed = Color | Relation;
62
62
  * A status role. A color pins it outright; an object nudges its hue within
63
63
  * the meaning it already carries.
64
64
  *
65
+ * **A hue stays in its status's band**, in OKLCH degrees, and one outside it
66
+ * is refused with the band it missed:
67
+ *
68
+ * - `danger`: 15 to 40, the reds from crimson to red-orange
69
+ * - `warning`: 45 to 105, orange and amber to yellow
70
+ * - `success`: 125 to 180, the greens from yellow-green to teal-green
71
+ * - `info`: 200 to 280, cyan and blue to indigo
72
+ *
65
73
  * **Never a `Relation`**, which is the asymmetry with `accent` and is
66
74
  * deliberate: a `danger` that rotates with the brand is a warning a reader has
67
75
  * to re-learn per product.
@@ -71,6 +79,11 @@ export type StatusSeed = Color | {
71
79
  };
72
80
  /** One pair to measure, by token name, without the namespace prefix. */
73
81
  export interface PairingRequest {
82
+ /**
83
+ * The ground. `color-well over color-surface` names a field's well over the
84
+ * surface it sits on: 5% of the theme's `color-foreground` laid over that
85
+ * surface, as the stylesheet paints it.
86
+ */
74
87
  background: string;
75
88
  foreground: string;
76
89
  /**
@@ -210,7 +223,13 @@ export interface ThemeIntent {
210
223
  weight?: 'light' | 'regular' | 'strong';
211
224
  };
212
225
  /**
213
- * The token prefix, without dashes. `helix` by default.
226
+ * The token prefix. `helix` by default, and `acme` emits
227
+ * `--acme-color-primary`.
228
+ *
229
+ * ASCII letters, digits, hyphens, and underscores, with no hyphen at either
230
+ * end, since the engine writes the hyphens around it. Any other namespace is
231
+ * refused with the value it received, such as an empty one, `--acme`, or
232
+ * `acme tokens`.
214
233
  *
215
234
  * **What makes the engine useful to somebody who has never heard of Helix.**
216
235
  * With this and `pairings`, a team on their own tokens gets the solver and
@@ -333,8 +352,15 @@ export interface ThemeIntent {
333
352
  };
334
353
  /**
335
354
  * Values that replace what derivation would have produced, keyed by token
336
- * name without the namespace prefix. Verified like anything else: a pinned
337
- * value that misses its target is a violation, not an exception.
355
+ * name without the namespace prefix.
356
+ *
357
+ * A pinned value is set after the derivation, so no other color is solved
358
+ * against it. Every pairing that names the token measures it, and a value
359
+ * that misses its target is a violation. A name the engine does not emit is
360
+ * appended as a token of your own, which no default pairing names, so only a
361
+ * pairing you list in `pairings` measures it. A value the engine cannot read
362
+ * is skipped: a token the engine emits keeps its derived value, and a name it
363
+ * does not emit is left out.
338
364
  */
339
365
  tokens?: Record<string, Color>;
340
366
  }
@@ -374,6 +400,10 @@ export interface DerivedToken {
374
400
  }
375
401
  /** One measured pair, whatever the outcome. */
376
402
  export interface Pairing {
403
+ /**
404
+ * The ground, as the request names it. `color-well over color-surface` is a
405
+ * field's well over that surface.
406
+ */
377
407
  background: string;
378
408
  foreground: string;
379
409
  /**
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@fusion.dev/helix-theme",
3
- "description": "Theme derivation engine. Computes a complete, contrast-guaranteed token set from intent inputs.",
4
- "version": "0.206.0",
3
+ "description": "Theme derivation engine. Computes a complete token set from intent inputs, and measures every text and background pair it solves against its contrast target.",
4
+ "version": "0.207.1",
5
5
  "license": "MIT",
6
6
  "author": "Ross Sheppard <ross.sheppard@fusion.dev>",
7
7
  "repository": {
@@ -17,6 +17,7 @@
17
17
  "types": "./dist/index.d.ts",
18
18
  "default": "./dist/index.js"
19
19
  },
20
+ "./package.json": "./package.json",
20
21
  "./wasm/fusion_helix_theme_bg.wasm": "./wasm/fusion_helix_theme_bg.wasm"
21
22
  },
22
23
  "files": [
@@ -26,6 +27,7 @@
26
27
  "sideEffects": false,
27
28
  "scripts": {
28
29
  "build": "tsc -p tsconfig.build.json && tsc -p tsconfig.types.json",
30
+ "test": "bun test src",
29
31
  "typecheck": "tsc -p tsconfig.json --noEmit",
30
32
  "wasm": "bun scripts/build-wasm.mjs",
31
33
  "wasm-check": "bun scripts/check-wasm.mjs"
@@ -236,11 +236,15 @@ function __wbg_finalize_init(instance, module) {
236
236
 
237
237
  async function __wbg_load(module, imports) {
238
238
  if (typeof Response === 'function' && module instanceof Response) {
239
+ if (!module.ok) {
240
+ throw new Error(`failed to fetch Wasm: ${module.status} ${module.statusText} fetching '${module.url}'`);
241
+ }
242
+
239
243
  if (typeof WebAssembly.instantiateStreaming === 'function') {
240
244
  try {
241
245
  return await WebAssembly.instantiateStreaming(module, imports);
242
246
  } catch (e) {
243
- const validResponse = module.ok && expectedResponseType(module.type);
247
+ const validResponse = expectedResponseType(module.type);
244
248
 
245
249
  if (validResponse && module.headers.get('Content-Type') !== 'application/wasm') {
246
250
  console.warn("`WebAssembly.instantiateStreaming` failed because your server does not serve Wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n", e);
Binary file