@fusion.dev/helix-theme 0.0.0-dev

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Fusion Development Group
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,47 @@
1
+ import type { DerivedTheme, Oklch, ThemeIntent } from './types.js';
2
+ export type { Color, DerivedTheme, DerivedToken, Oklch, Pairing, PairingRequest, Relation, Seed, StatusSeed, ThemeIntent, } from './types.js';
3
+ /** A loaded engine. */
4
+ export interface ThemeEngine {
5
+ /**
6
+ * Derives a theme, and renders it under each selector asked for.
7
+ *
8
+ * @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.
12
+ */
13
+ derive(intent: ThemeIntent, selectors?: readonly string[]): DerivedTheme;
14
+ /**
15
+ * Reads a hex color into its OKLCH components.
16
+ *
17
+ * @throws when the string is not a hex color. **The TypeScript engine
18
+ * returned a `NaN` triple**, which every comparison is false against, so a
19
+ * color that could not be read traveled onward looking like one.
20
+ */
21
+ hexToOklch(hex: string): Oklch;
22
+ }
23
+ /**
24
+ * Where the module is fetched from.
25
+ *
26
+ * **Required, because every default was wrong for somebody.** The obvious one,
27
+ * `new URL('fusion_helix_theme_bg.wasm', import.meta.url)`, is what the generated
28
+ * loader does and it works for neither caller here: Turbopack reads it as a
29
+ * module specifier and fails the build with "Can't resolve
30
+ * 'fusion_helix_theme_bg.wasm'", and Node's `fetch` refuses a `file:` URL outright.
31
+ * Reading the file here instead would put a `node:fs` import into a package
32
+ * whose purpose is to run in a browser.
33
+ *
34
+ * So the one thing a WebAssembly consumer genuinely has to decide, how the
35
+ * module gets delivered, is asked rather than guessed. A browser passes the URL
36
+ * it serves it from; Node passes the bytes.
37
+ */
38
+ export type Source = BufferSource | Response | URL | string;
39
+ /**
40
+ * Loads the engine, once.
41
+ *
42
+ * **The promise is cached rather than the result**, so two callers racing at
43
+ * startup get one instantiation and both wait on it. Caching the resolved value
44
+ * instead would let the second call start a second download before the first
45
+ * finished.
46
+ */
47
+ export declare function initTheme(source: Source): Promise<ThemeEngine>;
package/dist/index.js ADDED
@@ -0,0 +1,42 @@
1
+ /**
2
+ * The theme engine, reached from JavaScript.
3
+ *
4
+ * **One implementation, two distributions.** The derivation is Rust: it is a
5
+ * search, and `fitToGamut` is a twenty-step bisection while
6
+ * `lightnessForContrast` searches too, so iteration count, bisection order and
7
+ * float arithmetic are implementation choices. Two implementations of that
8
+ * disagree by construction rather than by accident, which is why this package
9
+ * stopped being an engine and became a way to reach one.
10
+ *
11
+ * **It has to be awaited, and there is no way around that.** WebAssembly
12
+ * instantiates asynchronously, so a module-level `deriveTheme` would either
13
+ * block or throw for anybody who called it before the module landed. Handing the
14
+ * engine back from `initTheme` makes the fact unavoidable rather than a runtime
15
+ * error somebody meets in production: you cannot hold a `ThemeEngine` without
16
+ * having waited for one.
17
+ *
18
+ * ```ts
19
+ * const engine = await initTheme('/helix/theme.wasm')
20
+ * const theme = engine.derive({ brand: '#3b82f6' }, [':root'])
21
+ * document.head.append(Object.assign(document.createElement('style'), {
22
+ * textContent: theme.css[':root'],
23
+ * }))
24
+ * ```
25
+ */
26
+ import initWasm, { derive, hexToOklch as readHex } from '../wasm/fusion_helix_theme.js';
27
+ let loading;
28
+ /**
29
+ * Loads the engine, once.
30
+ *
31
+ * **The promise is cached rather than the result**, so two callers racing at
32
+ * startup get one instantiation and both wait on it. Caching the resolved value
33
+ * instead would let the second call start a second download before the first
34
+ * finished.
35
+ */
36
+ export function initTheme(source) {
37
+ loading ??= initWasm({ module_or_path: source }).then(() => ({
38
+ derive: (intent, selectors = []) => JSON.parse(derive(JSON.stringify(intent), JSON.stringify(selectors))),
39
+ hexToOklch: (hex) => JSON.parse(readHex(hex)),
40
+ }));
41
+ return loading;
42
+ }
@@ -0,0 +1,425 @@
1
+ /**
2
+ * The shapes a consumer writes and the shapes they get back.
3
+ *
4
+ * **Declarations only, and that is what is left of the TypeScript engine.** The
5
+ * derivation is Rust now, reached through WebAssembly, so what a JavaScript
6
+ * consumer needs from this package is the vocabulary rather than the solver. A
7
+ * type is erased at build time, so nothing here is a second implementation of
8
+ * anything: it cannot drift into disagreeing with the engine about an *answer*,
9
+ * only about a *field name*, which is what `check:wasm` compares.
10
+ *
11
+ * The prose is kept whole from the files it came from. It is the argument for
12
+ * why each field exists, and it was never about how any of them was computed.
13
+ */
14
+ /** A color in OKLCH. Lightness 0 to 1, chroma from 0, hue in degrees. */
15
+ export interface Oklch {
16
+ chroma: number;
17
+ hue: number;
18
+ lightness: number;
19
+ }
20
+ /** A color a consumer supplies. Hex is accepted and converted at the edge. */
21
+ export type Color = Oklch | string;
22
+ /**
23
+ * How a role departs from the role it derives from.
24
+ *
25
+ * Written as a relationship rather than a second color because a literal
26
+ * drifts out of relation the moment anybody rethemes, which is the failure of
27
+ * every palette with two hardcoded colours in it.
28
+ */
29
+ export interface Relation {
30
+ /**
31
+ * How far, in degrees, the angle may move to clear a role it cannot be told
32
+ * apart from. 45 by default.
33
+ *
34
+ * **The accent is the role that yields, and that is the whole point of the
35
+ * field.** A status hue carries meaning and a brand is a fact the consumer
36
+ * supplied; an accent is a relationship with no meaning attached, so when two
37
+ * of them collide the accent is the one with nothing to lose. Measured on the
38
+ * shipped blue: the complement lands at 80 degrees, `warning` is amber at 75,
39
+ * and no rotation of `warning` inside any sane allowance clears it, because
40
+ * `danger` and `success` box in every hue it could reach.
41
+ *
42
+ * Set it to 0 to hold the angle exactly and let `collisions` report the
43
+ * result. A pinned accent never moves at all, a color being a fact.
44
+ */
45
+ allowance?: number;
46
+ /**
47
+ * Multiplier on the source's chroma. 1 keeps it, below 1 quiets it. An
48
+ * accent louder than the thing it accents is a second primary, so this is
49
+ * capped at 1 rather than trusted.
50
+ */
51
+ chroma?: number;
52
+ /**
53
+ * The role this one departs from. Only `brand` today, and the field exists
54
+ * so a third role can relate to the second without a new mechanism.
55
+ */
56
+ from: 'brand';
57
+ /**
58
+ * Degrees around the hue circle. 180 is the complement and is what a second
59
+ * color usually means, 30 is analogous, 120 is a triad.
60
+ */
61
+ rotate: number;
62
+ }
63
+ /** A role takes a color, or says how it relates to one. */
64
+ export type Seed = Color | Relation;
65
+ /**
66
+ * A status role. A color pins it outright; an object nudges its hue within
67
+ * the meaning it already carries.
68
+ *
69
+ * **Never a `Relation`**, which is the asymmetry with `accent` and is
70
+ * deliberate: a `danger` that rotates with the brand is a warning a reader has
71
+ * to re-learn per product.
72
+ */
73
+ export type StatusSeed = Color | {
74
+ hue?: number;
75
+ };
76
+ /** One pair to measure, by token name, without the namespace prefix. */
77
+ export interface PairingRequest {
78
+ background: string;
79
+ foreground: string;
80
+ /**
81
+ * The ratio this pair has to reach. Carried per pair rather than taken from
82
+ * the theme, because a focus ring answers to WCAG 1.4.11 at 3:1 while body
83
+ * text answers to `contrastTarget`, and one number cannot be both.
84
+ */
85
+ target: number;
86
+ }
87
+ export interface ThemeIntent {
88
+ /**
89
+ * The second emphasis. A color pins it and the relationship stands aside.
90
+ * Defaults to the complement, `{ from: 'brand', rotate: 180 }`.
91
+ */
92
+ accent?: Seed;
93
+ /**
94
+ * The one color most consumers care about, and the only required input.
95
+ */
96
+ brand: Color;
97
+ /**
98
+ * How saturated the palette is, independent of which hue it is.
99
+ *
100
+ * **This is the axis a brand's feel lives on.** Monotone, muted, restrained
101
+ * and vivid are all chroma statements and none of them is a hue. `ceiling`
102
+ * clamps, so 0 is monotone and roughly 0.10 is muted; `scale` multiplies
103
+ * what the brand supplies. Both apply to every derived color including the
104
+ * brand itself, because a monotone theme is one where the brand is gray too.
105
+ *
106
+ * Never applied to a value the consumer pinned. A pinned color is a fact.
107
+ */
108
+ chroma?: {
109
+ ceiling?: number;
110
+ scale?: number;
111
+ };
112
+ /**
113
+ * Target contrast for body text. 4.5 is WCAG AA, 7 is AAA. The European
114
+ * Accessibility Act points at WCAG 2.1 AA, so 4.5 is the figure with legal
115
+ * weight and is the default.
116
+ */
117
+ contrastTarget?: number;
118
+ /**
119
+ * Scales spacing and control heights. 1 is default, 0.875 is compact.
120
+ */
121
+ density?: number;
122
+ /**
123
+ * Translucency and blur: how much a surface above content admits what is
124
+ * behind it. `blur` is pixels and 0 is flat; `opacity` is 0 to 1.
125
+ *
126
+ * **The one material choice that can invalidate the guarantee.** Contrast is
127
+ * measured against a known ground and a translucent surface does not have
128
+ * one, so a pair over it comes back `unverifiable` rather than measured.
129
+ *
130
+ * The audit found this half nearly done already: six declarations across two
131
+ * families, both already behind hooks, so material needed a policy token to
132
+ * default to and nothing else.
133
+ */
134
+ material?: {
135
+ blur?: number;
136
+ opacity?: number;
137
+ };
138
+ /**
139
+ * The faces the theme is set in.
140
+ *
141
+ * **Type is part of a theme and had no input at all.** Twenty tokens across
142
+ * five categories, families, sizes, weights, leading and tracking, were
143
+ * unreachable from intent while every color in the palette was one field
144
+ * away. Families are the half a consumer actually chooses; the size scale,
145
+ * the weights and the spacing are the half that is a system, and they are
146
+ * still fixed.
147
+ *
148
+ * `display` falls back to `body`, which is what the stylesheet already does:
149
+ * a theme with one face is the common case and should not have to say so
150
+ * three times.
151
+ */
152
+ font?: {
153
+ /**
154
+ * Body size in pixels. 14 is what ships.
155
+ *
156
+ * The whole ramp is built from it: the two steps below step down a pixel
157
+ * each and the four above compound by `scale`.
158
+ */
159
+ base?: number;
160
+ body?: string;
161
+ display?: string;
162
+ mono?: string;
163
+ /**
164
+ * The ratio between the display steps. Roughly 1.33 is what ships, which is
165
+ * a perfect fourth.
166
+ *
167
+ * **It applies above the body size and not below it**, because the two ends
168
+ * of a type ramp are different problems. The steps under body are a UI
169
+ * ramp, where 12 and 13 sit a pixel apart because a label has to be smaller
170
+ * than its field and still be read; the steps above are a display ramp,
171
+ * where each one has to be obviously not the last. One ratio across both
172
+ * either crushes the top or blows out the bottom.
173
+ */
174
+ scale?: number;
175
+ /**
176
+ * How heavy the three weights run. `regular` is 400, 500 and 600, which is
177
+ * what ships; `strong` shifts each up a step and `light` down one.
178
+ *
179
+ * A character rather than three numbers, because the three move together or
180
+ * the relationships between them stop meaning anything.
181
+ */
182
+ weight?: 'light' | 'regular' | 'strong';
183
+ };
184
+ /**
185
+ * The token prefix, without dashes. `helix` by default.
186
+ *
187
+ * **What makes the engine useful to somebody who has never heard of Helix.**
188
+ * With this and `pairings`, a team on their own tokens gets the solver and
189
+ * the verification without adopting the components.
190
+ */
191
+ namespace?: string;
192
+ /**
193
+ * How the theme's motion feels.
194
+ *
195
+ * **The other axis with no input at all**, alongside the half of type that is
196
+ * a system: nine tokens, four durations and five curves, and a brand with a
197
+ * snappy voice and one with a gentle one reached them the same way, which is
198
+ * to say not at all.
199
+ *
200
+ * `character` sets the curve every component reaches for by default and
201
+ * carries a duration multiplier with it, because the two are one decision: a
202
+ * spring over 120ms is a twitch and a soft curve over 120ms is a fade. `scale`
203
+ * multiplies on top for a theme that wants the character and not the pace.
204
+ *
205
+ * `instant` is not scaled. It is 0 and means "do not animate", which is a
206
+ * statement rather than a duration, and it emits no curves at all: three
207
+ * easings for transitions of zero length is a contradiction the type used to
208
+ * allow through `scale: 0`.
209
+ *
210
+ * `precise` is the one character that is not physical. The other three
211
+ * accelerate, settle or overshoot; linear reads as moved by a mechanism, and
212
+ * it is the only curve legible on a short travel, because constancy shows in
213
+ * the timing where an overshoot shows in the distance.
214
+ */
215
+ motion?: {
216
+ character?: 'gentle' | 'instant' | 'precise' | 'snappy' | 'springy';
217
+ scale?: number;
218
+ };
219
+ /**
220
+ * The cast every gray carries.
221
+ *
222
+ * Chroma 0 is a dead gray and is the default, matching `primitives.css`,
223
+ * which records why: a fixed chroma is a rising *saturation* as a surface
224
+ * darkens, so a trace that looks right on a light page reads navy across a
225
+ * dark one. The engine used to hardcode 0.008, the exact value the
226
+ * stylesheet had already rejected for that reason.
227
+ */
228
+ neutral?: {
229
+ chroma?: number;
230
+ hue?: number;
231
+ };
232
+ /**
233
+ * How a surface is told apart from what is behind it.
234
+ *
235
+ * **Two policies, because the two kinds of surface are not the same
236
+ * problem.** A card sits in the flow on a ground the theme knows, so how it
237
+ * separates is a style choice. A menu floats over whatever the reader
238
+ * scrolled under it, so its elevation is semantic: it is the thing saying
239
+ * "above".
240
+ *
241
+ * `space` is therefore offered for a surface in the flow and not for a
242
+ * floating one. A floating panel with neither edge nor lift nor fill is
243
+ * unreadable over content nobody can predict, and it is the one failure the
244
+ * engine cannot detect, the ground being unbounded. The type refuses it
245
+ * rather than a comment asking nicely.
246
+ *
247
+ * `fill` and `space` produce the same edge and lift, which is none of
248
+ * either: the difference between them lives in the surface colours and in
249
+ * `density`, not here. Said out loud because two values with one output
250
+ * looks like a bug until it is explained.
251
+ */
252
+ separation?: {
253
+ floating?: 'border' | 'fill' | 'shadow';
254
+ surface?: 'border' | 'fill' | 'shadow' | 'space';
255
+ };
256
+ /**
257
+ * Which pairs to measure. Defaults to the surfaces and inks this engine
258
+ * emits. A caller may name their own tokens; anything not emitted comes back
259
+ * `unverifiable` rather than being dropped.
260
+ */
261
+ pairings?: PairingRequest[];
262
+ /**
263
+ * Base corner radius in rem.
264
+ */
265
+ radius?: number;
266
+ /**
267
+ * Light or dark surface.
268
+ */
269
+ scheme?: 'dark' | 'light';
270
+ /**
271
+ * Status roles. **Hue is fixed by meaning and everything else derives.**
272
+ *
273
+ * Chroma follows `chroma`, because a vivid red danger inside a monotone
274
+ * theme reads as a bug rather than as a warning. Lightness is solved against
275
+ * `contrastTarget` like any other ink. Only the hue is held, and only
276
+ * because moving it changes what the color means.
277
+ *
278
+ * `allowance` is how far a status may rotate on its own to clear a collision,
279
+ * which is what turns `collisions` from a finding into a repair: a red brand
280
+ * beside a red danger carries no information, and twenty degrees makes the
281
+ * danger crimson without making it not-red.
282
+ *
283
+ * **20 by default, not 0.** A palette that ships reporting a collision it has
284
+ * the means to fix is a palette nobody trusts the report of. A status spends
285
+ * only what it needs, the search stopping at the first hue that clears, and
286
+ * it keeps its own hue entirely when nothing inside the bound works. Set it
287
+ * to 0 to hold every status where convention puts it and read the result off
288
+ * `collisions` instead.
289
+ */
290
+ status?: {
291
+ allowance?: number;
292
+ /**
293
+ * How far the statuses follow the palette's own loudness, 0 to 1.
294
+ *
295
+ * **Hue is meaning and loudness is not**, which is the distinction this
296
+ * field turns on. A status that rotates with the brand is a warning a
297
+ * reader re-learns per product, and that stays refused. A status that
298
+ * ignores how loud the palette around it is has a different problem:
299
+ * against a muted brand, danger measured six times the chroma of primary,
300
+ * which is a vivid red screaming on a near-gray page.
301
+ *
302
+ * 0 holds each status at its own chroma. 1 follows the palette as far as the
303
+ * floor allows, and there is a floor: a danger quiet enough to stop reading
304
+ * as danger is worse than a loud one. It never goes above its own chroma
305
+ * either, that already being what its hue can hold.
306
+ */
307
+ harmony?: number;
308
+ danger?: StatusSeed;
309
+ info?: StatusSeed;
310
+ success?: StatusSeed;
311
+ warning?: StatusSeed;
312
+ };
313
+ /**
314
+ * Values that replace what derivation would have produced, keyed by token
315
+ * name without the namespace prefix. Verified like anything else: a pinned
316
+ * value that misses its target is a violation, not an exception.
317
+ */
318
+ tokens?: Record<string, Color>;
319
+ }
320
+ /** A derived token, carrying the check that produced it. */
321
+ export interface DerivedToken {
322
+ /**
323
+ * True when chroma had to be reduced to bring the color into the sRGB gamut.
324
+ * Informational, not a failure: `value` is always in gamut.
325
+ */
326
+ chromaReduced: boolean;
327
+ /** Measured contrast against the pairing surface, where one is meaningful. */
328
+ contrast?: number;
329
+ hex: string;
330
+ name: string;
331
+ /**
332
+ * Where the value came from. `derived` was solved, `related` was rotated off
333
+ * another role, `pinned` was supplied by the consumer. It is what lets a
334
+ * reader of the emitted file tell a decision from a computation.
335
+ */
336
+ origin: 'derived' | 'pinned' | 'related';
337
+ /** Always inside the sRGB gamut. */
338
+ value: Oklch;
339
+ }
340
+ /** One measured pair, whatever the outcome. */
341
+ export interface Pairing {
342
+ background: string;
343
+ foreground: string;
344
+ /**
345
+ * Measured contrast. Absent when `status` is `unverifiable`, because there
346
+ * was no ground to measure against.
347
+ */
348
+ ratio?: number;
349
+ /**
350
+ * **`unverifiable` is not a softer `fail`.** It means the pair was never
351
+ * measured, which a reader has to be able to tell from a pair that was
352
+ * measured and passed. Folding the two together is how a guarantee decays
353
+ * into a claim about the pairs that happened to be easy.
354
+ *
355
+ * One producer today: a pairing naming a token this theme does not emit,
356
+ * which a caller supplying their own `pairings` will legitimately do.
357
+ * Translucent grounds are the second, when `material` lands: what sits
358
+ * behind a glass surface is whatever the reader scrolled under it.
359
+ */
360
+ status: 'fail' | 'pass' | 'unverifiable';
361
+ /**
362
+ * The ratio this pair had to reach. Recorded rather than inferred from the
363
+ * theme, because a focus ring answers to WCAG 1.4.11 at 3:1 while text
364
+ * answers to `contrastTarget`, and a reader of one row should not have to
365
+ * know which kind it is looking at.
366
+ */
367
+ target: number;
368
+ }
369
+ /**
370
+ * What a derivation produced.
371
+ *
372
+ * **No `intent`, which the TypeScript engine carried and nothing read.** It held
373
+ * the resolved intent so a consumer could see what they got rather than what
374
+ * they asked for, which is a real thing to want and was never used, never dumped
375
+ * by the oracle, and therefore never checked. Serialising it across the boundary
376
+ * means a `Serialize` on every shape in the vocabulary including three unions
377
+ * with hand-written readers, which is a day of work for a field with no caller.
378
+ * It goes back the moment one appears.
379
+ */
380
+ export interface DerivedTheme {
381
+ /**
382
+ * Roles too close to tell apart, measured as perceptual distance rather than
383
+ * contrast. Empty means every role is distinguishable.
384
+ *
385
+ * A different finding from `violations` and neither substitutes for the
386
+ * other: contrast is a token against a ground, a collision is two roles
387
+ * against each other.
388
+ */
389
+ collisions: string[];
390
+ /**
391
+ * The emitted block, keyed by the selector it was rendered under. Empty when
392
+ * none were asked for.
393
+ *
394
+ * **Rendered by the same call that derived it**, because a derivation is the
395
+ * expensive half and rendering is not: the theme builder wants one theme under
396
+ * two selectors, and asking twice would derive twice for one keystroke.
397
+ */
398
+ css: Record<string, string>;
399
+ /**
400
+ * Every pair the theme was checked against, whatever the outcome.
401
+ *
402
+ * Structured rather than a list of sentences, which is what lets a consumer
403
+ * program against it, lets a builder render it, and stops an unmeasured pair
404
+ * from being invisible to anybody who only reads `violations`.
405
+ */
406
+ pairings: Pairing[];
407
+ /**
408
+ * The seed values a consumer must write for `helix/styles` to re-derive the
409
+ * halves this engine does not compute: the twenty-four series colours, the
410
+ * spacing scale and the corner radii.
411
+ *
412
+ * **Data rather than only a line of CSS**, because the two consumers apply it
413
+ * differently. `toCss` renders a block; a page theming one panel writes the
414
+ * properties onto that element. Both read this, so neither can hold a
415
+ * different opinion about what the seed is, which is exactly what the
416
+ * marketing site's hand-written bridge had become.
417
+ */
418
+ seeds: Record<string, string>;
419
+ tokens: DerivedToken[];
420
+ /**
421
+ * Pairings that failed their contrast target, pinned values included.
422
+ * Rendered from `pairings`, and no longer the only record of a result.
423
+ */
424
+ violations: string[];
425
+ }
package/dist/types.js ADDED
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The shapes a consumer writes and the shapes they get back.
3
+ *
4
+ * **Declarations only, and that is what is left of the TypeScript engine.** The
5
+ * derivation is Rust now, reached through WebAssembly, so what a JavaScript
6
+ * consumer needs from this package is the vocabulary rather than the solver. A
7
+ * type is erased at build time, so nothing here is a second implementation of
8
+ * anything: it cannot drift into disagreeing with the engine about an *answer*,
9
+ * only about a *field name*, which is what `check:wasm` compares.
10
+ *
11
+ * The prose is kept whole from the files it came from. It is the argument for
12
+ * why each field exists, and it was never about how any of them was computed.
13
+ */
14
+ export {};
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@fusion.dev/helix-theme",
3
+ "description": "Theme derivation engine. Computes a complete, contrast-guaranteed token set from intent inputs.",
4
+ "version": "0.0.0-dev",
5
+ "license": "MIT",
6
+ "author": "Ross Sheppard <ross.sheppard@fusion.dev>",
7
+ "repository": {
8
+ "directory": "helix/theme",
9
+ "type": "git",
10
+ "url": "git+https://github.com/project-fusion/fusion.git"
11
+ },
12
+ "type": "module",
13
+ "main": "./dist/index.js",
14
+ "types": "./dist/index.d.ts",
15
+ "exports": {
16
+ ".": {
17
+ "types": "./dist/index.d.ts",
18
+ "default": "./dist/index.js"
19
+ }
20
+ },
21
+ "files": [
22
+ "dist",
23
+ "wasm"
24
+ ],
25
+ "sideEffects": false,
26
+ "scripts": {
27
+ "build": "tsc -p tsconfig.json",
28
+ "test": "cargo test -p fusion-helix-theme --manifest-path ../../Cargo.toml",
29
+ "typecheck": "tsc -p tsconfig.json --noEmit"
30
+ },
31
+ "bugs": "https://github.com/project-fusion/fusion/issues",
32
+ "homepage": "https://fusion.dev",
33
+ "keywords": [
34
+ "theming",
35
+ "design-tokens",
36
+ "color",
37
+ "oklch",
38
+ "contrast"
39
+ ],
40
+ "publishConfig": {
41
+ "access": "restricted"
42
+ }
43
+ }
@@ -0,0 +1,76 @@
1
+ /* tslint:disable */
2
+ /* eslint-disable */
3
+
4
+ /**
5
+ * Derives a theme, and renders it under every selector asked for.
6
+ *
7
+ * **One call, because a derivation is the expensive part and rendering is not.**
8
+ * Measured in this build: 17ms to derive and a fraction of a millisecond to
9
+ * render a block. The theme builder wants the same theme under two selectors,
10
+ * one for the preview and one for the block a reader copies, so an
11
+ * intent-in-CSS-out function would have it deriving twice for one keystroke.
12
+ * The result carries `css` keyed by selector, empty when none were asked for.
13
+ *
14
+ * For reference, the TypeScript engine this replaces took 39ms for the
15
+ * derivation alone, so the builder does less work than it did rather than more.
16
+ *
17
+ * `selectors` is a JSON array, keeping the boundary uniformly JSON in and JSON
18
+ * out rather than mixing in a bound array type for one argument.
19
+ *
20
+ * # Errors
21
+ *
22
+ * When either argument is not the JSON it should be, or when the engine refuses
23
+ * the intent.
24
+ */
25
+ export function derive(intent: string, selectors: string): string;
26
+
27
+ /**
28
+ * Reads a hex color into its OKLCH components, as JSON.
29
+ *
30
+ * **Throws where the TypeScript returned `NaN`**, which is a deliberate change
31
+ * and the same one `resolve` makes for a brand: `Number.parseInt` of something
32
+ * that is not hex gives `NaN`, every comparison against which is false, so a
33
+ * color that could not be read traveled onward looking like a color. A
34
+ * caller passing the value of a color input never sees this.
35
+ *
36
+ * # Errors
37
+ *
38
+ * When the string is not a hex color.
39
+ */
40
+ export function hexToOklch(hex: string): string;
41
+
42
+ export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module;
43
+
44
+ export interface InitOutput {
45
+ readonly memory: WebAssembly.Memory;
46
+ readonly derive: (a: number, b: number, c: number, d: number) => [number, number, number, number];
47
+ readonly hexToOklch: (a: number, b: number) => [number, number, number, number];
48
+ readonly __wbindgen_externrefs: WebAssembly.Table;
49
+ readonly __wbindgen_malloc: (a: number, b: number) => number;
50
+ readonly __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;
51
+ readonly __externref_table_dealloc: (a: number) => void;
52
+ readonly __wbindgen_free: (a: number, b: number, c: number) => void;
53
+ readonly __wbindgen_start: () => void;
54
+ }
55
+
56
+ export type SyncInitInput = BufferSource | WebAssembly.Module;
57
+
58
+ /**
59
+ * Instantiates the given `module`, which can either be bytes or
60
+ * a precompiled `WebAssembly.Module`.
61
+ *
62
+ * @param {{ module: SyncInitInput }} module - Passing `SyncInitInput` directly is deprecated.
63
+ *
64
+ * @returns {InitOutput}
65
+ */
66
+ export function initSync(module: { module: SyncInitInput } | SyncInitInput): InitOutput;
67
+
68
+ /**
69
+ * If `module_or_path` is {RequestInfo} or {URL}, makes a request and
70
+ * for everything else, calls `WebAssembly.instantiate` directly.
71
+ *
72
+ * @param {{ module_or_path: InitInput | Promise<InitInput> }} module_or_path - Passing `InitInput` directly is deprecated.
73
+ *
74
+ * @returns {Promise<InitOutput>}
75
+ */
76
+ export default function __wbg_init (module_or_path?: { module_or_path: InitInput | Promise<InitInput> } | InitInput | Promise<InitInput>): Promise<InitOutput>;
@@ -0,0 +1,283 @@
1
+ /* @ts-self-types="./fusion_helix_theme.d.ts" */
2
+
3
+ /**
4
+ * Derives a theme, and renders it under every selector asked for.
5
+ *
6
+ * **One call, because a derivation is the expensive part and rendering is not.**
7
+ * Measured in this build: 17ms to derive and a fraction of a millisecond to
8
+ * render a block. The theme builder wants the same theme under two selectors,
9
+ * one for the preview and one for the block a reader copies, so an
10
+ * intent-in-CSS-out function would have it deriving twice for one keystroke.
11
+ * The result carries `css` keyed by selector, empty when none were asked for.
12
+ *
13
+ * For reference, the TypeScript engine this replaces took 39ms for the
14
+ * derivation alone, so the builder does less work than it did rather than more.
15
+ *
16
+ * `selectors` is a JSON array, keeping the boundary uniformly JSON in and JSON
17
+ * out rather than mixing in a bound array type for one argument.
18
+ *
19
+ * # Errors
20
+ *
21
+ * When either argument is not the JSON it should be, or when the engine refuses
22
+ * the intent.
23
+ * @param {string} intent
24
+ * @param {string} selectors
25
+ * @returns {string}
26
+ */
27
+ export function derive(intent, selectors) {
28
+ let deferred4_0;
29
+ let deferred4_1;
30
+ try {
31
+ const ptr0 = passStringToWasm0(intent, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
32
+ const len0 = WASM_VECTOR_LEN;
33
+ const ptr1 = passStringToWasm0(selectors, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
34
+ const len1 = WASM_VECTOR_LEN;
35
+ const ret = wasm.derive(ptr0, len0, ptr1, len1);
36
+ var ptr3 = ret[0];
37
+ var len3 = ret[1];
38
+ if (ret[3]) {
39
+ ptr3 = 0; len3 = 0;
40
+ throw takeFromExternrefTable0(ret[2]);
41
+ }
42
+ deferred4_0 = ptr3;
43
+ deferred4_1 = len3;
44
+ return getStringFromWasm0(ptr3, len3);
45
+ } finally {
46
+ wasm.__wbindgen_free(deferred4_0, deferred4_1, 1);
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Reads a hex color into its OKLCH components, as JSON.
52
+ *
53
+ * **Throws where the TypeScript returned `NaN`**, which is a deliberate change
54
+ * and the same one `resolve` makes for a brand: `Number.parseInt` of something
55
+ * that is not hex gives `NaN`, every comparison against which is false, so a
56
+ * color that could not be read traveled onward looking like a color. A
57
+ * caller passing the value of a color input never sees this.
58
+ *
59
+ * # Errors
60
+ *
61
+ * When the string is not a hex color.
62
+ * @param {string} hex
63
+ * @returns {string}
64
+ */
65
+ export function hexToOklch(hex) {
66
+ let deferred3_0;
67
+ let deferred3_1;
68
+ try {
69
+ const ptr0 = passStringToWasm0(hex, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
70
+ const len0 = WASM_VECTOR_LEN;
71
+ const ret = wasm.hexToOklch(ptr0, len0);
72
+ var ptr2 = ret[0];
73
+ var len2 = ret[1];
74
+ if (ret[3]) {
75
+ ptr2 = 0; len2 = 0;
76
+ throw takeFromExternrefTable0(ret[2]);
77
+ }
78
+ deferred3_0 = ptr2;
79
+ deferred3_1 = len2;
80
+ return getStringFromWasm0(ptr2, len2);
81
+ } finally {
82
+ wasm.__wbindgen_free(deferred3_0, deferred3_1, 1);
83
+ }
84
+ }
85
+ function __wbg_get_imports() {
86
+ const import0 = {
87
+ __proto__: null,
88
+ __wbg_Error_408e67f47ca7b58b: function(arg0, arg1) {
89
+ const ret = Error(getStringFromWasm0(arg0, arg1));
90
+ return ret;
91
+ },
92
+ __wbindgen_init_externref_table: function() {
93
+ const table = wasm.__wbindgen_externrefs;
94
+ const offset = table.grow(4);
95
+ table.set(0, undefined);
96
+ table.set(offset + 0, undefined);
97
+ table.set(offset + 1, null);
98
+ table.set(offset + 2, true);
99
+ table.set(offset + 3, false);
100
+ },
101
+ };
102
+ return {
103
+ __proto__: null,
104
+ "./fusion_helix_theme_bg.js": import0,
105
+ };
106
+ }
107
+
108
+ function getStringFromWasm0(ptr, len) {
109
+ return decodeText(ptr >>> 0, len);
110
+ }
111
+
112
+ let cachedUint8ArrayMemory0 = null;
113
+ function getUint8ArrayMemory0() {
114
+ if (cachedUint8ArrayMemory0 === null || cachedUint8ArrayMemory0.byteLength === 0) {
115
+ cachedUint8ArrayMemory0 = new Uint8Array(wasm.memory.buffer);
116
+ }
117
+ return cachedUint8ArrayMemory0;
118
+ }
119
+
120
+ function passStringToWasm0(arg, malloc, realloc) {
121
+ if (realloc === undefined) {
122
+ const buf = cachedTextEncoder.encode(arg);
123
+ const ptr = malloc(buf.length, 1) >>> 0;
124
+ getUint8ArrayMemory0().subarray(ptr, ptr + buf.length).set(buf);
125
+ WASM_VECTOR_LEN = buf.length;
126
+ return ptr;
127
+ }
128
+
129
+ let len = arg.length;
130
+ let ptr = malloc(len, 1) >>> 0;
131
+
132
+ const mem = getUint8ArrayMemory0();
133
+
134
+ let offset = 0;
135
+
136
+ for (; offset < len; offset++) {
137
+ const code = arg.charCodeAt(offset);
138
+ if (code > 0x7F) break;
139
+ mem[ptr + offset] = code;
140
+ }
141
+ if (offset !== len) {
142
+ if (offset !== 0) {
143
+ arg = arg.slice(offset);
144
+ }
145
+ ptr = realloc(ptr, len, len = offset + arg.length * 3, 1) >>> 0;
146
+ const view = getUint8ArrayMemory0().subarray(ptr + offset, ptr + len);
147
+ const ret = cachedTextEncoder.encodeInto(arg, view);
148
+
149
+ offset += ret.written;
150
+ ptr = realloc(ptr, len, offset, 1) >>> 0;
151
+ }
152
+
153
+ WASM_VECTOR_LEN = offset;
154
+ return ptr;
155
+ }
156
+
157
+ function takeFromExternrefTable0(idx) {
158
+ const value = wasm.__wbindgen_externrefs.get(idx);
159
+ wasm.__externref_table_dealloc(idx);
160
+ return value;
161
+ }
162
+
163
+ let cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true });
164
+ cachedTextDecoder.decode();
165
+ const MAX_SAFARI_DECODE_BYTES = 2146435072;
166
+ let numBytesDecoded = 0;
167
+ function decodeText(ptr, len) {
168
+ numBytesDecoded += len;
169
+ if (numBytesDecoded >= MAX_SAFARI_DECODE_BYTES) {
170
+ cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true });
171
+ cachedTextDecoder.decode();
172
+ numBytesDecoded = len;
173
+ }
174
+ return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len));
175
+ }
176
+
177
+ const cachedTextEncoder = new TextEncoder();
178
+
179
+ if (!('encodeInto' in cachedTextEncoder)) {
180
+ cachedTextEncoder.encodeInto = function (arg, view) {
181
+ const buf = cachedTextEncoder.encode(arg);
182
+ view.set(buf);
183
+ return {
184
+ read: arg.length,
185
+ written: buf.length
186
+ };
187
+ };
188
+ }
189
+
190
+ let WASM_VECTOR_LEN = 0;
191
+
192
+ let wasmModule, wasmInstance, wasm;
193
+ function __wbg_finalize_init(instance, module) {
194
+ wasmInstance = instance;
195
+ wasm = instance.exports;
196
+ wasmModule = module;
197
+ cachedUint8ArrayMemory0 = null;
198
+ wasm.__wbindgen_start();
199
+ return wasm;
200
+ }
201
+
202
+ async function __wbg_load(module, imports) {
203
+ if (typeof Response === 'function' && module instanceof Response) {
204
+ if (typeof WebAssembly.instantiateStreaming === 'function') {
205
+ try {
206
+ return await WebAssembly.instantiateStreaming(module, imports);
207
+ } catch (e) {
208
+ const validResponse = module.ok && expectedResponseType(module.type);
209
+
210
+ if (validResponse && module.headers.get('Content-Type') !== 'application/wasm') {
211
+ 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);
212
+
213
+ } else { throw e; }
214
+ }
215
+ }
216
+
217
+ const bytes = await module.arrayBuffer();
218
+ return await WebAssembly.instantiate(bytes, imports);
219
+ } else {
220
+ const instance = await WebAssembly.instantiate(module, imports);
221
+
222
+ if (instance instanceof WebAssembly.Instance) {
223
+ return { instance, module };
224
+ } else {
225
+ return instance;
226
+ }
227
+ }
228
+
229
+ function expectedResponseType(type) {
230
+ switch (type) {
231
+ case 'basic': case 'cors': case 'default': return true;
232
+ }
233
+ return false;
234
+ }
235
+ }
236
+
237
+ function initSync(module) {
238
+ if (wasm !== undefined) return wasm;
239
+
240
+
241
+ if (module !== undefined) {
242
+ if (Object.getPrototypeOf(module) === Object.prototype) {
243
+ ({module} = module)
244
+ } else {
245
+ console.warn('using deprecated parameters for `initSync()`; pass a single object instead')
246
+ }
247
+ }
248
+
249
+ const imports = __wbg_get_imports();
250
+ if (!(module instanceof WebAssembly.Module)) {
251
+ module = new WebAssembly.Module(module);
252
+ }
253
+ const instance = new WebAssembly.Instance(module, imports);
254
+ return __wbg_finalize_init(instance, module);
255
+ }
256
+
257
+ async function __wbg_init(module_or_path) {
258
+ if (wasm !== undefined) return wasm;
259
+
260
+
261
+ if (module_or_path !== undefined) {
262
+ if (Object.getPrototypeOf(module_or_path) === Object.prototype) {
263
+ ({module_or_path} = module_or_path)
264
+ } else {
265
+ console.warn('using deprecated parameters for the initialization function; pass a single object instead')
266
+ }
267
+ }
268
+
269
+ if (module_or_path === undefined) {
270
+ module_or_path = new URL('fusion_helix_theme_bg.wasm', import.meta.url);
271
+ }
272
+ const imports = __wbg_get_imports();
273
+
274
+ if (typeof module_or_path === 'string' || (typeof Request === 'function' && module_or_path instanceof Request) || (typeof URL === 'function' && module_or_path instanceof URL)) {
275
+ module_or_path = fetch(module_or_path);
276
+ }
277
+
278
+ const { instance, module } = await __wbg_load(await module_or_path, imports);
279
+
280
+ return __wbg_finalize_init(instance, module);
281
+ }
282
+
283
+ export { initSync, __wbg_init as default };
Binary file
@@ -0,0 +1,11 @@
1
+ /* tslint:disable */
2
+ /* eslint-disable */
3
+ export const memory: WebAssembly.Memory;
4
+ export const derive: (a: number, b: number, c: number, d: number) => [number, number, number, number];
5
+ export const hexToOklch: (a: number, b: number) => [number, number, number, number];
6
+ export const __wbindgen_externrefs: WebAssembly.Table;
7
+ export const __wbindgen_malloc: (a: number, b: number) => number;
8
+ export const __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;
9
+ export const __externref_table_dealloc: (a: number) => void;
10
+ export const __wbindgen_free: (a: number, b: number, c: number) => void;
11
+ export const __wbindgen_start: () => void;