@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 +51 -11
- package/dist/index.d.ts +12 -3
- package/dist/index.js +25 -4
- package/dist/types.d.ts +33 -3
- package/package.json +4 -2
- package/wasm/fusion_helix_theme.js +5 -1
- package/wasm/fusion_helix_theme_bg.wasm +0 -0
package/README.md
CHANGED
|
@@ -1,32 +1,72 @@
|
|
|
1
1
|
# @fusion.dev/helix-theme
|
|
2
2
|
|
|
3
|
-
The theme derivation engine. Computes a complete
|
|
4
|
-
|
|
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(
|
|
14
|
-
|
|
29
|
+
const engine = await initTheme(wasmUrl)
|
|
30
|
+
```
|
|
15
31
|
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
|
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 })
|
|
5
|
-
|
|
6
|
-
|
|
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
|
|
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.
|
|
337
|
-
*
|
|
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
|
|
4
|
-
"version": "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 =
|
|
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
|