@crossworks/share-ui 0.232.65 → 0.232.67
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/package.json +7 -3
- package/src/appearance.ts +12 -0
- package/src/neat-background.test.ts +58 -0
- package/src/neat-background.ts +249 -0
- package/src/neat-mount.ts +82 -0
- package/src/share-mode.ts +12 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crossworks/share-ui",
|
|
3
|
-
"version": "0.232.
|
|
3
|
+
"version": "0.232.67",
|
|
4
4
|
"description": "The server-rendered share surface — the /s/<token> presenters, view-payload contract, mini-app sandbox, and the few primitives they need. Lives in MANTLE (the server renders these), published for jackdaw to consume; may depend only on @mantle/{client-types,content-core} (the jackdaw-repo-split boundary).",
|
|
5
5
|
"exports": {
|
|
6
6
|
"./app-presenter": "./src/app-presenter.tsx",
|
|
@@ -25,6 +25,9 @@
|
|
|
25
25
|
"./nav-items": "./src/nav-items.ts",
|
|
26
26
|
"./help-topics": "./src/help-topics.ts",
|
|
27
27
|
"./appearance": "./src/appearance.ts",
|
|
28
|
+
"./neat-background": "./src/neat-background.ts",
|
|
29
|
+
"./neat-mount": "./src/neat-mount.ts",
|
|
30
|
+
"./share-mode": "./src/share-mode.ts",
|
|
28
31
|
"./backgrounds": "./src/backgrounds.ts",
|
|
29
32
|
"./avatar": "./src/avatar.ts",
|
|
30
33
|
"./lib/themes": "./src/lib/themes.ts",
|
|
@@ -32,8 +35,9 @@
|
|
|
32
35
|
"./styles/app.css": "./styles/app.css"
|
|
33
36
|
},
|
|
34
37
|
"dependencies": {
|
|
35
|
-
"@
|
|
36
|
-
"@mantle/
|
|
38
|
+
"@firecms/neat": "1.0.2",
|
|
39
|
+
"@mantle/client-types": "npm:@crossworks/client-types@0.232.67",
|
|
40
|
+
"@mantle/content-core": "npm:@crossworks/content-core@0.232.67",
|
|
37
41
|
"@radix-ui/react-label": "^2.1.12",
|
|
38
42
|
"@radix-ui/react-slot": "^1.3.0",
|
|
39
43
|
"class-variance-authority": "^0.7.1",
|
package/src/appearance.ts
CHANGED
|
@@ -61,6 +61,18 @@ export type BrainAppearance = {
|
|
|
61
61
|
* Optional for the published-contract reason below: readers treat absence
|
|
62
62
|
* as "not set" and paint the plain themed fill. */
|
|
63
63
|
neatBackground?: string | null;
|
|
64
|
+
/** The brain's default light/dark mode for surfaces where the visitor has
|
|
65
|
+
* not chosen one (the public /s share reader): 'light' | 'dark' | 'system'.
|
|
66
|
+
* NOT stamped onto `<html>` by resolveAppearanceAttrs — the share renderer
|
|
67
|
+
* applies it as the `.dark` class itself, and the client app ignores it
|
|
68
|
+
* (in-app mode stays the visitor's own next-themes choice). Optional for
|
|
69
|
+
* the published-contract reason below; absence reads as 'light', the share
|
|
70
|
+
* surface's historical rendering. */
|
|
71
|
+
defaultMode?: string | null;
|
|
72
|
+
/** Whether shared surfaces (/s, the team workspace) paint the saved Neat
|
|
73
|
+
* gradient at all — false is the printable plain fallback. NOT stamped onto
|
|
74
|
+
* `<html>`; readers treat absence as true (the default). */
|
|
75
|
+
shareNeat?: boolean | null;
|
|
64
76
|
/**
|
|
65
77
|
* WHO this brain is — its own name, the name of the box, and whether an
|
|
66
78
|
* uploaded logo exists. Unlike everything above these are NOT stamped onto
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { decodeNeatSpec, encodeNeatSpec, neatConfigFromSpec } from './neat-background';
|
|
3
|
+
|
|
4
|
+
const SPEC = { v: 1 as const, seed: 2325271021, tone: 'auto' as const, speed: 2 };
|
|
5
|
+
|
|
6
|
+
describe('decodeNeatSpec', () => {
|
|
7
|
+
it('round-trips the canonical encoding', () => {
|
|
8
|
+
expect(decodeNeatSpec(encodeNeatSpec(SPEC))).toEqual(SPEC);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('rejects garbage as null, never a throw', () => {
|
|
12
|
+
for (const bad of [null, '', '{}', '{"v":2}', 'not json', '{"v":1,"seed":-1}']) {
|
|
13
|
+
expect(decodeNeatSpec(bad)).toBeNull();
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe('neatConfigFromSpec — the wash', () => {
|
|
19
|
+
const long = {
|
|
20
|
+
background: '#ffffff',
|
|
21
|
+
primary: '#b85c23',
|
|
22
|
+
accent: '#eeeeee',
|
|
23
|
+
secondary: '#527575',
|
|
24
|
+
};
|
|
25
|
+
// What getComputedStyle actually returns on a deployed build: the compiled
|
|
26
|
+
// stylesheet minifies hex custom properties to shorthand where possible.
|
|
27
|
+
const short = { background: '#fff', primary: '#b85c23', accent: '#eee', secondary: '#527575' };
|
|
28
|
+
|
|
29
|
+
it('produces the IDENTICAL config for shorthand #rgb and #rrggbb tokens', () => {
|
|
30
|
+
// Whole-config equality on purpose: it pins the wash (a failed shorthand
|
|
31
|
+
// parse degraded stops to the raw brand colour) AND the raw pass-throughs
|
|
32
|
+
// (colors[0]/[4] and backgroundColor once carried '#fff' verbatim, which
|
|
33
|
+
// Neat's parseInt-based parser reads as 0x000fff — electric blue).
|
|
34
|
+
expect(neatConfigFromSpec(SPEC, short, 'light')).toEqual(
|
|
35
|
+
neatConfigFromSpec(SPEC, long, 'light'),
|
|
36
|
+
);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('hands the shader only canonical six-digit hex, never shorthand', () => {
|
|
40
|
+
const c = neatConfigFromSpec(SPEC, short, 'light');
|
|
41
|
+
for (const stop of c.colors) expect(stop.color).toMatch(/^#[0-9a-f]{6}$/);
|
|
42
|
+
expect(c.backgroundColor).toBe('#ffffff');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('actually washes the brand colour toward the ground', () => {
|
|
46
|
+
const washed = neatConfigFromSpec(SPEC, short, 'light').colors[1]?.color ?? '';
|
|
47
|
+
// Raw primary (or its 1.08 shade, #c66c39-ish) means the mix silently
|
|
48
|
+
// failed; a washed stop is much lighter than the brand colour.
|
|
49
|
+
expect(washed).not.toBe('#b85c23');
|
|
50
|
+
const mean =
|
|
51
|
+
[1, 3, 5].map((i) => parseInt(washed.slice(i, i + 2), 16)).reduce((s, n) => s + n, 0) / 3;
|
|
52
|
+
expect(mean).toBeGreaterThan(150);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('is deterministic — same seed, same config', () => {
|
|
56
|
+
expect(neatConfigFromSpec(SPEC, long, 'dark')).toEqual(neatConfigFromSpec(SPEC, long, 'dark'));
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import type { NeatConfig } from '@firecms/neat';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The Neat background — an animated WebGL gradient behind a whole surface,
|
|
5
|
+
* generated in Settings → Appearance and stored on the brain's preferences
|
|
6
|
+
* row. This module is the spec contract and the parameter derivation; it lives
|
|
7
|
+
* HERE (published as @crossworks/share-ui) because two renderers consume it —
|
|
8
|
+
* the jackdaw owner app (login screen, content area) and this repo's
|
|
9
|
+
* server-rendered /s share surface — and the two must never disagree about
|
|
10
|
+
* what a saved spec looks like.
|
|
11
|
+
*
|
|
12
|
+
* We store a SPEC, never colours: `{ v, seed, tone, speed }`. Every colour is
|
|
13
|
+
* derived from the LIVE theme tokens at paint time, so one saved background
|
|
14
|
+
* follows all ~40 colour themes and light/dark mode by construction — a
|
|
15
|
+
* stored hex would break silently on most themes, exactly like a hardcoded
|
|
16
|
+
* class would. The seed drives a deterministic PRNG for every other Neat
|
|
17
|
+
* parameter, so "the background I saved" is reproducible from four numbers,
|
|
18
|
+
* and "Generate" is just a re-roll of the seed.
|
|
19
|
+
*
|
|
20
|
+
* The derived colours are washes: each brand colour is pulled most of the way
|
|
21
|
+
* into `--background` before it reaches the shader. That is the legibility
|
|
22
|
+
* guarantee — the gradient reads as a tint of the page surface, never a
|
|
23
|
+
* poster fighting the text sitting on it.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
export type NeatTone = 'auto' | 'darker' | 'lighter';
|
|
27
|
+
|
|
28
|
+
export type NeatBackgroundSpec = {
|
|
29
|
+
v: 1;
|
|
30
|
+
/** Seeds the PRNG that derives every Neat parameter. */
|
|
31
|
+
seed: number;
|
|
32
|
+
/** Push the wash below the page surface ('darker'), above it ('lighter'),
|
|
33
|
+
* or follow the mode — dark mode darkens, light mode lightens ('auto'). */
|
|
34
|
+
tone: NeatTone;
|
|
35
|
+
/** Animation speed, 0 (still) to {@link NEAT_SPEED_MAX}. */
|
|
36
|
+
speed: number;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export const NEAT_SPEED_MAX = 6;
|
|
40
|
+
export const NEAT_DEFAULT_SPEED = 2;
|
|
41
|
+
|
|
42
|
+
/** Storage cap, mirrored by the server route — the canonical encoding of a
|
|
43
|
+
* valid spec is ~60 chars, so this is a garbage guard, not a limit. */
|
|
44
|
+
export const NEAT_BACKGROUND_MAX = 200;
|
|
45
|
+
|
|
46
|
+
const TONES: readonly NeatTone[] = ['auto', 'darker', 'lighter'];
|
|
47
|
+
|
|
48
|
+
/** Canonical wire encoding — fixed key order so equal specs compare equal as
|
|
49
|
+
* strings (the UI's dirty check and the renderer's effect key rely on it). */
|
|
50
|
+
export function encodeNeatSpec(spec: NeatBackgroundSpec): string {
|
|
51
|
+
return JSON.stringify({ v: 1, seed: spec.seed, tone: spec.tone, speed: spec.speed });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Parse + shape-check a stored value. Garbage ⇒ null, never a throw — the
|
|
55
|
+
* same lenient contract every appearance preference follows on read. */
|
|
56
|
+
export function decodeNeatSpec(raw: unknown): NeatBackgroundSpec | null {
|
|
57
|
+
if (typeof raw !== 'string' || raw.length === 0 || raw.length > NEAT_BACKGROUND_MAX) return null;
|
|
58
|
+
let parsed: unknown;
|
|
59
|
+
try {
|
|
60
|
+
parsed = JSON.parse(raw);
|
|
61
|
+
} catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
if (!parsed || typeof parsed !== 'object') return null;
|
|
65
|
+
const o = parsed as Record<string, unknown>;
|
|
66
|
+
if (o.v !== 1) return null;
|
|
67
|
+
if (typeof o.seed !== 'number' || !Number.isInteger(o.seed) || o.seed < 0 || o.seed > 0xffffffff)
|
|
68
|
+
return null;
|
|
69
|
+
if (typeof o.tone !== 'string' || !(TONES as readonly string[]).includes(o.tone)) return null;
|
|
70
|
+
if (typeof o.speed !== 'number' || !Number.isFinite(o.speed) || o.speed < 0) return null;
|
|
71
|
+
return {
|
|
72
|
+
v: 1,
|
|
73
|
+
seed: o.seed,
|
|
74
|
+
tone: o.tone as NeatTone,
|
|
75
|
+
speed: Math.min(o.speed, NEAT_SPEED_MAX),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function randomNeatSpec(
|
|
80
|
+
prev?: Pick<NeatBackgroundSpec, 'tone' | 'speed'>,
|
|
81
|
+
): NeatBackgroundSpec {
|
|
82
|
+
return {
|
|
83
|
+
v: 1,
|
|
84
|
+
seed: Math.floor(Math.random() * 0xffffffff),
|
|
85
|
+
tone: prev?.tone ?? 'auto',
|
|
86
|
+
speed: prev?.speed ?? NEAT_DEFAULT_SPEED,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The tokens the shader needs, resolved to literal hex — WebGL cannot read
|
|
91
|
+
* `var()`, same constraint as the DiceBear ramp in theme-ramp.ts. */
|
|
92
|
+
export type NeatThemeTokens = {
|
|
93
|
+
background: string;
|
|
94
|
+
primary: string;
|
|
95
|
+
accent: string;
|
|
96
|
+
secondary: string;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/** Deterministic PRNG (mulberry32) — same seed, same background, forever. */
|
|
100
|
+
function mulberry32(seed: number): () => number {
|
|
101
|
+
let a = seed >>> 0;
|
|
102
|
+
return () => {
|
|
103
|
+
a |= 0;
|
|
104
|
+
a = (a + 0x6d2b79f5) | 0;
|
|
105
|
+
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
|
106
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
107
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function hexToRgb(hex: string): [number, number, number] | null {
|
|
112
|
+
const t = hex.trim();
|
|
113
|
+
// Shorthand #rgb MUST parse: the compiled stylesheets minify hex custom
|
|
114
|
+
// properties (#ffffff → #fff, #eeeeee → #eee), so this is what
|
|
115
|
+
// getComputedStyle actually returns on deployed builds. Rejecting it made
|
|
116
|
+
// mixHex silently return the RAW brand colour — no wash, a saturated
|
|
117
|
+
// poster instead of a tint — on exactly the themes whose tokens shorten,
|
|
118
|
+
// and only in production (dev CSS is unminified). Light modes were hit
|
|
119
|
+
// hardest because near-white grounds (#ffffff/#eeeeee) all shorten.
|
|
120
|
+
const short = /^#?([0-9a-f]{3})$/i.exec(t)?.[1];
|
|
121
|
+
if (short) {
|
|
122
|
+
const c = (i: number) => parseInt(short.charAt(i) + short.charAt(i), 16);
|
|
123
|
+
return [c(0), c(1), c(2)];
|
|
124
|
+
}
|
|
125
|
+
const digits = /^#?([0-9a-f]{6})$/i.exec(t)?.[1];
|
|
126
|
+
if (!digits) return null;
|
|
127
|
+
const n = parseInt(digits, 16);
|
|
128
|
+
return [(n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function rgbToHex([r, g, b]: [number, number, number]): string {
|
|
132
|
+
const c = (v: number) =>
|
|
133
|
+
Math.max(0, Math.min(255, Math.round(v)))
|
|
134
|
+
.toString(16)
|
|
135
|
+
.padStart(2, '0');
|
|
136
|
+
return `#${c(r)}${c(g)}${c(b)}`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** `amount` of `into` mixed over `from` — 0 keeps `from`, 1 lands on `into`. */
|
|
140
|
+
function mixHex(from: string, into: string, amount: number): string {
|
|
141
|
+
const a = hexToRgb(from);
|
|
142
|
+
const b = hexToRgb(into);
|
|
143
|
+
if (!a || !b) return from;
|
|
144
|
+
return rgbToHex([
|
|
145
|
+
a[0] + (b[0] - a[0]) * amount,
|
|
146
|
+
a[1] + (b[1] - a[1]) * amount,
|
|
147
|
+
a[2] + (b[2] - a[2]) * amount,
|
|
148
|
+
]);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Nudge toward black (factor < 1) or white (factor > 1). */
|
|
152
|
+
function shadeHex(hex: string, factor: number): string {
|
|
153
|
+
const rgb = hexToRgb(hex);
|
|
154
|
+
if (!rgb) return hex;
|
|
155
|
+
if (factor <= 1) return rgbToHex([rgb[0] * factor, rgb[1] * factor, rgb[2] * factor]);
|
|
156
|
+
const up = factor - 1;
|
|
157
|
+
return rgbToHex([
|
|
158
|
+
rgb[0] + (255 - rgb[0]) * up,
|
|
159
|
+
rgb[1] + (255 - rgb[1]) * up,
|
|
160
|
+
rgb[2] + (255 - rgb[2]) * up,
|
|
161
|
+
]);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Canonical #rrggbb for any hex this module can parse; unparseable values
|
|
165
|
+
* pass through untouched. EVERY colour handed to the shader goes through
|
|
166
|
+
* this: Neat's own parser is `parseInt(hex, 16)` on whatever it gets, so a
|
|
167
|
+
* shorthand `#fff` reads as the 24-bit int 0x000fff — rgb(0, 15, 255), an
|
|
168
|
+
* electric blue poster where the page ground should be. That is precisely
|
|
169
|
+
* what deployed light themes produced, because minified stylesheets shorten
|
|
170
|
+
* near-white tokens to #fff/#eee. */
|
|
171
|
+
function normalizeHex(hex: string): string {
|
|
172
|
+
const rgb = hexToRgb(hex);
|
|
173
|
+
return rgb ? rgbToHex(rgb) : hex;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const round1 = (n: number) => Math.round(n * 10) / 10;
|
|
177
|
+
const round2 = (n: number) => Math.round(n * 100) / 100;
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Spec + live tokens + mode → the full Neat config (minus `ref`/`resolution`,
|
|
181
|
+
* which are the renderer's business). Pure and deterministic, so the settings
|
|
182
|
+
* preview and the real surface can never disagree about what a saved spec
|
|
183
|
+
* looks like.
|
|
184
|
+
*/
|
|
185
|
+
export function neatConfigFromSpec(
|
|
186
|
+
spec: NeatBackgroundSpec,
|
|
187
|
+
tokens: NeatThemeTokens,
|
|
188
|
+
mode: 'light' | 'dark',
|
|
189
|
+
): NeatConfig {
|
|
190
|
+
const rnd = mulberry32(spec.seed);
|
|
191
|
+
const range = (lo: number, hi: number) => lo + rnd() * (hi - lo);
|
|
192
|
+
const darker = spec.tone === 'darker' || (spec.tone === 'auto' && mode === 'dark');
|
|
193
|
+
|
|
194
|
+
// Tokens arrive as whatever getComputedStyle returns — on deployed builds
|
|
195
|
+
// that includes minifier shorthand. Normalized ONCE here, so both our wash
|
|
196
|
+
// math and the raw pass-throughs below hand the shader canonical hex.
|
|
197
|
+
const background = normalizeHex(tokens.background);
|
|
198
|
+
const primary = normalizeHex(tokens.primary);
|
|
199
|
+
const accent = normalizeHex(tokens.accent);
|
|
200
|
+
const secondary = normalizeHex(tokens.secondary);
|
|
201
|
+
|
|
202
|
+
// A wash: pull the brand colour part-way into the page background, then
|
|
203
|
+
// nudge the result off the surface in the chosen direction. Enough colour
|
|
204
|
+
// to read as a real gradient, close enough to the surface that content
|
|
205
|
+
// sitting on it never fights it.
|
|
206
|
+
const wash = (hex: string, towardBg: number) =>
|
|
207
|
+
shadeHex(mixHex(hex, background, towardBg), darker ? 0.88 : 1.08);
|
|
208
|
+
|
|
209
|
+
return {
|
|
210
|
+
colors: [
|
|
211
|
+
{ color: background, enabled: true, influence: round2(range(0.3, 0.6)) },
|
|
212
|
+
{
|
|
213
|
+
color: wash(primary, round2(range(0.25, 0.5))),
|
|
214
|
+
enabled: true,
|
|
215
|
+
influence: round2(range(0.5, 0.9)),
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
color: wash(accent, round2(range(0.25, 0.55))),
|
|
219
|
+
enabled: true,
|
|
220
|
+
influence: round2(range(0.45, 0.85)),
|
|
221
|
+
},
|
|
222
|
+
{
|
|
223
|
+
color: wash(secondary, round2(range(0.35, 0.6))),
|
|
224
|
+
enabled: true,
|
|
225
|
+
influence: round2(range(0.35, 0.75)),
|
|
226
|
+
},
|
|
227
|
+
{ color: background, enabled: true, influence: round2(range(0.25, 0.55)) },
|
|
228
|
+
],
|
|
229
|
+
speed: spec.speed,
|
|
230
|
+
horizontalPressure: round1(range(2, 5)),
|
|
231
|
+
verticalPressure: round1(range(2, 5)),
|
|
232
|
+
waveFrequencyX: round1(range(2, 6)),
|
|
233
|
+
waveFrequencyY: round1(range(2, 6)),
|
|
234
|
+
waveAmplitude: round1(range(3, 7)),
|
|
235
|
+
// Keep the shader's own light play gentle: strong shadows/highlights are
|
|
236
|
+
// exactly what would carve text-hostile contrast into the surface.
|
|
237
|
+
shadows: round1(range(0, darker ? 3 : 1.5)),
|
|
238
|
+
highlights: round1(range(0, darker ? 1.5 : 3)),
|
|
239
|
+
colorBrightness: darker ? 0.95 : 1,
|
|
240
|
+
colorSaturation: round1(range(-1, 2.5)),
|
|
241
|
+
colorBlending: round1(range(5, 9)),
|
|
242
|
+
grainScale: 2,
|
|
243
|
+
grainIntensity: round2(range(0.02, 0.1)),
|
|
244
|
+
grainSpeed: 0.3,
|
|
245
|
+
wireframe: false,
|
|
246
|
+
backgroundColor: background,
|
|
247
|
+
backgroundAlpha: 1,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import {
|
|
2
|
+
neatConfigFromSpec,
|
|
3
|
+
type NeatBackgroundSpec,
|
|
4
|
+
type NeatThemeTokens,
|
|
5
|
+
} from './neat-background';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The one true way to put a Neat spec onto a canvas — shared by every
|
|
9
|
+
* renderer (the /s reader's vanilla runtime here in mantle; jackdaw's React
|
|
10
|
+
* NeatBackdrop wraps it too), so the hard-won WebGL specifics live exactly
|
|
11
|
+
* once:
|
|
12
|
+
*
|
|
13
|
+
* - `@firecms/neat` is dynamically imported HERE, so every consumer's
|
|
14
|
+
* bundler splits the ~80KB WebGL chunk out and only fetches it on
|
|
15
|
+
* surfaces that actually paint a gradient.
|
|
16
|
+
* - Colours are read off the live document per call — shaders take
|
|
17
|
+
* literals, not `var()` — and an unresolvable theme mounts NOTHING
|
|
18
|
+
* rather than a wrong guess; the themed surface underneath is the
|
|
19
|
+
* designed fallback.
|
|
20
|
+
* - Neat's `seed` is NOT a randomness seed: it is the animation clock's
|
|
21
|
+
* starting value (u_time, fp32 on the GPU). A raw 32-bit seed exceeds
|
|
22
|
+
* fp32 precision and collapses the shader's small offsets into ONE FLAT
|
|
23
|
+
* COLOUR. It is modded into the library's own clock range here; the full
|
|
24
|
+
* seed still drives the parameter PRNG.
|
|
25
|
+
* - `prefers-reduced-motion` freezes the animation (speed 0); the wash
|
|
26
|
+
* still paints.
|
|
27
|
+
*
|
|
28
|
+
* TIMING is the caller's job: mount only after the document's theme
|
|
29
|
+
* class/attributes have settled (in practice, from a requestAnimationFrame
|
|
30
|
+
* callback), or the tokens read here belong to the OUTGOING theme. So is
|
|
31
|
+
* CANCELLATION: this resolves after an await, so a caller that has moved on
|
|
32
|
+
* must destroy the returned handle instead of keeping it.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
export type NeatMountHandle = { destroy: () => void };
|
|
36
|
+
|
|
37
|
+
/** The four tokens the shader derives every colour from, resolved to literal
|
|
38
|
+
* values off the live document. Null when the theme is unresolvable (a test
|
|
39
|
+
* DOM, a broken stylesheet) — paint nothing rather than a wrong guess. */
|
|
40
|
+
export function readNeatThemeTokens(): NeatThemeTokens | null {
|
|
41
|
+
const cs = getComputedStyle(document.documentElement);
|
|
42
|
+
const read = (name: string) => cs.getPropertyValue(name).trim();
|
|
43
|
+
const tokens = {
|
|
44
|
+
background: read('--background'),
|
|
45
|
+
primary: read('--primary'),
|
|
46
|
+
accent: read('--accent'),
|
|
47
|
+
secondary: read('--secondary'),
|
|
48
|
+
};
|
|
49
|
+
return tokens.background && tokens.primary ? tokens : null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Build the gradient onto `canvas`. Resolves to a handle to destroy, or null
|
|
54
|
+
* when nothing mounted (unresolvable theme, WebGL unavailable) — null is the
|
|
55
|
+
* designed fallback, never an error.
|
|
56
|
+
*/
|
|
57
|
+
export async function mountNeat(
|
|
58
|
+
canvas: HTMLCanvasElement,
|
|
59
|
+
spec: NeatBackgroundSpec,
|
|
60
|
+
mode: 'light' | 'dark',
|
|
61
|
+
opts: { resolution?: number; licenseKey?: string } = {},
|
|
62
|
+
): Promise<NeatMountHandle | null> {
|
|
63
|
+
const { NeatGradient } = await import('@firecms/neat');
|
|
64
|
+
const tokens = readNeatThemeTokens();
|
|
65
|
+
if (!tokens) return null;
|
|
66
|
+
|
|
67
|
+
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
68
|
+
const config = neatConfigFromSpec(spec, tokens, mode);
|
|
69
|
+
try {
|
|
70
|
+
return new NeatGradient({
|
|
71
|
+
...config,
|
|
72
|
+
ref: canvas,
|
|
73
|
+
seed: spec.seed % 3600,
|
|
74
|
+
resolution: opts.resolution ?? 1,
|
|
75
|
+
speed: reduced ? 0 : config.speed,
|
|
76
|
+
...(opts.licenseKey ? { licenseKey: opts.licenseKey } : {}),
|
|
77
|
+
});
|
|
78
|
+
} catch {
|
|
79
|
+
// WebGL unavailable (headless browser, exhausted contexts).
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The /s reader's visitor mode contract — deliberately its own tiny module:
|
|
3
|
+
* the share-page browser runtime imports it, and routing it through
|
|
4
|
+
* appearance.ts would drag the avatar/font/background registries into a
|
|
5
|
+
* bundle that exists to stay small.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** The localStorage key holding a /s VISITOR's own light/dark choice — theirs
|
|
9
|
+
* alone, never the owner's. Two bundles must agree on it: the server's
|
|
10
|
+
* pre-paint inline script (template.ts) writes the reader, the share-page
|
|
11
|
+
* runtime writes the value. One constant, no mirrored literal. */
|
|
12
|
+
export const SHARE_MODE_STORAGE_KEY = 'mantle-share-mode';
|