mockpit 3.0.0 → 3.1.0

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.
Files changed (54) hide show
  1. package/README.md +11 -23
  2. package/bin/initDesign.js +11 -16
  3. package/bin/mockpit.js +242 -52
  4. package/bin/poster.js +64 -7
  5. package/dist/mcp/server.js +48 -6
  6. package/dist/server/apiViews.d.ts +562 -557
  7. package/dist/server/apiViews.js +18 -9
  8. package/dist/server/app.js +723 -206
  9. package/dist/server/cdnInline.js +6 -4
  10. package/dist/server/codeWorker.js +2 -2
  11. package/dist/server/contrast.d.ts +4 -0
  12. package/dist/server/contrast.js +157 -0
  13. package/dist/server/decisionList.d.ts +4 -1
  14. package/dist/server/decisionList.js +43 -5
  15. package/dist/server/designGuide.d.ts +13 -6
  16. package/dist/server/designGuide.js +206 -41
  17. package/dist/server/events.d.ts +6 -0
  18. package/dist/server/feedbackBatch.d.ts +4 -0
  19. package/dist/server/feedbackBatch.js +19 -1
  20. package/dist/server/guidance.d.ts +1 -1
  21. package/dist/server/guidance.js +2 -2
  22. package/dist/server/kits.d.ts +2 -2
  23. package/dist/server/kits.js +54 -1
  24. package/dist/server/mcpHttp.d.ts +2 -0
  25. package/dist/server/mcpHttp.js +13 -1
  26. package/dist/server/mcpSpec.d.ts +27 -27
  27. package/dist/server/mcpSpec.js +78 -10
  28. package/dist/server/run.js +22 -1
  29. package/dist/server/runApi.d.ts +2 -2
  30. package/dist/server/runApi.js +9 -2
  31. package/dist/server/sqlStore.d.ts +68 -39
  32. package/dist/server/sqlStore.js +163 -26
  33. package/dist/server/surfacePage.d.ts +4 -2
  34. package/dist/server/surfacePage.js +80 -32
  35. package/dist/server/system.d.ts +234 -0
  36. package/dist/server/system.js +1508 -0
  37. package/dist/server/systemChecks.d.ts +15 -0
  38. package/dist/server/systemChecks.js +224 -0
  39. package/dist/server/systemSpecimen.d.ts +2 -0
  40. package/dist/server/systemSpecimen.js +49 -0
  41. package/dist/server/themes.d.ts +9 -7
  42. package/dist/server/themes.js +43 -90
  43. package/dist/server/types.d.ts +43 -24
  44. package/dist/server/types.js +3 -1
  45. package/guide/AGENT_SETUP.md +3 -1
  46. package/guide/packs/foundations.md +35 -0
  47. package/guide/topics/asks.md +2 -1
  48. package/guide/topics/feedback.md +5 -3
  49. package/guide/topics/html.md +2 -2
  50. package/guide/topics/http.md +3 -1
  51. package/guide/topics/system.md +157 -0
  52. package/package.json +12 -12
  53. package/skills/mockpit/SKILL.md +7 -4
  54. package/viewer/dist/index.html +9 -8
