@crossworks/share-ui 0.230.43
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.md +135 -0
- package/package.json +68 -0
- package/src/app-bridge-protocol.ts +115 -0
- package/src/app-presenter.tsx +25 -0
- package/src/app-sandbox.tsx +552 -0
- package/src/appearance.ts +192 -0
- package/src/avatar.test.ts +229 -0
- package/src/avatar.ts +731 -0
- package/src/backgrounds.test.ts +119 -0
- package/src/backgrounds.ts +118 -0
- package/src/draw-presenter.tsx +39 -0
- package/src/event-presenter.tsx +62 -0
- package/src/file-presenter.tsx +76 -0
- package/src/formula-calculator.tsx +209 -0
- package/src/formula-presenter.test.ts +128 -0
- package/src/formula-presenter.tsx +301 -0
- package/src/help-topics.ts +104 -0
- package/src/lib/ink-audit.test.ts +314 -0
- package/src/lib/theme-css-blocks.ts +26 -0
- package/src/lib/theme-generator.test.ts +179 -0
- package/src/lib/theme-registry.gen.ts +352 -0
- package/src/lib/themes.test.ts +308 -0
- package/src/lib/themes.ts +75 -0
- package/src/lib/utils.ts +6 -0
- package/src/nav-items.ts +225 -0
- package/src/note-presenter.tsx +14 -0
- package/src/page-outline.tsx +127 -0
- package/src/table-presenter.tsx +226 -0
- package/src/task-presenter.tsx +60 -0
- package/src/ui/button.tsx +50 -0
- package/src/ui/input.tsx +18 -0
- package/src/ui/label.tsx +20 -0
- package/src/view-payload.ts +82 -0
- package/styles/app.css +1098 -0
- package/styles/themes.css +6198 -0
- package/themes/generate.d.mts +11 -0
- package/themes/generate.mjs +618 -0
- package/themes/model.d.mts +24 -0
- package/themes/model.mjs +213 -0
- package/themes/preview.html +145 -0
- package/themes/seeds.d.mts +16 -0
- package/themes/seeds.mjs +3694 -0
- package/tsconfig.json +15 -0
- package/tsconfig.tsbuildinfo +1 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Hand-written declarations for generate.mjs. */
|
|
2
|
+
import type { ThemeModeSeed } from './seeds.mjs';
|
|
3
|
+
|
|
4
|
+
export const ROLE_HUES: Record<string, number>;
|
|
5
|
+
export function resolveSeed(mode: ThemeModeSeed): Record<string, string>;
|
|
6
|
+
export function generateMode(
|
|
7
|
+
modeSeed: ThemeModeSeed,
|
|
8
|
+
opts: { mode: 'light' | 'dark' },
|
|
9
|
+
): Record<string, string>;
|
|
10
|
+
export function generateCss(): string;
|
|
11
|
+
export function generateRegistry(): string;
|
|
@@ -0,0 +1,618 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Theme generator — seeds.mjs -> styles/themes.css + the generated picker
|
|
3
|
+
* registry. Run via `pnpm themes:build` (or `node themes/generate.mjs`);
|
|
4
|
+
* `--check` regenerates in memory and fails on drift (CI calls this through
|
|
5
|
+
* the drift test); `--report` prints every token that differs from a given
|
|
6
|
+
* baseline css, with ΔE, so a repaint is measured instead of assumed.
|
|
7
|
+
*
|
|
8
|
+
* WHAT IS AUTHORED VS DERIVED
|
|
9
|
+
* verbatim from seeds…. surfaces (background/card/popover/muted/secondary/
|
|
10
|
+
* accent/sidebar/sidebar-accent), fills (primary/
|
|
11
|
+
* destructive), decor (border/input/sidebar-border),
|
|
12
|
+
* non-colour extras, and pinnacle's brand chart ramp.
|
|
13
|
+
* solved (anchored)……. every -foreground (anchor: the authored value, so a
|
|
14
|
+
* passing pair ships unchanged), every -ink (anchor:
|
|
15
|
+
* its fill), ring/sidebar-ring at the 3:1 non-text
|
|
16
|
+
* bar, the semantic roles, the code palette, and the
|
|
17
|
+
* categorical chart ramp.
|
|
18
|
+
*
|
|
19
|
+
* THE CONTRACTS (all measured on the emitted 8-bit hex)
|
|
20
|
+
* 4.5:1 text — each -foreground on its own fill; `foreground` on every
|
|
21
|
+
* neutral surface; `muted-foreground` on muted + the neutrals; every
|
|
22
|
+
* ink (primary/destructive/success/warning/info/code-*) on every
|
|
23
|
+
* neutral surface, because ~330 call sites use inks without declaring
|
|
24
|
+
* a background.
|
|
25
|
+
* 3.0:1 non-text — ring against background+card, sidebar-ring against
|
|
26
|
+
* sidebar, generated chart colours against background+card.
|
|
27
|
+
* (A seeded `charts` override is authored brand artwork and is
|
|
28
|
+
* exempt — pinnacle's ramp is identity, not data ink.)
|
|
29
|
+
*
|
|
30
|
+
* SEMANTIC ROLES. success/warning/info sit beside destructive: one global hue
|
|
31
|
+
* each, chroma borrowed from the theme (max of primary/destructive chroma,
|
|
32
|
+
* clamped) so `mono` gets them as quiet as its own destructive and `cyberpunk`
|
|
33
|
+
* gets them loud, lightness solved per theme. A new role is one line in
|
|
34
|
+
* ROLE_HUES — never another 168 hand-picked hex values.
|
|
35
|
+
*
|
|
36
|
+
* CODE PALETTE. `code-keyword` keeps the THEME's primary hue (the brand accent
|
|
37
|
+
* survives into code blocks); string/number/title/variable are fixed semantic
|
|
38
|
+
* hues (strings read green in every theme), nudged away from the keyword hue
|
|
39
|
+
* when a theme's primary would collide with one of them.
|
|
40
|
+
*/
|
|
41
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
42
|
+
import { fileURLToPath } from 'node:url';
|
|
43
|
+
import { join } from 'node:path';
|
|
44
|
+
import { THEME_SEEDS } from './seeds.mjs';
|
|
45
|
+
import {
|
|
46
|
+
cssToHex,
|
|
47
|
+
deltaE,
|
|
48
|
+
hueDistance,
|
|
49
|
+
oklchToSrgb,
|
|
50
|
+
parseOklch,
|
|
51
|
+
solvePair,
|
|
52
|
+
solveText,
|
|
53
|
+
toHex,
|
|
54
|
+
} from './model.mjs';
|
|
55
|
+
|
|
56
|
+
const HERE = fileURLToPath(new URL('.', import.meta.url));
|
|
57
|
+
const CSS_PATH = join(HERE, '..', 'styles', 'themes.css');
|
|
58
|
+
const REGISTRY_PATH = join(HERE, '..', 'src', 'lib', 'theme-registry.gen.ts');
|
|
59
|
+
|
|
60
|
+
// ── structural defaults (must match seeds.mjs docs) ──────────────────────────
|
|
61
|
+
const DEFAULTS = {
|
|
62
|
+
'card-foreground': 'foreground',
|
|
63
|
+
popover: 'card',
|
|
64
|
+
'popover-foreground': 'card-foreground',
|
|
65
|
+
'secondary-foreground': 'foreground',
|
|
66
|
+
'accent-foreground': 'foreground',
|
|
67
|
+
input: 'border',
|
|
68
|
+
ring: 'primary',
|
|
69
|
+
sidebar: 'muted',
|
|
70
|
+
'sidebar-foreground': 'foreground',
|
|
71
|
+
'sidebar-primary': 'primary',
|
|
72
|
+
'sidebar-primary-foreground': 'primary-foreground',
|
|
73
|
+
'sidebar-accent': 'accent',
|
|
74
|
+
'sidebar-accent-foreground': 'accent-foreground',
|
|
75
|
+
'sidebar-border': 'border',
|
|
76
|
+
'sidebar-ring': 'ring',
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/** Surfaces every unscoped ink can land on (mirrors the audit + ink tests). */
|
|
80
|
+
const NEUTRALS = ['background', 'card', 'popover', 'muted', 'sidebar'];
|
|
81
|
+
|
|
82
|
+
/** Semantic roles beside `destructive`. Adding a role = adding a line. */
|
|
83
|
+
export const ROLE_HUES = { success: 150, warning: 80, info: 245 };
|
|
84
|
+
|
|
85
|
+
/** Fixed hues of the code palette (keyword takes the theme's primary hue). */
|
|
86
|
+
const CODE_HUES = { string: 150, number: 75, title: 245, variable: 330 };
|
|
87
|
+
|
|
88
|
+
/** Relative hue steps of the generated categorical chart ramp (from primary). */
|
|
89
|
+
const CHART_HUE_STEPS = [0, 72, 144, 216, 288];
|
|
90
|
+
|
|
91
|
+
const clampC = (c, lo, hi) => Math.min(hi, Math.max(lo, c));
|
|
92
|
+
|
|
93
|
+
/** Resolve a seed mode through the default chain to all 27 authored tokens. */
|
|
94
|
+
export function resolveSeed(mode) {
|
|
95
|
+
const t = { ...mode };
|
|
96
|
+
delete t.charts;
|
|
97
|
+
delete t.extras;
|
|
98
|
+
for (const [token, fallback] of Object.entries(DEFAULTS)) {
|
|
99
|
+
if (!(token in t)) {
|
|
100
|
+
// fallbacks can chain (sidebar-ring -> ring -> primary)
|
|
101
|
+
let v = fallback;
|
|
102
|
+
while (!(v in t)) {
|
|
103
|
+
if (!(v in DEFAULTS)) throw new Error(`unresolvable default for --${token}`);
|
|
104
|
+
v = DEFAULTS[v];
|
|
105
|
+
}
|
|
106
|
+
t[token] = t[v];
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return t;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Pure-function memo: seeds are immutable module data, and the CSS, the
|
|
113
|
+
* registry and the test suites all re-derive the same modes — solving each
|
|
114
|
+
* (seed, mode) once keeps the whole pipeline O(themes), not O(callers). */
|
|
115
|
+
const MODE_MEMO = new WeakMap();
|
|
116
|
+
|
|
117
|
+
/** Generate the full token map for one theme mode. */
|
|
118
|
+
export function generateMode(modeSeed, { mode }) {
|
|
119
|
+
const hit = MODE_MEMO.get(modeSeed);
|
|
120
|
+
if (hit?.[mode]) return { ...hit[mode] };
|
|
121
|
+
const out = generateModeUncached(modeSeed, { mode });
|
|
122
|
+
MODE_MEMO.set(modeSeed, { ...hit, [mode]: out });
|
|
123
|
+
return { ...out };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** solveText, but refusing to emit an unmeetable contract. Infeasibility means
|
|
127
|
+
* the surfaces span mid-luminance in a way NO single ink can clear (one
|
|
128
|
+
* surface needs light text, another dark) — a seed problem, and the author
|
|
129
|
+
* should hear it as a generator error naming the surfaces, not as a cryptic
|
|
130
|
+
* contrast-test failure three artifacts downstream. */
|
|
131
|
+
function mustSolve(anchorCss, against, opts) {
|
|
132
|
+
const r = solveText(anchorCss, against, opts);
|
|
133
|
+
if (!r.feasible) {
|
|
134
|
+
throw new Error(
|
|
135
|
+
`no feasible ink: anchor ${anchorCss} cannot clear ${opts?.ratio ?? 4.5}:1 against ` +
|
|
136
|
+
`[${against.join(', ')}] — the surfaces demand light AND dark text at once; fix the seed`,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
return r;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function generateModeUncached(modeSeed, { mode }) {
|
|
143
|
+
const s = resolveSeed(modeSeed);
|
|
144
|
+
const out = {};
|
|
145
|
+
const dark = mode === 'dark';
|
|
146
|
+
|
|
147
|
+
// 1. Neutral surfaces and decor — the theme's canvas, verbatim. These are
|
|
148
|
+
// what every text token is solved AGAINST; they never move.
|
|
149
|
+
for (const token of [
|
|
150
|
+
'background',
|
|
151
|
+
'card',
|
|
152
|
+
'popover',
|
|
153
|
+
'muted',
|
|
154
|
+
'sidebar',
|
|
155
|
+
'border',
|
|
156
|
+
'input',
|
|
157
|
+
'sidebar-border',
|
|
158
|
+
]) {
|
|
159
|
+
out[token] = s[token];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// 2. Branded fills + their own -foreground — solved as a PAIR. A pair the
|
|
163
|
+
// theme authored consistently ships verbatim; a broken one moves
|
|
164
|
+
// whichever side shifts least (fill weighted heavier). See solvePair.
|
|
165
|
+
for (const [fill, fg] of [
|
|
166
|
+
['primary', 'primary-foreground'],
|
|
167
|
+
['secondary', 'secondary-foreground'],
|
|
168
|
+
['accent', 'accent-foreground'],
|
|
169
|
+
['destructive', 'destructive-foreground'],
|
|
170
|
+
['sidebar-primary', 'sidebar-primary-foreground'],
|
|
171
|
+
['sidebar-accent', 'sidebar-accent-foreground'],
|
|
172
|
+
]) {
|
|
173
|
+
const pair = solvePair(s[fill], s[fg]);
|
|
174
|
+
out[fill] = pair.fill;
|
|
175
|
+
out[fg] = pair.fg;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// 3. Text on the neutral canvas — anchored on the authored value.
|
|
179
|
+
out.foreground = mustSolve(
|
|
180
|
+
s.foreground,
|
|
181
|
+
NEUTRALS.map((n) => out[n]),
|
|
182
|
+
).hex;
|
|
183
|
+
out['card-foreground'] = mustSolve(s['card-foreground'], [out.card]).hex;
|
|
184
|
+
out['popover-foreground'] = mustSolve(s['popover-foreground'], [out.popover]).hex;
|
|
185
|
+
out['sidebar-foreground'] = mustSolve(s['sidebar-foreground'], [out.sidebar]).hex;
|
|
186
|
+
out['muted-foreground'] = mustSolve(
|
|
187
|
+
s['muted-foreground'],
|
|
188
|
+
NEUTRALS.map((n) => out[n]),
|
|
189
|
+
).hex;
|
|
190
|
+
|
|
191
|
+
// 4. Inks — the fill's colour, at whatever lightness survives every surface.
|
|
192
|
+
const neutralSurfaces = NEUTRALS.map((n) => out[n]);
|
|
193
|
+
out['primary-ink'] = mustSolve(out.primary, neutralSurfaces).hex;
|
|
194
|
+
out['destructive-ink'] = mustSolve(out.destructive, neutralSurfaces).hex;
|
|
195
|
+
|
|
196
|
+
// 5. Semantic roles — global hue, the theme's own chroma and weight, and
|
|
197
|
+
// the SAME on-fill text convention as their sibling `destructive` (a
|
|
198
|
+
// theme whose destructive wears white gets white on success too — the
|
|
199
|
+
// pair solve deepens the fill to hold it, exactly as it would have for a
|
|
200
|
+
// hand-authored pair).
|
|
201
|
+
const [pL, pC] = parseOklch(out.primary);
|
|
202
|
+
const [dL, dC] = parseOklch(out.destructive);
|
|
203
|
+
const roleC = clampC(Math.max(pC, dC), 0.05, 0.16);
|
|
204
|
+
const roleL = dC >= 0.05 ? dL : pL; // sibling weight: match destructive when it is chromatic
|
|
205
|
+
const dHue = parseOklch(out.destructive)[2];
|
|
206
|
+
for (const [role, baseHue] of Object.entries(ROLE_HUES)) {
|
|
207
|
+
// A destructive that drifts toward a role's hue (doom-64's is orange)
|
|
208
|
+
// would make e.g. warning and destructive the same colour — push the role
|
|
209
|
+
// hue away until the two stay tellable-apart, biased toward the original.
|
|
210
|
+
let hue = baseHue;
|
|
211
|
+
if (dC >= 0.05 && hueDistance(hue, dHue) < 40) {
|
|
212
|
+
const up = (dHue + 45 + 360) % 360;
|
|
213
|
+
const down = (dHue - 45 + 360) % 360;
|
|
214
|
+
hue = hueDistance(up, baseHue) <= hueDistance(down, baseHue) ? up : down;
|
|
215
|
+
}
|
|
216
|
+
// Solve the pair, then make sure the fill is tellable-apart from
|
|
217
|
+
// destructive and the roles before it. Hue separation alone is not enough:
|
|
218
|
+
// at very low lightness sRGB has no ambers, so a dark theme's warning can
|
|
219
|
+
// gamut-collapse onto the exact browny-red of its destructive. When that
|
|
220
|
+
// happens, walk the anchor lightness away (lighter in dark mode, darker in
|
|
221
|
+
// light) and re-solve — each step only gains distance.
|
|
222
|
+
const siblings = ['destructive', ...Object.keys(ROLE_HUES).filter((r) => r in out)];
|
|
223
|
+
let L = roleL;
|
|
224
|
+
let pair;
|
|
225
|
+
for (let i = 0; i < 12; i++) {
|
|
226
|
+
pair = solvePair(toHex(oklchToSrgb([L, roleC, hue])), out['destructive-foreground']);
|
|
227
|
+
if (siblings.every((sib) => deltaE(pair.fill, out[sib]) >= 0.06)) break;
|
|
228
|
+
L = Math.min(0.95, Math.max(0.05, L + (dark ? 0.04 : -0.04)));
|
|
229
|
+
}
|
|
230
|
+
out[role] = pair.fill;
|
|
231
|
+
out[`${role}-foreground`] = pair.fg;
|
|
232
|
+
out[`${role}-ink`] = mustSolve(pair.fill, neutralSurfaces).hex;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// 6. Code palette — inks by contract (code sits on --muted, but like every
|
|
236
|
+
// ink these land wherever `.code-view` happens to be mounted). Keywords
|
|
237
|
+
// keep the brand: they anchor on primary-ink, which is already ink-safe,
|
|
238
|
+
// so in most themes `code-keyword` IS the primary ink at full chroma.
|
|
239
|
+
const keywordHue = pC >= 0.02 ? parseOklch(out.primary)[2] : 300;
|
|
240
|
+
const codeC = clampC(Math.max(pC, dC), 0.05, 0.14);
|
|
241
|
+
const codeAnchorL = dark ? 0.75 : 0.5;
|
|
242
|
+
out['code-keyword'] = mustSolve(out['primary-ink'], neutralSurfaces).hex;
|
|
243
|
+
for (const [role, hue] of Object.entries(CODE_HUES)) {
|
|
244
|
+
// a primary too close to a semantic code hue would make two token kinds
|
|
245
|
+
// identical — push the fixed hue away, keyword keeps the brand.
|
|
246
|
+
const h = hueDistance(hue, keywordHue) < 25 ? (hue + 40) % 360 : hue;
|
|
247
|
+
out[`code-${role}`] = mustSolve(
|
|
248
|
+
toHex(oklchToSrgb([codeAnchorL, codeC, h])),
|
|
249
|
+
neutralSurfaces,
|
|
250
|
+
).hex;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// 7. Focus rings — non-text, 3:1 against what they ring. A ring the seed
|
|
254
|
+
// left defaulted follows the SOLVED fill, not the authored one.
|
|
255
|
+
out.ring = mustSolve(modeSeed.ring ?? out.primary, [out.background, out.card], {
|
|
256
|
+
ratio: 3,
|
|
257
|
+
}).hex;
|
|
258
|
+
out['sidebar-ring'] = mustSolve(modeSeed['sidebar-ring'] ?? out.ring, [out.sidebar], {
|
|
259
|
+
ratio: 3,
|
|
260
|
+
}).hex;
|
|
261
|
+
|
|
262
|
+
// 8. Charts — categorical data ink. Seeded override = authored brand ramp,
|
|
263
|
+
// exempt; otherwise five distinguishable hues anchored on the brand.
|
|
264
|
+
if (modeSeed.charts) {
|
|
265
|
+
modeSeed.charts.forEach((c, i) => (out[`chart-${i + 1}`] = c));
|
|
266
|
+
} else if (pC < 0.02) {
|
|
267
|
+
// achromatic theme: a lightness ramp keeps its character. Solve the step
|
|
268
|
+
// NEAREST the surfaces once (the 3:1 floor), then ladder strictly away
|
|
269
|
+
// from them — every further step only gains contrast, and the fixed ΔL
|
|
270
|
+
// keeps the five steps tellable-apart (solving each independently used to
|
|
271
|
+
// collapse chart-4 and chart-5 onto the same grey).
|
|
272
|
+
const floor = mustSolve(
|
|
273
|
+
toHex(oklchToSrgb([dark ? 0.5 : 0.6, 0, 0])),
|
|
274
|
+
[out.background, out.card],
|
|
275
|
+
{
|
|
276
|
+
ratio: 3,
|
|
277
|
+
},
|
|
278
|
+
);
|
|
279
|
+
const floorL = parseOklch(floor.hex)[0];
|
|
280
|
+
for (let i = 0; i < 5; i++) {
|
|
281
|
+
const L = Math.min(0.97, Math.max(0.05, floorL + (dark ? 1 : -1) * i * 0.1));
|
|
282
|
+
out[`chart-${i + 1}`] = toHex(oklchToSrgb([L, 0, 0]));
|
|
283
|
+
}
|
|
284
|
+
} else {
|
|
285
|
+
const chartC = clampC(Math.max(pC, dC), 0.06, 0.15);
|
|
286
|
+
const baseL = dark ? 0.7 : 0.55;
|
|
287
|
+
CHART_HUE_STEPS.forEach((step, i) => {
|
|
288
|
+
const hue = (keywordHue + step) % 360;
|
|
289
|
+
// chart-1 keeps the brand affinity most authored ramps had: it anchors
|
|
290
|
+
// at the primary's own lightness, so it reads as "the theme's colour".
|
|
291
|
+
out[`chart-${i + 1}`] = mustSolve(
|
|
292
|
+
toHex(oklchToSrgb([i === 0 ? pL : baseL, chartC, hue])),
|
|
293
|
+
[out.background, out.card],
|
|
294
|
+
{ ratio: 3 },
|
|
295
|
+
).hex;
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return out;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ── emission ─────────────────────────────────────────────────────────────────
|
|
303
|
+
|
|
304
|
+
/** Canonical token order for a theme block (stable, review-friendly diffs). */
|
|
305
|
+
const EMIT_ORDER = [
|
|
306
|
+
'background',
|
|
307
|
+
'foreground',
|
|
308
|
+
'card',
|
|
309
|
+
'card-foreground',
|
|
310
|
+
'popover',
|
|
311
|
+
'popover-foreground',
|
|
312
|
+
'primary',
|
|
313
|
+
'primary-foreground',
|
|
314
|
+
'primary-ink',
|
|
315
|
+
'secondary',
|
|
316
|
+
'secondary-foreground',
|
|
317
|
+
'muted',
|
|
318
|
+
'muted-foreground',
|
|
319
|
+
'accent',
|
|
320
|
+
'accent-foreground',
|
|
321
|
+
'destructive',
|
|
322
|
+
'destructive-foreground',
|
|
323
|
+
'destructive-ink',
|
|
324
|
+
'success',
|
|
325
|
+
'success-foreground',
|
|
326
|
+
'success-ink',
|
|
327
|
+
'warning',
|
|
328
|
+
'warning-foreground',
|
|
329
|
+
'warning-ink',
|
|
330
|
+
'info',
|
|
331
|
+
'info-foreground',
|
|
332
|
+
'info-ink',
|
|
333
|
+
'code-keyword',
|
|
334
|
+
'code-string',
|
|
335
|
+
'code-number',
|
|
336
|
+
'code-title',
|
|
337
|
+
'code-variable',
|
|
338
|
+
'border',
|
|
339
|
+
'input',
|
|
340
|
+
'ring',
|
|
341
|
+
'chart-1',
|
|
342
|
+
'chart-2',
|
|
343
|
+
'chart-3',
|
|
344
|
+
'chart-4',
|
|
345
|
+
'chart-5',
|
|
346
|
+
'sidebar',
|
|
347
|
+
'sidebar-foreground',
|
|
348
|
+
'sidebar-primary',
|
|
349
|
+
'sidebar-primary-foreground',
|
|
350
|
+
'sidebar-accent',
|
|
351
|
+
'sidebar-accent-foreground',
|
|
352
|
+
'sidebar-border',
|
|
353
|
+
'sidebar-ring',
|
|
354
|
+
];
|
|
355
|
+
|
|
356
|
+
function emitBlock(selector, tokens, extras) {
|
|
357
|
+
const lines = [`${selector} {`];
|
|
358
|
+
for (const t of EMIT_ORDER) lines.push(` --${t}: ${tokens[t]};`);
|
|
359
|
+
for (const [k, v] of Object.entries(extras ?? {})) lines.push(` --${k}: ${v};`);
|
|
360
|
+
lines.push('}');
|
|
361
|
+
return lines.join('\n');
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const THEME_INLINE = `@theme inline {
|
|
365
|
+
--color-background: var(--background);
|
|
366
|
+
--color-foreground: var(--foreground);
|
|
367
|
+
--color-card: var(--card);
|
|
368
|
+
--color-card-foreground: var(--card-foreground);
|
|
369
|
+
--color-popover: var(--popover);
|
|
370
|
+
--color-popover-foreground: var(--popover-foreground);
|
|
371
|
+
--color-primary: var(--primary);
|
|
372
|
+
--color-primary-foreground: var(--primary-foreground);
|
|
373
|
+
/* INK, not fill. A fill is tuned to sit BEHIND its own -foreground; as text
|
|
374
|
+
on a neutral surface it is a different job, and -ink is the derived token
|
|
375
|
+
that does it. Bare text-primary / text-destructive / text-success /
|
|
376
|
+
text-warning / text-info are lint errors (mantle/use-ink-for-text). */
|
|
377
|
+
--color-primary-ink: var(--primary-ink);
|
|
378
|
+
--color-secondary: var(--secondary);
|
|
379
|
+
--color-secondary-foreground: var(--secondary-foreground);
|
|
380
|
+
--color-muted: var(--muted);
|
|
381
|
+
--color-muted-foreground: var(--muted-foreground);
|
|
382
|
+
--color-accent: var(--accent);
|
|
383
|
+
--color-accent-foreground: var(--accent-foreground);
|
|
384
|
+
--color-destructive: var(--destructive);
|
|
385
|
+
--color-destructive-foreground: var(--destructive-foreground);
|
|
386
|
+
--color-destructive-ink: var(--destructive-ink);
|
|
387
|
+
--color-success: var(--success);
|
|
388
|
+
--color-success-foreground: var(--success-foreground);
|
|
389
|
+
--color-success-ink: var(--success-ink);
|
|
390
|
+
--color-warning: var(--warning);
|
|
391
|
+
--color-warning-foreground: var(--warning-foreground);
|
|
392
|
+
--color-warning-ink: var(--warning-ink);
|
|
393
|
+
--color-info: var(--info);
|
|
394
|
+
--color-info-foreground: var(--info-foreground);
|
|
395
|
+
--color-info-ink: var(--info-ink);
|
|
396
|
+
/* The code palette is ink-only (no fills): hljs rules use the vars directly,
|
|
397
|
+
and these mappings give JSX the same colours (json-tree, code chips). */
|
|
398
|
+
--color-code-keyword: var(--code-keyword);
|
|
399
|
+
--color-code-string: var(--code-string);
|
|
400
|
+
--color-code-number: var(--code-number);
|
|
401
|
+
--color-code-title: var(--code-title);
|
|
402
|
+
--color-code-variable: var(--code-variable);
|
|
403
|
+
--color-border: var(--border);
|
|
404
|
+
--color-input: var(--input);
|
|
405
|
+
--color-ring: var(--ring);
|
|
406
|
+
--color-chart-1: var(--chart-1);
|
|
407
|
+
--color-chart-2: var(--chart-2);
|
|
408
|
+
--color-chart-3: var(--chart-3);
|
|
409
|
+
--color-chart-4: var(--chart-4);
|
|
410
|
+
--color-chart-5: var(--chart-5);
|
|
411
|
+
--color-sidebar: var(--sidebar);
|
|
412
|
+
--color-sidebar-foreground: var(--sidebar-foreground);
|
|
413
|
+
--color-sidebar-primary: var(--sidebar-primary);
|
|
414
|
+
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
|
415
|
+
--color-sidebar-accent: var(--sidebar-accent);
|
|
416
|
+
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
|
417
|
+
--color-sidebar-border: var(--sidebar-border);
|
|
418
|
+
--color-sidebar-ring: var(--sidebar-ring);
|
|
419
|
+
|
|
420
|
+
--font-sans: var(--font-sans);
|
|
421
|
+
--font-mono: var(--font-mono);
|
|
422
|
+
--font-serif: var(--font-serif);
|
|
423
|
+
|
|
424
|
+
--radius-sm: calc(var(--radius) - 4px);
|
|
425
|
+
--radius-md: calc(var(--radius) - 2px);
|
|
426
|
+
--radius-lg: var(--radius);
|
|
427
|
+
--radius-xl: calc(var(--radius) + 4px);
|
|
428
|
+
|
|
429
|
+
--shadow-2xs: var(--shadow-2xs);
|
|
430
|
+
--shadow-xs: var(--shadow-xs);
|
|
431
|
+
--shadow-sm: var(--shadow-sm);
|
|
432
|
+
--shadow: var(--shadow);
|
|
433
|
+
--shadow-md: var(--shadow-md);
|
|
434
|
+
--shadow-lg: var(--shadow-lg);
|
|
435
|
+
--shadow-xl: var(--shadow-xl);
|
|
436
|
+
--shadow-2xl: var(--shadow-2xl);
|
|
437
|
+
|
|
438
|
+
--tracking-tighter: calc(var(--tracking-normal) - 0.05em);
|
|
439
|
+
--tracking-tight: calc(var(--tracking-normal) - 0.025em);
|
|
440
|
+
--tracking-normal: var(--tracking-normal);
|
|
441
|
+
--tracking-wide: calc(var(--tracking-normal) + 0.025em);
|
|
442
|
+
--tracking-wider: calc(var(--tracking-normal) + 0.05em);
|
|
443
|
+
--tracking-widest: calc(var(--tracking-normal) + 0.1em);
|
|
444
|
+
}`;
|
|
445
|
+
|
|
446
|
+
export function generateCss() {
|
|
447
|
+
const parts = [
|
|
448
|
+
`/* GENERATED FILE — do not edit. \`pnpm themes:build\` regenerates it from
|
|
449
|
+
* themes/seeds.mjs (the authored source); the drift test fails CI if the two
|
|
450
|
+
* disagree. Every -foreground and -ink below is SOLVED against the surfaces it
|
|
451
|
+
* must be legible on (see themes/generate.mjs for the contracts), so a token
|
|
452
|
+
* here is correct by construction — fix a colour in seeds.mjs, never here.
|
|
453
|
+
*
|
|
454
|
+
* Imported via \`@import '@mantle/web-ui/styles/themes.css';\` by both Next
|
|
455
|
+
* apps, inside the Tailwind bundle (tokens must precede @layer base). */`,
|
|
456
|
+
];
|
|
457
|
+
const [base, ...rest] = THEME_SEEDS;
|
|
458
|
+
if (base.id !== 'clean-slate') throw new Error('clean-slate must stay the baseline seed');
|
|
459
|
+
// MODE ISLANDS: `.light` rides every light selector, and each theme's dark
|
|
460
|
+
// block also matches a `.dark` WRAPPER under the themed root. A wrapper
|
|
461
|
+
// carrying either class re-declares that mode's tokens on itself, which
|
|
462
|
+
// beats whatever it would inherit — so a subtree can preview the opposite
|
|
463
|
+
// mode truthfully in the CURRENT theme. Costs only selector text (each
|
|
464
|
+
// block is emitted once), and exists for the logo strips in Settings →
|
|
465
|
+
// Appearance, which must show both modes at once. Without the
|
|
466
|
+
// `[data-color-theme] .dark` arm, a `.dark` wrapper's own declarations came
|
|
467
|
+
// from the BASELINE `.dark` block and beat the theme's inherited values —
|
|
468
|
+
// the forced-dark preview showed clean-slate's palette on every other
|
|
469
|
+
// theme. Note the islands change TOKENS only: `dark:` Tailwind variants
|
|
470
|
+
// inside still follow the real ancestor class.
|
|
471
|
+
parts.push(
|
|
472
|
+
emitBlock(':root, .light', generateMode(base.light, { mode: 'light' }), base.light.extras),
|
|
473
|
+
);
|
|
474
|
+
parts.push(emitBlock('.dark', generateMode(base.dark, { mode: 'dark' }), base.dark.extras));
|
|
475
|
+
for (const t of rest) {
|
|
476
|
+
parts.push(
|
|
477
|
+
emitBlock(
|
|
478
|
+
`[data-color-theme="${t.id}"], [data-color-theme="${t.id}"] .light`,
|
|
479
|
+
generateMode(t.light, { mode: 'light' }),
|
|
480
|
+
t.light.extras,
|
|
481
|
+
),
|
|
482
|
+
);
|
|
483
|
+
parts.push(
|
|
484
|
+
emitBlock(
|
|
485
|
+
`.dark[data-color-theme="${t.id}"], [data-color-theme="${t.id}"] .dark`,
|
|
486
|
+
generateMode(t.dark, { mode: 'dark' }),
|
|
487
|
+
t.dark.extras,
|
|
488
|
+
),
|
|
489
|
+
);
|
|
490
|
+
}
|
|
491
|
+
parts.push(THEME_INLINE);
|
|
492
|
+
return parts.join('\n\n') + '\n';
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
export function generateRegistry() {
|
|
496
|
+
const swatch = (seed, mode) => {
|
|
497
|
+
const m = generateMode(seed, { mode });
|
|
498
|
+
return [m.primary, m.accent, m.background].map((s) => `'${s}'`).join(', ');
|
|
499
|
+
};
|
|
500
|
+
const rows = THEME_SEEDS.map((t) => {
|
|
501
|
+
// Expanded object literals on purpose: prettier keeps an object multi-line
|
|
502
|
+
// when the source breaks after `{`, so the generated file is stable under
|
|
503
|
+
// `format:check` no matter how long a label or hex list gets.
|
|
504
|
+
return ` {
|
|
505
|
+
id: '${t.id}',
|
|
506
|
+
label: '${t.label}',
|
|
507
|
+
swatches: {
|
|
508
|
+
light: [${swatch(t.light, 'light')}],
|
|
509
|
+
dark: [${swatch(t.dark, 'dark')}],
|
|
510
|
+
},
|
|
511
|
+
},`;
|
|
512
|
+
});
|
|
513
|
+
return `/* GENERATED FILE — do not edit. \`pnpm themes:build\` regenerates it from
|
|
514
|
+
* themes/seeds.mjs. Swatches are [primary, accent, background] of the generated
|
|
515
|
+
* light AND dark modes, so the picker previews the theme you would actually get
|
|
516
|
+
* in the mode you are in, and can never desync from the CSS. */
|
|
517
|
+
import type { ColorTheme } from './themes';
|
|
518
|
+
|
|
519
|
+
export const GENERATED_COLOR_THEMES: ColorTheme[] = [
|
|
520
|
+
${rows.join('\n')}
|
|
521
|
+
];
|
|
522
|
+
`;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// ── CLI ──────────────────────────────────────────────────────────────────────
|
|
526
|
+
// Guarded to direct execution: the drift test IMPORTS generateCss/
|
|
527
|
+
// generateRegistry, and an import must never write or exit.
|
|
528
|
+
const invokedDirectly =
|
|
529
|
+
process.argv[1] && import.meta.url === new URL(`file://${process.argv[1]}`).href;
|
|
530
|
+
|
|
531
|
+
if (!invokedDirectly) {
|
|
532
|
+
// imported as a library — expose the pure functions only
|
|
533
|
+
} else main(process.argv[2]);
|
|
534
|
+
|
|
535
|
+
function main(arg) {
|
|
536
|
+
if (arg === '--check') {
|
|
537
|
+
const cssOk = readFileSync(CSS_PATH, 'utf8') === generateCss();
|
|
538
|
+
const regOk = readFileSync(REGISTRY_PATH, 'utf8') === generateRegistry();
|
|
539
|
+
if (!cssOk || !regOk) {
|
|
540
|
+
console.error(
|
|
541
|
+
`drift: ${[!cssOk && 'styles/themes.css', !regOk && 'src/lib/theme-registry.gen.ts']
|
|
542
|
+
.filter(Boolean)
|
|
543
|
+
.join(', ')} do not match themes/seeds.mjs — run \`pnpm themes:build\``,
|
|
544
|
+
);
|
|
545
|
+
process.exit(1);
|
|
546
|
+
}
|
|
547
|
+
console.log('themes.css + registry match seeds');
|
|
548
|
+
} else if (arg === '--report') {
|
|
549
|
+
// Fidelity report: what would visibly change vs the css at `baseline` path.
|
|
550
|
+
const baselinePath = process.argv[3] ?? CSS_PATH;
|
|
551
|
+
const baseline = readFileSync(baselinePath, 'utf8');
|
|
552
|
+
// Light blocks carry the `.light` island selector since it was added;
|
|
553
|
+
// candidates are tried in order so a pre-island baseline still reports.
|
|
554
|
+
// (The bare `:root {` / `[data-color-theme="x"] {` forms MUST come second:
|
|
555
|
+
// as substrings they also occur inside the dark selectors.)
|
|
556
|
+
const blockOf = (selectors) => {
|
|
557
|
+
for (const selector of Array.isArray(selectors) ? selectors : [selectors]) {
|
|
558
|
+
const at = baseline.indexOf(`${selector} {`);
|
|
559
|
+
if (at < 0) continue;
|
|
560
|
+
const body = baseline.slice(at, baseline.indexOf('\n}', at));
|
|
561
|
+
const t = {};
|
|
562
|
+
for (const m of body.matchAll(/--([\w-]+):\s*([^;]+);/g)) t[m[1]] = m[2].trim();
|
|
563
|
+
return t;
|
|
564
|
+
}
|
|
565
|
+
return null;
|
|
566
|
+
};
|
|
567
|
+
const rootBase = blockOf([':root, .light', ':root']);
|
|
568
|
+
const buckets = {};
|
|
569
|
+
let moved = 0;
|
|
570
|
+
let total = 0;
|
|
571
|
+
for (const t of THEME_SEEDS) {
|
|
572
|
+
for (const mode of ['light', 'dark']) {
|
|
573
|
+
const sel =
|
|
574
|
+
t.id === 'clean-slate'
|
|
575
|
+
? mode === 'light'
|
|
576
|
+
? [':root, .light', ':root']
|
|
577
|
+
: ['.dark']
|
|
578
|
+
: mode === 'light'
|
|
579
|
+
? [
|
|
580
|
+
`[data-color-theme="${t.id}"], [data-color-theme="${t.id}"] .light`,
|
|
581
|
+
`[data-color-theme="${t.id}"]`,
|
|
582
|
+
]
|
|
583
|
+
: [
|
|
584
|
+
`.dark[data-color-theme="${t.id}"], [data-color-theme="${t.id}"] .dark`,
|
|
585
|
+
`.dark[data-color-theme="${t.id}"]`,
|
|
586
|
+
];
|
|
587
|
+
const authored = { ...rootBase, ...(blockOf(sel) ?? {}) };
|
|
588
|
+
const gen = generateMode(t[mode], { mode });
|
|
589
|
+
for (const [token, hex] of Object.entries(gen)) {
|
|
590
|
+
if (!authored[token]) continue; // new token, nothing to compare
|
|
591
|
+
total++;
|
|
592
|
+
const d = deltaE(hex, authored[token]);
|
|
593
|
+
if (d < 0.005) continue;
|
|
594
|
+
moved++;
|
|
595
|
+
(buckets[token] ??= []).push({
|
|
596
|
+
id: `${t.id}/${mode}`,
|
|
597
|
+
d,
|
|
598
|
+
from: cssToHex(authored[token]),
|
|
599
|
+
to: hex,
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
console.log(`moved ${moved} of ${total} pre-existing token values (ΔE ≥ 0.005)\n`);
|
|
605
|
+
for (const [token, list] of Object.entries(buckets).sort((a, b) => b[1].length - a[1].length)) {
|
|
606
|
+
list.sort((a, b) => b.d - a.d);
|
|
607
|
+
const worst = list
|
|
608
|
+
.slice(0, 3)
|
|
609
|
+
.map((x) => `${x.id} ${x.from}→${x.to} ΔE ${x.d.toFixed(3)}`)
|
|
610
|
+
.join('; ');
|
|
611
|
+
console.log(` --${token}: ${list.length} moved worst: ${worst}`);
|
|
612
|
+
}
|
|
613
|
+
} else {
|
|
614
|
+
writeFileSync(CSS_PATH, generateCss());
|
|
615
|
+
writeFileSync(REGISTRY_PATH, generateRegistry());
|
|
616
|
+
console.log(`wrote ${CSS_PATH}\nwrote ${REGISTRY_PATH}`);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** Hand-written declarations for model.mjs (plain JS so `node` runs it at
|
|
2
|
+
* build time with no transpile step; typecheck still sees real shapes). */
|
|
3
|
+
export function parseColor(value: string): [number, number, number];
|
|
4
|
+
export function oklchToSrgb(lch: [number, number, number]): [number, number, number];
|
|
5
|
+
export function srgbToOklab(rgb: [number, number, number]): [number, number, number];
|
|
6
|
+
export function srgbToOklch(rgb: [number, number, number]): [number, number, number];
|
|
7
|
+
export function parseOklch(css: string): [number, number, number];
|
|
8
|
+
export function toHex(rgb: [number, number, number]): string;
|
|
9
|
+
export function cssToHex(css: string): string;
|
|
10
|
+
export function luminance(rgb: [number, number, number]): number;
|
|
11
|
+
export function contrast(cssA: string, cssB: string): number;
|
|
12
|
+
export function deltaE(cssA: string, cssB: string): number;
|
|
13
|
+
export function hueDistance(a: number, b: number): number;
|
|
14
|
+
export const clamp01: (x: number) => number;
|
|
15
|
+
export function solveText(
|
|
16
|
+
anchorCss: string,
|
|
17
|
+
against: readonly string[],
|
|
18
|
+
opts?: { ratio?: number },
|
|
19
|
+
): { hex: string; dL: number; dC: number; feasible: boolean };
|
|
20
|
+
export function solvePair(
|
|
21
|
+
fillCss: string,
|
|
22
|
+
fgAnchorCss: string,
|
|
23
|
+
opts?: { ratio?: number; fillWeight?: number },
|
|
24
|
+
): { fill: string; fg: string; cost: number };
|