@@ -0,0 +1,15 @@
1
+ import { type Scale, type System } from "./system.ts";
2
+ export declare function classesDefined(css: string): Set<string>;
3
+ export interface PairResult {
4
+ palette: string;
5
+ mode: "light" | "dark";
6
+ fg: string;
7
+ bg: string;
8
+ min: number;
9
+ ratio: number | null;
10
+ }
11
+ export declare function pairResults(system: System, all?: boolean): PairResult[];
12
+ export declare function systemChecks(system: System): string[];
13
+ export declare function zoomFailures(scale: Scale): string[];
14
+ export declare function pageChecks(system: System, htmls: string[], what?: string): string[];
15
+ export declare function purposeNudge(slug: string, notes: string | null | undefined): string | null;
@@ -0,0 +1,224 @@
1
+ // What mockpit checks against a project's system, by string: on a `system`
2
+ // write (the system against itself) and on a publish (a page against the
3
+ // system). Pure and runtime-agnostic. Every check comes back as a nudge line,
4
+ // one per kind, with a count and the first instances; none blocks a write.
5
+ import { contrastRatio } from "./contrast.js";
6
+ import { declaredNames, isColorValue, scaleSteps, TEXT_FIELDS, } from "./system.js";
7
+ const FIRST = 3;
8
+ const firstOf = (items) => `${items.slice(0, FIRST).join(", ")}${items.length > FIRST ? `, +${items.length - FIRST} more` : ""}`;
9
+ const stripComments = (css) => css.replace(/\/\*[\s\S]*?\*\//g, "");
10
+ // `var(--x)` names a stylesheet reads.
11
+ function varsRead(css) {
12
+ const out = new Set();
13
+ for (const m of stripComments(css).matchAll(/var\(\s*--([\w-]+)/g))
14
+ out.add(m[1]);
15
+ return out;
16
+ }
17
+ // Custom properties a stylesheet declares.
18
+ function varsDeclared(css) {
19
+ const out = new Set();
20
+ for (const m of stripComments(css).matchAll(/(?:^|[{;\s])--([\w-]+)\s*:/g))
21
+ out.add(m[1]);
22
+ return out;
23
+ }
24
+ // Class selectors a stylesheet defines rules for.
25
+ export function classesDefined(css) {
26
+ const out = new Set();
27
+ const bare = stripComments(css).replace(/\{[^{}]*\}/g, "{}");
28
+ for (const m of bare.matchAll(/\.(-?[_a-zA-Z][\w-]*)/g))
29
+ out.add(m[1]);
30
+ return out;
31
+ }
32
+ // Top-level `selector{declarations}` rules, by normalized selector and body.
33
+ function flatRules(css) {
34
+ const out = new Map();
35
+ const norm = (x) => x
36
+ .replace(/\s+/g, " ")
37
+ .replace(/\s*([{};:,>])\s*/g, "$1")
38
+ .trim();
39
+ for (const m of stripComments(css).matchAll(/([^{}@;]+)\{([^{}]*)\}/g)) {
40
+ out.set(norm(m[1]), norm(m[2]).replace(/;$/, ""));
41
+ }
42
+ return out;
43
+ }
44
+ // Every declared pair in every palette and mode. A value contrast.ts can't
45
+ // resolve is null: unchecked, never passed.
46
+ // Hidden palettes are left out unless `all`: the System page shows every palette's.
47
+ export function pairResults(system, all = false) {
48
+ const constants = new Map(system.tokens
49
+ .filter((t) => t.type === "color" && typeof t.value === "string")
50
+ .map((t) => [t.name, t.value]));
51
+ const out = [];
52
+ const palettes = system.color.palettes.filter((p) => all || p.status !== "hidden");
53
+ for (const p of palettes.length ? palettes : [null]) {
54
+ for (const mode of ["light", "dark"]) {
55
+ const value = (name) => p?.[mode][name] ?? constants.get(name);
56
+ for (const [fg, bg, min] of system.color.pairs) {
57
+ const f = value(fg);
58
+ const b = value(bg);
59
+ out.push({
60
+ palette: p?.id ?? "",
61
+ mode,
62
+ fg,
63
+ bg,
64
+ min,
65
+ ratio: f && b ? contrastRatio(f, b) : null,
66
+ });
67
+ }
68
+ }
69
+ }
70
+ return out;
71
+ }
72
+ export function systemChecks(system) {
73
+ const out = [];
74
+ const known = declaredNames(system);
75
+ const roleNames = system.color.roles.map((r) => r.name);
76
+ const results = pairResults(system);
77
+ const failing = results.filter((r) => r.ratio !== null && r.ratio < r.min);
78
+ if (failing.length)
79
+ out.push(`contrast: ${failing.length} pair${failing.length === 1 ? "" : "s"} below their minimum: ${firstOf(failing.map((r) => `${r.fg} on ${r.bg} ${r.ratio.toFixed(2)}:1 < ${r.min} (${r.palette || "tokens"} ${r.mode})`))}`);
80
+ const unchecked = results.filter((r) => r.ratio === null);
81
+ if (unchecked.length)
82
+ out.push(`contrast: ${unchecked.length} pair${unchecked.length === 1 ? "" : "s"} unchecked (a value that isn't hex, rgb, hsl, oklch or oklab, or a missing one): ${firstOf(unchecked.map((r) => `${r.fg} on ${r.bg} (${r.palette || "tokens"} ${r.mode})`))}`);
83
+ const missing = [];
84
+ for (const p of system.color.palettes) {
85
+ for (const mode of ["light", "dark"]) {
86
+ const lacks = roleNames.filter((r) => p[mode][r] === undefined);
87
+ if (lacks.length)
88
+ missing.push(`${p.id} ${mode} lacks ${lacks.slice(0, 4).join(", ")}${lacks.length > 4 ? "…" : ""}`);
89
+ }
90
+ }
91
+ if (missing.length)
92
+ out.push(`palettes missing a role: ${firstOf(missing)}`);
93
+ const strangers = system.color.palettes.flatMap((p) => Object.keys(p.light)
94
+ .filter((k) => !roleNames.includes(k))
95
+ .map((k) => `${p.id}.${k}`));
96
+ if (strangers.length && roleNames.length)
97
+ out.push(`palette values for no declared role: ${firstOf(strangers)} (declare them in roles)`);
98
+ const unknownPairs = system.color.pairs
99
+ .flatMap(([fg, bg]) => [fg, bg])
100
+ .filter((n) => !known.has(n));
101
+ if (unknownPairs.length)
102
+ out.push(`pairs name unknown tokens: ${firstOf([...new Set(unknownPairs)])}`);
103
+ for (const [token, role] of Object.entries(system.color.as ?? {})) {
104
+ if (!known.has(role))
105
+ out.push(`as.${token} points at "${role}", which nothing declares`);
106
+ }
107
+ const sheet = system.sheet?.css ?? "";
108
+ if (sheet) {
109
+ const own = varsDeclared(sheet);
110
+ const undeclared = [...varsRead(sheet)].filter((v) => !known.has(v) &&
111
+ !own.has(v) &&
112
+ !v.startsWith("k-") &&
113
+ !TEXT_FIELDS.some((f) => v.endsWith(`-${f}`) && known.has(v.slice(0, -f.length - 1))));
114
+ if (undeclared.length)
115
+ out.push(`sheet reads var(--…) that neither the system nor the sheet declares (fine if every page sets them): ${firstOf(undeclared.map((v) => `--${v}`))}`);
116
+ const defined = classesDefined(sheet);
117
+ const missingClasses = (system.sheet?.classes ?? [])
118
+ .map((c) => c.name)
119
+ .filter((c) => !defined.has(c));
120
+ if (missingClasses.length)
121
+ out.push(`indexed classes the sheet doesn't define: ${firstOf(missingClasses.map((c) => `.${c}`))}`);
122
+ const important = (stripComments(sheet).match(/!important/g) ?? []).length;
123
+ if (important)
124
+ out.push(`sheet uses !important ${important}×: inside a layer it beats every page rule, the opposite of what it does elsewhere`);
125
+ }
126
+ for (const scale of system.scales) {
127
+ const fails = zoomFailures(scale);
128
+ if (fails.length)
129
+ out.push(`scale ${scale.id} fails 200% zoom (WCAG 1.4.4): ${firstOf(fails)}; keep each step's largest size within 2.5× its smallest`);
130
+ }
131
+ return out;
132
+ }
133
+ // A fluid step whose largest size is past 2.5× its smallest can't reach 200%
134
+ // at every width with browser zoom: the vw part doesn't grow with it.
135
+ export function zoomFailures(scale) {
136
+ if (scale.family !== "type" || !scale.fluid)
137
+ return [];
138
+ return scaleSteps(scale)
139
+ .filter(({ min, max }) => Math.max(min, max) > 2.5 * Math.min(min, max))
140
+ .map(({ step, min, max }) => `${step} ${min}px → ${max}px`);
141
+ }
142
+ // --- a page against the system ----------------------------------------------------------
143
+ const COLOR_LITERAL_RE = /#[0-9a-fA-F]{3,8}\b|\b(?:rgba?|hsla?|hwb|lab|lch|oklab|oklch)\([^()]*\)/g;
144
+ const STYLE_BLOCK_RE = /<style[^>]*>([\s\S]*?)<\/style>/gi;
145
+ const STYLE_ATTR_RE = /\sstyle\s*=\s*("([^"]*)"|'([^']*)')/gi;
146
+ // The CSS a page carries: its style blocks and style attributes.
147
+ function pageCss(html) {
148
+ const blocks = [...html.matchAll(STYLE_BLOCK_RE)].map((m) => m[1]).join("\n");
149
+ const inline = [...html.matchAll(STYLE_ATTR_RE)].map((m) => m[2] ?? m[3] ?? "").join(";\n");
150
+ return { blocks, inline };
151
+ }
152
+ const pxOf = (v) => {
153
+ const m = /^(-?\d*\.?\d+)(px|rem)$/.exec(v.trim());
154
+ if (!m)
155
+ return null;
156
+ return m[2] === "rem" ? Number(m[1]) * 16 : Number(m[1]);
157
+ };
158
+ export function pageChecks(system, htmls, what = "page") {
159
+ const out = [];
160
+ const css = htmls.map(pageCss);
161
+ const all = css.map((c) => `${c.blocks}\n${c.inline}`).join("\n");
162
+ if (!all.trim())
163
+ return out;
164
+ const bare = stripComments(all);
165
+ const hasColour = system.color.palettes.length > 0 || system.tokens.some((t) => t.type === "color");
166
+ if (hasColour) {
167
+ const literals = [...bare.replace(/--[\w-]+\s*:[^;}]*/g, "").matchAll(COLOR_LITERAL_RE)]
168
+ .map((m) => m[0])
169
+ .filter((v) => isColorValue(v));
170
+ if (literals.length)
171
+ out.push(`${what} has ${literals.length} colour literal${literals.length === 1 ? "" : "s"} where the system has colours: ${firstOf([...new Set(literals)])}; use a role or token`);
172
+ }
173
+ const own = new Set([
174
+ ...system.tokens.filter((t) => t.owner !== "repo").map((t) => t.name),
175
+ ...system.color.roles.map((r) => r.name),
176
+ ]);
177
+ const redeclared = [...varsDeclared(bare)].filter((v) => own.has(v));
178
+ if (redeclared.length)
179
+ out.push(`${what} redeclares system names: ${firstOf(redeclared.map((v) => `--${v}`))}; change them in the system`);
180
+ // A rule copied from the sheet, same selector and same body, is the inlined
181
+ // kit habit; a page restyling a sheet class is an override, which is fine.
182
+ const sheetRules = flatRules(system.sheet?.css ?? "");
183
+ if (sheetRules.size) {
184
+ const copied = [...flatRules(css.map((c) => c.blocks).join("\n"))].filter(([selector, body]) => sheetRules.get(selector) === body);
185
+ if (copied.length)
186
+ out.push(`${what} copies ${copied.length} rule${copied.length === 1 ? "" : "s"} the system sheet already has: ${firstOf(copied.map(([sel]) => sel))}; the sheet is in every frame, so drop the copy`);
187
+ }
188
+ const typeScale = system.scales.filter((s) => s.family === "type");
189
+ const spaceScale = system.scales.filter((s) => s.family === "space");
190
+ const sizes = (scales) => scales.flatMap((s) => scaleSteps(s).flatMap(({ min, max }) => [min, max]));
191
+ // A plain dimension token is a size the system declares too.
192
+ const dimensions = system.tokens.flatMap((t) => t.type === "dimension" && !t.scale && typeof t.value === "string"
193
+ ? [pxOf(t.value)].filter((x) => x !== null)
194
+ : []);
195
+ const offScale = (prop, scales, label, extra = []) => {
196
+ if (!scales.length)
197
+ return;
198
+ const allowed = [...sizes(scales), ...extra];
199
+ const off = [];
200
+ for (const m of bare.matchAll(prop)) {
201
+ for (const part of m[1].trim().split(/\s+/)) {
202
+ const px = pxOf(part);
203
+ if (px === null || px === 0)
204
+ continue;
205
+ // A negative margin pulls by a step of the scale.
206
+ if (!allowed.some((a) => Math.abs(a - Math.abs(px)) < 0.6))
207
+ off.push(part);
208
+ }
209
+ }
210
+ if (off.length)
211
+ out.push(`${what} has ${off.length} ${label} value${off.length === 1 ? "" : "s"} off the ${scales.map((s) => s.id).join("/")} scale: ${firstOf([...new Set(off)])}; use var(--${scales[0].prefix}-…)`);
212
+ };
213
+ offScale(/font-size\s*:\s*([^;}"']+)/gi, typeScale, "font-size");
214
+ offScale(/(?:^|[;{\s])(?:gap|row-gap|column-gap|margin(?:-[a-z]+)?|padding(?:-[a-z]+)?)\s*:\s*([^;}"']+)/gi, spaceScale, "spacing", dimensions);
215
+ return out;
216
+ }
217
+ // A component's notes should open with one line saying what it is for: the
218
+ // brief's component index prints that line.
219
+ export function purposeNudge(slug, notes) {
220
+ const first = (notes ?? "").split("\n")[0].trim();
221
+ if (first && first.length <= 100)
222
+ return null;
223
+ return `give ${slug} a one-line purpose as the first line of its notes (\`edit --notes\`); the brief lists it${first ? ` (now ${first.length} characters, at most 100)` : ""}`;
224
+ }
@@ -0,0 +1,2 @@
1
+ import { type System } from "./system.ts";
2
+ export declare function specimenHtml(system: System): string;
@@ -0,0 +1,49 @@
1
+ // The System place's type and space specimen: the scales drawn with the project's
2
+ // own fonts and tokens, so it is a document of its own, rendered through
3
+ // renderHtmlPage and served under the sandbox CSP like any surface. Names and
4
+ // values are agent data: they go in as escaped text and as `var(--name)` of names
5
+ // the system already validated. Runtime-agnostic.
6
+ import { escapeHtml } from "./surfacePage.js";
7
+ import { scaleSteps } from "./system.js";
8
+ const SAMPLE = "Every render wears it";
9
+ const CSS = `
10
+ body{margin:0;padding:16px 20px;font-family:var(--font-sans,system-ui);color:var(--color-text-primary)}
11
+ .sp-h{font:600 11px/1.4 var(--font-mono,ui-monospace);letter-spacing:.04em;text-transform:uppercase;color:var(--color-text-tertiary);margin:18px 0 8px}
12
+ .sp-h:first-child{margin-top:0}
13
+ .sp-row{display:grid;grid-template-columns:minmax(120px,180px) 1fr;align-items:baseline;gap:12px;padding:4px 0}
14
+ .sp-lb{font:400 12px/1.4 var(--font-mono,ui-monospace);color:var(--color-text-secondary);white-space:nowrap}
15
+ .sp-lb b{font-weight:400;color:var(--color-text-tertiary)}
16
+ .sp-tx{line-height:1.15;overflow-wrap:anywhere}
17
+ .sp-bar{display:block;height:12px;border-radius:2px;background:var(--color-text-info,currentColor);opacity:.7}
18
+ .sp-none{font-size:13px;color:var(--color-text-secondary)}
19
+ `;
20
+ // Each label's px follows the frame's width, so a fluid step reads what it is here.
21
+ const MEASURE_JS = `<script>(function(){function m(){document.querySelectorAll("[data-sp]").forEach(function(r){var t=r.querySelector("[data-sp-of]");var o=r.querySelector("b");if(!t||!o)return;var cs=getComputedStyle(t);var v=r.getAttribute("data-sp")==="space"?t.getBoundingClientRect().width:parseFloat(cs.fontSize);o.textContent=" "+Math.round(v*10)/10+"px"})}addEventListener("load",m);addEventListener("resize",m)})();</script>`;
22
+ export function specimenHtml(system) {
23
+ const out = [`<style>${CSS}</style>`];
24
+ const type = system.scales.filter((s) => s.family === "type");
25
+ const space = system.scales.filter((s) => s.family === "space");
26
+ for (const scale of type) {
27
+ out.push(`<div class="sp-h">${escapeHtml(scale.id)}</div>`);
28
+ for (const { step } of [...scaleSteps(scale)].reverse()) {
29
+ const name = `${scale.prefix}-${step}`;
30
+ out.push(`<div class="sp-row" data-sp="type"><span class="sp-lb">--${escapeHtml(name)}<b></b></span><span class="sp-tx" data-sp-of style="font-size:var(--${name})">${SAMPLE}</span></div>`);
31
+ }
32
+ }
33
+ for (const scale of space) {
34
+ out.push(`<div class="sp-h">${escapeHtml(scale.id)}</div>`);
35
+ for (const { step } of scaleSteps(scale)) {
36
+ const name = `${scale.prefix}-${step}`;
37
+ out.push(`<div class="sp-row" data-sp="space"><span class="sp-lb">--${escapeHtml(name)}<b></b></span><span><i class="sp-bar" data-sp-of style="width:var(--${name})"></i></span></div>`);
38
+ }
39
+ }
40
+ if (system.fonts.length)
41
+ out.push(`<div class="sp-h">fonts</div>`);
42
+ for (const f of system.fonts) {
43
+ out.push(`<div class="sp-row"><span class="sp-lb">${escapeHtml(f.family)}</span><span class="sp-tx" style="font-family:${escapeHtml(JSON.stringify(f.family))};font-size:20px">${SAMPLE}</span></div>`);
44
+ }
45
+ if (out.length === 1)
46
+ out.push(`<p class="sp-none">No scales or fonts yet.</p>`);
47
+ out.push(MEASURE_JS);
48
+ return out.join("\n");
49
+ }
@@ -5,7 +5,7 @@ export interface Accent {
5
5
  solid?: string;
6
6
  onSolid?: string;
7
7
  }
8
- export interface Palette {
8
+ export interface ChromeColors {
9
9
  bg: string;
10
10
  panel: string;
11
11
  surface: string;
@@ -27,10 +27,11 @@ export interface Theme {
27
27
  light: string;
28
28
  dark: string;
29
29
  };
30
- light: Palette;
31
- dark: Palette;
30
+ light: ChromeColors;
31
+ dark: ChromeColors;
32
32
  }
33
33
  export type Mode = "light" | "dark";
34
+ export declare const CHROME_TOKENS: readonly ["color-background-primary", "color-background-secondary", "color-background-tertiary", "color-background-info", "color-background-success", "color-background-warning", "color-background-danger", "color-text-primary", "color-text-secondary", "color-text-tertiary", "color-text-info", "color-text-success", "color-text-warning", "color-text-danger", "color-border-primary", "color-border-secondary", "color-border-tertiary", "color-border-info", "color-border-success", "color-border-warning", "color-border-danger"];
34
35
  export declare function schemeCss(light: Record<string, string>, dark: Record<string, string>, mode?: Mode): string;
35
36
  export declare function viewerThemeCss(t: Theme, mode?: Mode): string;
36
37
  export declare function tokenThemeCss(t: Theme, mode?: Mode): string;
@@ -44,7 +45,8 @@ export declare const themeOptions: () => {
44
45
  id: string;
45
46
  label: string;
46
47
  }[];
47
- export declare function paletteFromCssVars(cssVars: string): {
48
- light: Palette;
49
- dark: Palette;
50
- } | null;
48
+ export declare function normalizeCssColor(raw: string): string | null;
49
+ export declare function repoCustomProps(css: string): {
50
+ light: Record<string, string>;
51
+ dark: Record<string, string>;
52
+ };
@@ -54,6 +54,30 @@ function tokenVars(p) {
54
54
  "color-border-danger": p.danger.border,
55
55
  };
56
56
  }
57
+ // The names of mockpit's own html tokens, reserved in a project's system.
58
+ export const CHROME_TOKENS = [
59
+ "color-background-primary",
60
+ "color-background-secondary",
61
+ "color-background-tertiary",
62
+ "color-background-info",
63
+ "color-background-success",
64
+ "color-background-warning",
65
+ "color-background-danger",
66
+ "color-text-primary",
67
+ "color-text-secondary",
68
+ "color-text-tertiary",
69
+ "color-text-info",
70
+ "color-text-success",
71
+ "color-text-warning",
72
+ "color-text-danger",
73
+ "color-border-primary",
74
+ "color-border-secondary",
75
+ "color-border-tertiary",
76
+ "color-border-info",
77
+ "color-border-success",
78
+ "color-border-warning",
79
+ "color-border-danger",
80
+ ];
57
81
  // Terminal chrome. Always sourced from the theme's DARK palette (a terminal
58
82
  // reads as a terminal — ANSI output assumes a dark backdrop — so it stays dark
59
83
  // in light mode too), but tinted to the theme so it doesn't look foreign.
@@ -159,23 +183,15 @@ export function themeById(_id) {
159
183
  return DIALKIT;
160
184
  }
161
185
  export const themeOptions = () => THEMES.map((t) => ({ id: t.id, label: t.label }));
162
- // --- Imported project palettes -------------------------------------------
186
+ // --- A repo's own custom properties -----------------------------------------
163
187
  //
164
- // `mockpit init` reads a repo's own design tokens (a `:root{}` / `@theme`
165
- // block from Tailwind or shadcn) and stores them per project. The raw text is
166
- // injected verbatim into the html-surface sandbox, but the VIEWER chrome and
167
- // the `--color-*` contract still need a Palette — so the recognised token
168
- // names are mapped onto one here, runtime-agnostic like the rest of this file.
169
- //
170
- // Anything the repo doesn't declare falls back per field to the default theme,
171
- // so a partial import can never leave a color undefined (an undefined token
172
- // renders as unstyled black-on-white, which reads as "mockpit is broken"
173
- // rather than "your repo declares no border color").
188
+ // `mockpit init` stores a repo's raw `:root{}` / `.dark{}` / `@theme{}` text per
189
+ // project; the system (server/system.ts) types what it can of it.
174
190
  // Colour values arrive in three dialects: real CSS colors (`#fff`,
175
191
  // `oklch(...)`), shadcn's channel-only triplets (`0 0% 100%`, meant to be
176
192
  // wrapped by `hsl(var(--x))` at the use site), and bare rgb triplets. Only the
177
193
  // first is usable as-is, so the other two are re-wrapped into a function.
178
- function normalizeCssColor(raw) {
194
+ export function normalizeCssColor(raw) {
179
195
  const v = raw.trim().replace(/\s*!important$/, "");
180
196
  if (!v)
181
197
  return null;
@@ -240,86 +256,23 @@ function readCustomProps(declarations) {
240
256
  vars[m[1]] = m[2].trim();
241
257
  return vars;
242
258
  }
243
- // Ordered lookup: first declared name wins, so a repo that defines both
244
- // `--card` and `--background` gets the more specific one for a surface.
245
- function pick(vars, names) {
246
- for (const name of names) {
247
- const raw = vars[name];
248
- if (raw == null)
249
- continue;
250
- const color = normalizeCssColor(raw);
251
- if (color)
252
- return color;
253
- }
254
- return null;
255
- }
256
- function accentFrom(vars, bg, text, border, fallback) {
257
- const ink = pick(vars, text);
258
- return {
259
- bg: pick(vars, bg) ?? fallback.bg,
260
- text: ink ?? fallback.text,
261
- border: pick(vars, border) ?? fallback.border,
262
- // The default's solid pair belongs to the default's ink; a repo's own ink
263
- // fills with itself.
264
- ...(ink ? {} : { solid: fallback.solid, onSolid: fallback.onSolid }),
265
- };
266
- }
267
- function paletteFrom(vars, fallback) {
268
- return {
269
- bg: pick(vars, ["background", "color-background", "body-background"]) ?? fallback.bg,
270
- panel: pick(vars, ["muted", "secondary", "color-muted", "popover"]) ?? fallback.panel,
271
- surface: pick(vars, ["card", "popover", "background", "color-background"]) ?? fallback.surface,
272
- text: pick(vars, ["foreground", "color-foreground", "card-foreground"]) ?? fallback.text,
273
- muted: pick(vars, ["muted-foreground", "secondary-foreground"]) ?? fallback.muted,
274
- faint: pick(vars, ["muted-foreground", "secondary-foreground"]) ?? fallback.faint,
275
- border: pick(vars, ["border", "color-border", "input"]) ?? fallback.border,
276
- border2: pick(vars, ["input", "ring", "border", "color-border"]) ?? fallback.border2,
277
- hover: pick(vars, ["accent", "muted", "secondary"]) ?? fallback.hover,
278
- // `info` doubles as the accent/link color everywhere in the viewer, so it
279
- // is the one that must track the project's brand: primary for ink, accent
280
- // for the wash, ring for the focus edge.
281
- info: accentFrom(vars, ["accent", "primary-foreground", "secondary"], ["primary", "ring", "accent-foreground"], ["ring", "primary", "border"], fallback.info),
282
- // Tailwind/shadcn ship no success or warning token, so these stay on the
283
- // default theme unless a repo happens to declare them.
284
- success: accentFrom(vars, ["success-background", "success-bg"], ["success", "success-foreground"], ["success-border", "success"], fallback.success),
285
- warning: accentFrom(vars, ["warning-background", "warning-bg"], ["warning", "warning-foreground"], ["warning-border", "warning"], fallback.warning),
286
- danger: accentFrom(vars, ["destructive-background", "destructive-bg", "danger-bg"], ["destructive", "danger", "destructive-foreground"], ["destructive-border", "destructive", "danger"], fallback.danger),
287
- };
288
- }
289
- // Map a repo's CSS custom properties onto the two Palettes a theme needs.
290
- // Returns null when the text declares no recognisable color token at all —
291
- // the caller (`mockpit init`) then keeps the default theme rather than
292
- // storing a palette that is 100% fallback.
293
- //
294
- // `--radius` is intentionally NOT mapped: Palette carries colors only, and the
295
- // raw block is injected into the sandbox verbatim, so the repo's radius reaches
296
- // surfaces through `var(--radius)` without a second source of truth.
297
- export function paletteFromCssVars(cssVars) {
298
- if (!cssVars || !cssVars.trim())
299
- return null;
300
- const blocks = [];
301
- // Strip comments first so a commented-out token can't win the ordered pick.
302
- collectBlocks(cssVars.replace(/\/\*[\s\S]*?\*\//g, ""), false, blocks);
303
- // A bare declaration list (no selector) is treated as the light root, so
304
- // callers may pass either `:root{…}` or just its contents.
305
- if (blocks.length === 0)
306
- blocks.push({ selector: ":root", dark: false, declarations: cssVars });
259
+ // The custom properties a repo's root-level blocks declare, light and dark. Only
260
+ // blocks that set page-wide values count (`:root`, `html`, `@theme`, `.dark`, a
261
+ // dark media query), so a component's local `--x` isn't read as a project token.
262
+ const ROOT_SELECTOR = /^(:root|html|:host|@theme\b|\.dark\b|:root\.dark|html\.dark|\[data-theme=["']?dark["']?\]|:root\[data-theme=["']?dark["']?\])/i;
263
+ export function repoCustomProps(css) {
307
264
  const light = {};
308
265
  const dark = {};
266
+ if (!css.trim())
267
+ return { light, dark };
268
+ const blocks = [];
269
+ collectBlocks(css.replace(/\/\*[\s\S]*?\*\//g, ""), false, blocks);
270
+ if (blocks.length === 0)
271
+ blocks.push({ selector: ":root", dark: false, declarations: css });
309
272
  for (const b of blocks) {
310
- const vars = readCustomProps(b.declarations);
311
- Object.assign(b.dark ? dark : light, vars);
273
+ if (!ROOT_SELECTOR.test(b.selector.trim()) && !(b.dark && /:root|html/.test(b.selector)))
274
+ continue;
275
+ Object.assign(b.dark ? dark : light, readCustomProps(b.declarations));
312
276
  }
313
- const base = themeById(DEFAULT_THEME_ID);
314
- const lightPalette = paletteFrom(light, base.light);
315
- // Dark inherits the light declarations first: a repo that only overrides
316
- // `--background`/`--foreground` in `.dark` still gets its brand `--primary`.
317
- // A repo with no dark block at all is single-palette (Loom, for one, is
318
- // dark-only with everything in `:root`) — reusing the same values in both
319
- // schemes keeps it looking like itself, where falling back to the default
320
- // theme's dark would make the two modes look like two different products.
321
- const darkPalette = paletteFrom({ ...light, ...dark }, base.dark);
322
- const recognised = JSON.stringify(lightPalette) !== JSON.stringify(base.light) ||
323
- JSON.stringify(darkPalette) !== JSON.stringify(base.dark);
324
- return recognised ? { light: lightPalette, dark: darkPalette } : null;
277
+ return { light, dark };
325
278
  }