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.
- package/README.md +11 -23
- package/bin/initDesign.js +11 -16
- package/bin/mockpit.js +242 -52
- package/bin/poster.js +64 -7
- package/dist/mcp/server.js +48 -6
- package/dist/server/apiViews.d.ts +562 -557
- package/dist/server/apiViews.js +18 -9
- package/dist/server/app.js +723 -206
- package/dist/server/cdnInline.js +6 -4
- package/dist/server/codeWorker.js +2 -2
- package/dist/server/contrast.d.ts +4 -0
- package/dist/server/contrast.js +157 -0
- package/dist/server/decisionList.d.ts +4 -1
- package/dist/server/decisionList.js +43 -5
- package/dist/server/designGuide.d.ts +13 -6
- package/dist/server/designGuide.js +206 -41
- package/dist/server/events.d.ts +6 -0
- package/dist/server/feedbackBatch.d.ts +4 -0
- package/dist/server/feedbackBatch.js +19 -1
- package/dist/server/guidance.d.ts +1 -1
- package/dist/server/guidance.js +2 -2
- package/dist/server/kits.d.ts +2 -2
- package/dist/server/kits.js +54 -1
- package/dist/server/mcpHttp.d.ts +2 -0
- package/dist/server/mcpHttp.js +13 -1
- package/dist/server/mcpSpec.d.ts +27 -27
- package/dist/server/mcpSpec.js +78 -10
- package/dist/server/run.js +22 -1
- package/dist/server/runApi.d.ts +2 -2
- package/dist/server/runApi.js +9 -2
- package/dist/server/sqlStore.d.ts +68 -39
- package/dist/server/sqlStore.js +163 -26
- package/dist/server/surfacePage.d.ts +4 -2
- package/dist/server/surfacePage.js +80 -32
- package/dist/server/system.d.ts +234 -0
- package/dist/server/system.js +1508 -0
- package/dist/server/systemChecks.d.ts +15 -0
- package/dist/server/systemChecks.js +224 -0
- package/dist/server/systemSpecimen.d.ts +2 -0
- package/dist/server/systemSpecimen.js +49 -0
- package/dist/server/themes.d.ts +9 -7
- package/dist/server/themes.js +43 -90
- package/dist/server/types.d.ts +43 -24
- package/dist/server/types.js +3 -1
- package/guide/AGENT_SETUP.md +3 -1
- package/guide/packs/foundations.md +35 -0
- package/guide/topics/asks.md +2 -1
- package/guide/topics/feedback.md +5 -3
- package/guide/topics/html.md +2 -2
- package/guide/topics/http.md +3 -1
- package/guide/topics/system.md +157 -0
- package/package.json +12 -12
- package/skills/mockpit/SKILL.md +7 -4
- package/viewer/dist/index.html +9 -8
|
@@ -0,0 +1,1508 @@
|
|
|
1
|
+
// The project's system: a shared stylesheet, typed tokens, scales, palettes,
|
|
2
|
+
// project knobs and checks. Every render in the project wears it, the brief
|
|
3
|
+
// indexes it, and every write is checked against it. This module owns the
|
|
4
|
+
// model, its validation, scale expansion, what `mockpit init` read turned into
|
|
5
|
+
// repo-owned tokens, and the CSS a frame gets. Mockpit picks no look here: it
|
|
6
|
+
// expands scales and emits what the agent wrote.
|
|
7
|
+
//
|
|
8
|
+
// Runtime-agnostic (no node imports): the Worker and SqlStore's migration
|
|
9
|
+
// import it.
|
|
10
|
+
import { CHROME_TOKENS, normalizeCssColor, repoCustomProps } from "./themes.js";
|
|
11
|
+
import { explicitKnob } from "./knobs.js";
|
|
12
|
+
// Versions published from now on render with cascade layers; older ones keep
|
|
13
|
+
// the cascade they were approved under (absent on a version means 1).
|
|
14
|
+
export const CURRENT_CONTRACT = 2;
|
|
15
|
+
export const SYSTEM_LIMITS = {
|
|
16
|
+
sheet: 128_000,
|
|
17
|
+
tokens: 400,
|
|
18
|
+
palettes: 40,
|
|
19
|
+
roles: 120,
|
|
20
|
+
pairs: 200,
|
|
21
|
+
revisions: 30,
|
|
22
|
+
classes: 200,
|
|
23
|
+
fonts: 12,
|
|
24
|
+
scales: 12,
|
|
25
|
+
steps: 16,
|
|
26
|
+
note: 300,
|
|
27
|
+
summary: 400,
|
|
28
|
+
};
|
|
29
|
+
export const TOKEN_TYPES = [
|
|
30
|
+
"color",
|
|
31
|
+
"dimension",
|
|
32
|
+
"font",
|
|
33
|
+
"weight",
|
|
34
|
+
"number",
|
|
35
|
+
"shadow",
|
|
36
|
+
"duration",
|
|
37
|
+
"easing",
|
|
38
|
+
"text",
|
|
39
|
+
];
|
|
40
|
+
// The fields of a "text" token, each emitted as `--<name>-<field>`.
|
|
41
|
+
export const TEXT_FIELDS = ["font", "size", "line", "weight", "tracking"];
|
|
42
|
+
export const PALETTE_STATUSES = ["default", "kept", "open", "hidden"];
|
|
43
|
+
export const emptySystem = (project) => ({
|
|
44
|
+
project,
|
|
45
|
+
rev: 0,
|
|
46
|
+
updatedAt: new Date().toISOString(),
|
|
47
|
+
sheet: null,
|
|
48
|
+
fonts: [],
|
|
49
|
+
tokens: [],
|
|
50
|
+
scales: [],
|
|
51
|
+
color: { roles: [], palettes: [], pairs: [] },
|
|
52
|
+
knobs: {},
|
|
53
|
+
settled: {},
|
|
54
|
+
additions: [],
|
|
55
|
+
kit: "none",
|
|
56
|
+
projectKits: [],
|
|
57
|
+
iconSets: [],
|
|
58
|
+
repo: null,
|
|
59
|
+
});
|
|
60
|
+
// A stored system, with anything an older or partial row lacks filled in.
|
|
61
|
+
export function normalizeSystem(project, raw) {
|
|
62
|
+
const base = emptySystem(project);
|
|
63
|
+
if (!raw || typeof raw !== "object")
|
|
64
|
+
return base;
|
|
65
|
+
const s = raw;
|
|
66
|
+
const arr = (v) => (Array.isArray(v) ? v : []);
|
|
67
|
+
const obj = (v, fallback) => v && typeof v === "object" && !Array.isArray(v) ? v : fallback;
|
|
68
|
+
const color = obj(s.color, {});
|
|
69
|
+
return {
|
|
70
|
+
...base,
|
|
71
|
+
rev: typeof s.rev === "number" ? s.rev : 0,
|
|
72
|
+
updatedAt: typeof s.updatedAt === "string" ? s.updatedAt : base.updatedAt,
|
|
73
|
+
sheet: s.sheet && typeof s.sheet.css === "string"
|
|
74
|
+
? { css: s.sheet.css, classes: arr(s.sheet.classes) }
|
|
75
|
+
: null,
|
|
76
|
+
fonts: arr(s.fonts),
|
|
77
|
+
tokens: arr(s.tokens),
|
|
78
|
+
scales: arr(s.scales),
|
|
79
|
+
color: {
|
|
80
|
+
roles: arr(color.roles),
|
|
81
|
+
palettes: arr(color.palettes),
|
|
82
|
+
pairs: arr(color.pairs),
|
|
83
|
+
...(color.as && typeof color.as === "object" ? { as: color.as } : {}),
|
|
84
|
+
},
|
|
85
|
+
knobs: obj(s.knobs, {}),
|
|
86
|
+
settled: obj(s.settled, {}),
|
|
87
|
+
additions: arr(s.additions),
|
|
88
|
+
kit: typeof s.kit === "string" ? s.kit : "none",
|
|
89
|
+
projectKits: arr(s.projectKits),
|
|
90
|
+
iconSets: arr(s.iconSets),
|
|
91
|
+
repo: s.repo && typeof s.repo === "object" ? normalizeRepo(s.repo) : null,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
function normalizeRepo(r) {
|
|
95
|
+
return {
|
|
96
|
+
detected: r.detected ?? null,
|
|
97
|
+
cssVars: typeof r.cssVars === "string" ? r.cssVars : "",
|
|
98
|
+
tailwindCss: typeof r.tailwindCss === "string" ? r.tailwindCss : "",
|
|
99
|
+
strippedImports: Array.isArray(r.strippedImports) ? r.strippedImports : [],
|
|
100
|
+
designFiles: r.designFiles ?? null,
|
|
101
|
+
...(Array.isArray(r.untyped) && r.untyped.length ? { untyped: r.untyped } : {}),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
// --- names and values ---------------------------------------------------------
|
|
105
|
+
const NAME_RE = /^[a-z][a-z0-9-]{0,47}$/;
|
|
106
|
+
const RESERVED = new Set(CHROME_TOKENS);
|
|
107
|
+
export function checkName(name, what) {
|
|
108
|
+
if (typeof name !== "string" || !NAME_RE.test(name))
|
|
109
|
+
return `${what} "${String(name)}" must match ^[a-z][a-z0-9-]{0,47}$`;
|
|
110
|
+
if (name.startsWith("k-"))
|
|
111
|
+
return `${what} "${name}": the k- prefix is reserved for knobs`;
|
|
112
|
+
if (RESERVED.has(name))
|
|
113
|
+
return `${what} "${name}" is one of mockpit's own tokens`;
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
// What a value inside a custom property may hold: the characters CSS values
|
|
117
|
+
// need, nothing that ends a declaration or a block, and no function that fetches.
|
|
118
|
+
const VALUE_RE = /^[#\w\s(),.%/+*'"-]{1,300}$/;
|
|
119
|
+
const FETCH_RE = /\b(url|image|image-set|src|expression)\s*\(/i;
|
|
120
|
+
export const isSafeValue = (v) => typeof v === "string" && VALUE_RE.test(v) && !FETCH_RE.test(v);
|
|
121
|
+
const NUM = "-?(\\d+\\.?\\d*|\\.\\d+)";
|
|
122
|
+
const DIMENSION_RE = new RegExp(`^(0|${NUM}(px|rem|em|%|vw|vh|vi|vb|vmin|vmax|ch|ex|lh|rlh|cqi|cqw|svh|dvh|lvh))$`);
|
|
123
|
+
const DURATION_RE = new RegExp(`^${NUM}(ms|s)$`);
|
|
124
|
+
const MATH_RE = /^(calc|clamp|min|max|var)\(/;
|
|
125
|
+
function checkValue(type, value, name) {
|
|
126
|
+
if (type === "text") {
|
|
127
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
128
|
+
return `token "${name}": a text token's value is {font, size, line, weight, tracking}`;
|
|
129
|
+
for (const [field, v] of Object.entries(value)) {
|
|
130
|
+
if (!TEXT_FIELDS.includes(field))
|
|
131
|
+
return `token "${name}": unknown text field "${field}" (${TEXT_FIELDS.join(", ")})`;
|
|
132
|
+
if (!isSafeValue(v))
|
|
133
|
+
return `token "${name}.${field}": not a plain CSS value`;
|
|
134
|
+
}
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
if (!isSafeValue(value))
|
|
138
|
+
return `token "${name}": not a plain CSS value`;
|
|
139
|
+
const v = value.trim();
|
|
140
|
+
const ok = type === "color"
|
|
141
|
+
? isColorValue(v) || MATH_RE.test(v) || v.startsWith("color-mix(")
|
|
142
|
+
: type === "dimension"
|
|
143
|
+
? DIMENSION_RE.test(v) || MATH_RE.test(v)
|
|
144
|
+
: type === "duration"
|
|
145
|
+
? DURATION_RE.test(v) || v.startsWith("var(")
|
|
146
|
+
: type === "weight"
|
|
147
|
+
? /^([1-9]00|[1-9]\d{0,2}|normal|bold|bolder|lighter)$/.test(v) || v.startsWith("var(")
|
|
148
|
+
: type === "number"
|
|
149
|
+
? new RegExp(`^${NUM}$`).test(v) || MATH_RE.test(v)
|
|
150
|
+
: true;
|
|
151
|
+
return ok ? null : `token "${name}": "${v}" is not a ${type}`;
|
|
152
|
+
}
|
|
153
|
+
const NAMED_COLORS = new Set("transparent currentcolor black white red green blue yellow orange purple pink gray grey silver maroon olive lime aqua teal navy fuchsia cyan magenta brown gold indigo violet coral salmon crimson tomato khaki plum orchid tan beige ivory lavender linen azure snow mintcream honeydew seashell wheat chocolate sienna peru firebrick darkred darkgreen darkblue darkgray darkgrey lightgray lightgrey dimgray dimgrey slategray slategrey whitesmoke gainsboro".split(" "));
|
|
154
|
+
// A colour literal or a colour function; not a var() or a dimension.
|
|
155
|
+
export function isColorValue(v) {
|
|
156
|
+
const s = v.trim().toLowerCase();
|
|
157
|
+
if (/^#[0-9a-f]{3,8}$/.test(s))
|
|
158
|
+
return [4, 5, 7, 9].includes(s.length);
|
|
159
|
+
if (/^(rgba?|hsla?|hwb|lab|lch|oklab|oklch|color|color-mix|light-dark)\(/.test(s))
|
|
160
|
+
return true;
|
|
161
|
+
return NAMED_COLORS.has(s);
|
|
162
|
+
}
|
|
163
|
+
// --- scales ------------------------------------------------------------------
|
|
164
|
+
const round = (n, places = 3) => {
|
|
165
|
+
const f = 10 ** places;
|
|
166
|
+
return Math.round(n * f) / f;
|
|
167
|
+
};
|
|
168
|
+
const rem = (px) => `${round(px / 16, 4)}rem`;
|
|
169
|
+
// Each step's px at the smallest and largest width (equal for a fixed scale).
|
|
170
|
+
export function scaleSteps(scale) {
|
|
171
|
+
const zero = scale.steps.indexOf(scale.zero);
|
|
172
|
+
return scale.steps.map((step, i) => {
|
|
173
|
+
const e = i - zero;
|
|
174
|
+
const min = scale.base * scale.ratio ** e;
|
|
175
|
+
const max = scale.fluid ? scale.fluid.maxBase * scale.fluid.maxRatio ** e : min;
|
|
176
|
+
return { step, min: round(min, 2), max: round(max, 2) };
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
// One step's CSS value: rem for a fixed scale, a clamp() between the widths for
|
|
180
|
+
// a fluid one (the slope runs through both endpoints).
|
|
181
|
+
export function stepValue(scale, min, max) {
|
|
182
|
+
if (!scale.fluid || min === max)
|
|
183
|
+
return rem(min);
|
|
184
|
+
const { minWidth, maxWidth } = scale.fluid;
|
|
185
|
+
const slope = (max - min) / (maxWidth - minWidth);
|
|
186
|
+
const intercept = min - slope * minWidth;
|
|
187
|
+
const lo = Math.min(min, max);
|
|
188
|
+
const hi = Math.max(min, max);
|
|
189
|
+
return `clamp(${rem(lo)}, ${rem(intercept)} + ${round(slope * 100, 4)}vw, ${rem(hi)})`;
|
|
190
|
+
}
|
|
191
|
+
export function expandScale(scale) {
|
|
192
|
+
return scaleSteps(scale).map(({ step, min, max }) => ({
|
|
193
|
+
name: `${scale.prefix}-${step}`,
|
|
194
|
+
type: "dimension",
|
|
195
|
+
value: stepValue(scale, min, max),
|
|
196
|
+
scale: scale.id,
|
|
197
|
+
}));
|
|
198
|
+
}
|
|
199
|
+
function checkScale(raw) {
|
|
200
|
+
if (!raw || typeof raw !== "object")
|
|
201
|
+
return "a scale is an object";
|
|
202
|
+
const s = raw;
|
|
203
|
+
if (typeof s.id !== "string" || !NAME_RE.test(s.id))
|
|
204
|
+
return `scale id "${String(s.id)}" must match ^[a-z][a-z0-9-]{0,47}$`;
|
|
205
|
+
if (s.family !== "type" && s.family !== "space")
|
|
206
|
+
return `scale "${s.id}": family is "type" or "space"`;
|
|
207
|
+
const prefixError = checkName(s.prefix, `scale "${s.id}" prefix`);
|
|
208
|
+
if (prefixError)
|
|
209
|
+
return prefixError;
|
|
210
|
+
if (!Array.isArray(s.steps) || s.steps.length < 2 || s.steps.length > SYSTEM_LIMITS.steps)
|
|
211
|
+
return `scale "${s.id}": 2 to ${SYSTEM_LIMITS.steps} steps`;
|
|
212
|
+
if (!s.steps.every((x) => typeof x === "string" && /^[a-z0-9][a-z0-9-]{0,11}$/.test(x)))
|
|
213
|
+
return `scale "${s.id}": step names are short lowercase words (xs, sm, base, 2xl)`;
|
|
214
|
+
if (new Set(s.steps).size !== s.steps.length)
|
|
215
|
+
return `scale "${s.id}": a step is named twice`;
|
|
216
|
+
if (typeof s.zero !== "string" || !s.steps.includes(s.zero))
|
|
217
|
+
return `scale "${s.id}": zero names the base step, one of ${s.steps.join(", ")}`;
|
|
218
|
+
const positive = (n) => typeof n === "number" && Number.isFinite(n) && n > 0;
|
|
219
|
+
if (!positive(s.base) || s.base > 512)
|
|
220
|
+
return `scale "${s.id}": base is px, above 0`;
|
|
221
|
+
if (!positive(s.ratio) || s.ratio < 1 || s.ratio > 4)
|
|
222
|
+
return `scale "${s.id}": ratio is between 1 and 4`;
|
|
223
|
+
let fluid;
|
|
224
|
+
if (s.fluid !== undefined) {
|
|
225
|
+
const f = s.fluid;
|
|
226
|
+
if (!f ||
|
|
227
|
+
!positive(f.minWidth) ||
|
|
228
|
+
!positive(f.maxWidth) ||
|
|
229
|
+
f.maxWidth <= f.minWidth ||
|
|
230
|
+
!positive(f.maxBase) ||
|
|
231
|
+
!positive(f.maxRatio) ||
|
|
232
|
+
f.maxRatio < 1 ||
|
|
233
|
+
f.maxRatio > 4)
|
|
234
|
+
return `scale "${s.id}": fluid is {minWidth, maxWidth, maxBase, maxRatio}, maxWidth above minWidth`;
|
|
235
|
+
fluid = {
|
|
236
|
+
minWidth: f.minWidth,
|
|
237
|
+
maxWidth: f.maxWidth,
|
|
238
|
+
maxBase: f.maxBase,
|
|
239
|
+
maxRatio: f.maxRatio,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
return {
|
|
243
|
+
id: s.id,
|
|
244
|
+
family: s.family,
|
|
245
|
+
prefix: s.prefix,
|
|
246
|
+
steps: [...s.steps],
|
|
247
|
+
zero: s.zero,
|
|
248
|
+
base: s.base,
|
|
249
|
+
ratio: s.ratio,
|
|
250
|
+
...(fluid ? { fluid } : {}),
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
// --- the repo's own tokens ---------------------------------------------------------
|
|
254
|
+
// Tailwind's theme namespaces, longest first so `font-weight-` beats `font-`.
|
|
255
|
+
const TAILWIND_NAMESPACES = [
|
|
256
|
+
[/^font-weight-/, "weight"],
|
|
257
|
+
[/^(font|font-family)-/, "font"],
|
|
258
|
+
[/^text-[a-z0-9-]+$/, "dimension"],
|
|
259
|
+
[/^(spacing|spacing-.+)$/, "dimension"],
|
|
260
|
+
[/^(radius|radius-.+)$/, "dimension"],
|
|
261
|
+
[/^(shadow|shadow-.+|inset-shadow-.+|drop-shadow-.+)$/, "shadow"],
|
|
262
|
+
[/^ease-/, "easing"],
|
|
263
|
+
[/^(leading|tracking)-/, "dimension"],
|
|
264
|
+
[/^(breakpoint|container)-/, "dimension"],
|
|
265
|
+
];
|
|
266
|
+
function typeByValue(v) {
|
|
267
|
+
const s = v.trim();
|
|
268
|
+
if (isColorValue(s) ||
|
|
269
|
+
normalizeCssColor(s)?.startsWith("hsl(") ||
|
|
270
|
+
normalizeCssColor(s)?.startsWith("rgb("))
|
|
271
|
+
return "color";
|
|
272
|
+
if (DIMENSION_RE.test(s))
|
|
273
|
+
return "dimension";
|
|
274
|
+
if (DURATION_RE.test(s))
|
|
275
|
+
return "duration";
|
|
276
|
+
if (/^cubic-bezier\(|^steps\(|^(linear|ease|ease-in|ease-out|ease-in-out)$/.test(s))
|
|
277
|
+
return "easing";
|
|
278
|
+
return null;
|
|
279
|
+
}
|
|
280
|
+
// A repo colour as a usable CSS colour, or null when it isn't one. The value
|
|
281
|
+
// reaches the viewer's swatches, so a colour is also a plain value: a prefix
|
|
282
|
+
// that looks like a colour can still carry a url().
|
|
283
|
+
function repoColor(raw) {
|
|
284
|
+
const v = normalizeCssColor(raw);
|
|
285
|
+
if (!v || !isSafeValue(v))
|
|
286
|
+
return null;
|
|
287
|
+
return isColorValue(v) ? v : null;
|
|
288
|
+
}
|
|
289
|
+
// Replaces every repo-owned token, role and palette with what `repo` says now.
|
|
290
|
+
// Colours of the `:root` and `.dark` blocks become roles and the `repo`
|
|
291
|
+
// palette; a colour with no dark value is a constant. Other families are typed
|
|
292
|
+
// by Tailwind's namespace or a DTCG file's type. A system-owned name the repo
|
|
293
|
+
// now brings is reported, not overwritten.
|
|
294
|
+
export function deriveRepoTokens(system) {
|
|
295
|
+
const previousRepo = system.color.palettes.find((p) => p.owner === "repo");
|
|
296
|
+
const keepTokens = system.tokens.filter((t) => t.owner !== "repo");
|
|
297
|
+
const keepRoles = system.color.roles.filter((r) => r.owner !== "repo");
|
|
298
|
+
const keepPalettes = system.color.palettes.filter((p) => p.owner !== "repo");
|
|
299
|
+
const repo = system.repo;
|
|
300
|
+
if (!repo) {
|
|
301
|
+
return {
|
|
302
|
+
system: {
|
|
303
|
+
...system,
|
|
304
|
+
tokens: keepTokens,
|
|
305
|
+
color: { ...system.color, roles: keepRoles, palettes: keepPalettes },
|
|
306
|
+
},
|
|
307
|
+
clashes: [],
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
const { light, dark } = repoCustomProps(`${repo.cssVars}\n${repo.tailwindCss}`);
|
|
311
|
+
const taken = new Set([
|
|
312
|
+
...keepTokens.map((t) => t.name),
|
|
313
|
+
...keepRoles.map((r) => r.name),
|
|
314
|
+
...system.scales.flatMap((s) => s.steps.map((x) => `${s.prefix}-${x}`)),
|
|
315
|
+
]);
|
|
316
|
+
const clashes = [];
|
|
317
|
+
const tokens = [];
|
|
318
|
+
const roles = [];
|
|
319
|
+
const paletteLight = {};
|
|
320
|
+
const paletteDark = {};
|
|
321
|
+
const untyped = [];
|
|
322
|
+
const claim = (name) => {
|
|
323
|
+
if (!NAME_RE.test(name) || RESERVED.has(name) || name.startsWith("k-"))
|
|
324
|
+
return false;
|
|
325
|
+
if (taken.has(name)) {
|
|
326
|
+
clashes.push(name);
|
|
327
|
+
return false;
|
|
328
|
+
}
|
|
329
|
+
taken.add(name);
|
|
330
|
+
return true;
|
|
331
|
+
};
|
|
332
|
+
// shadcn and its kin name things through one indirection
|
|
333
|
+
// (`--background: var(--t-bg)`): a name is what the one it points at is, per mode,
|
|
334
|
+
// since a dark block may redefine only the target.
|
|
335
|
+
const target = (v) => v === undefined ? null : (/^var\(\s*--([a-z0-9-]+)\s*\)$/i.exec(v.trim())?.[1] ?? null);
|
|
336
|
+
const hop = (v, mode) => {
|
|
337
|
+
const to = target(v);
|
|
338
|
+
if (!to)
|
|
339
|
+
return v;
|
|
340
|
+
return (mode === "dark" ? (dark[to] ?? light[to]) : light[to]) ?? v;
|
|
341
|
+
};
|
|
342
|
+
const namespace = (name) => TAILWIND_NAMESPACES.find(([re]) => re.test(name))?.[1] ?? null;
|
|
343
|
+
for (const name of new Set([...Object.keys(light), ...Object.keys(dark)])) {
|
|
344
|
+
const lv = hop(light[name], "light");
|
|
345
|
+
const dv = dark[name] !== undefined
|
|
346
|
+
? hop(dark[name], "dark")
|
|
347
|
+
: target(light[name])
|
|
348
|
+
? hop(light[name], "dark")
|
|
349
|
+
: undefined;
|
|
350
|
+
const l = lv !== undefined ? repoColor(lv) : null;
|
|
351
|
+
const d = dv !== undefined ? repoColor(dv) : null;
|
|
352
|
+
if (l || d) {
|
|
353
|
+
if (!claim(name))
|
|
354
|
+
continue;
|
|
355
|
+
if (d && d !== l) {
|
|
356
|
+
roles.push({ name, note: "", owner: "repo" });
|
|
357
|
+
paletteLight[name] = l ?? d;
|
|
358
|
+
paletteDark[name] = d;
|
|
359
|
+
}
|
|
360
|
+
else {
|
|
361
|
+
tokens.push({ name, type: "color", value: (l ?? d), owner: "repo" });
|
|
362
|
+
}
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
const raw = light[name] ?? dark[name];
|
|
366
|
+
const to = target(raw);
|
|
367
|
+
const pointed = to ? hop(raw, "light") : undefined;
|
|
368
|
+
const type = namespace(name) ??
|
|
369
|
+
(to && pointed !== undefined && pointed !== raw
|
|
370
|
+
? (namespace(to) ?? typeByValue(pointed))
|
|
371
|
+
: null);
|
|
372
|
+
if (!type || !isSafeValue(raw) || checkValue(type, raw, name)) {
|
|
373
|
+
if (untyped.length < 200)
|
|
374
|
+
untyped.push(name);
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
if (claim(name))
|
|
378
|
+
tokens.push({ name, type, value: raw.trim(), owner: "repo" });
|
|
379
|
+
}
|
|
380
|
+
const dtcg = repo.designFiles?.tokens;
|
|
381
|
+
if (dtcg) {
|
|
382
|
+
for (const [dotted, value] of Object.entries(dtcg.values)) {
|
|
383
|
+
const name = dotted.replace(/\./g, "-").toLowerCase();
|
|
384
|
+
if (taken.has(name) && tokens.some((t) => t.name === name))
|
|
385
|
+
continue;
|
|
386
|
+
const declared = dtcg.types?.[dotted];
|
|
387
|
+
const type = declared ? dtcgType(declared) : typeByValue(value);
|
|
388
|
+
if (!type || value.startsWith("{") || checkValue(type, value, name)) {
|
|
389
|
+
if (untyped.length < 200)
|
|
390
|
+
untyped.push(name);
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
if (claim(name))
|
|
394
|
+
tokens.push({ name, type, value, owner: "repo" });
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
const palettes = [...keepPalettes];
|
|
398
|
+
if (roles.length) {
|
|
399
|
+
const hasDefault = keepPalettes.some((p) => p.status === "default");
|
|
400
|
+
// Rereading the repo keeps what the user made of its palette (a Hide stays);
|
|
401
|
+
// it is the default only while no other palette is.
|
|
402
|
+
const was = previousRepo?.status;
|
|
403
|
+
const status = was === undefined
|
|
404
|
+
? hasDefault
|
|
405
|
+
? "kept"
|
|
406
|
+
: "default"
|
|
407
|
+
: was === "default" && hasDefault
|
|
408
|
+
? "kept"
|
|
409
|
+
: was;
|
|
410
|
+
palettes.unshift({
|
|
411
|
+
id: "repo",
|
|
412
|
+
label: "Repo",
|
|
413
|
+
note: "The repo's :root and .dark blocks",
|
|
414
|
+
light: paletteLight,
|
|
415
|
+
dark: paletteDark,
|
|
416
|
+
...(previousRepo?.scheme ? { scheme: previousRepo.scheme } : {}),
|
|
417
|
+
status,
|
|
418
|
+
owner: "repo",
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
return {
|
|
422
|
+
system: {
|
|
423
|
+
...system,
|
|
424
|
+
tokens: [...tokens.slice(0, SYSTEM_LIMITS.tokens), ...keepTokens],
|
|
425
|
+
color: { ...system.color, roles: [...roles, ...keepRoles], palettes },
|
|
426
|
+
repo: { ...repo, ...(untyped.length ? { untyped } : { untyped: undefined }) },
|
|
427
|
+
},
|
|
428
|
+
clashes,
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
function dtcgType(t) {
|
|
432
|
+
switch (t) {
|
|
433
|
+
case "color":
|
|
434
|
+
return "color";
|
|
435
|
+
case "dimension":
|
|
436
|
+
return "dimension";
|
|
437
|
+
case "fontFamily":
|
|
438
|
+
return "font";
|
|
439
|
+
case "fontWeight":
|
|
440
|
+
return "weight";
|
|
441
|
+
case "number":
|
|
442
|
+
return "number";
|
|
443
|
+
case "shadow":
|
|
444
|
+
return "shadow";
|
|
445
|
+
case "duration":
|
|
446
|
+
return "duration";
|
|
447
|
+
case "cubicBezier":
|
|
448
|
+
return "easing";
|
|
449
|
+
default:
|
|
450
|
+
return null;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
// --- migration ---------------------------------------------------------------
|
|
454
|
+
// The old per-project design settings (`design:<project>`), as a system. Its
|
|
455
|
+
// six-role palette is not carried as colours: each value that a derived role
|
|
456
|
+
// holds points mockpit's matching `--color-*` token at that role.
|
|
457
|
+
export function systemFromDesignSettings(project, raw) {
|
|
458
|
+
const old = (raw && typeof raw === "object" ? raw : {});
|
|
459
|
+
const hasRepo = !!(old.detected || old.cssVars || old.tailwindCss || old.designFiles);
|
|
460
|
+
const base = {
|
|
461
|
+
...emptySystem(project),
|
|
462
|
+
updatedAt: old.updatedAt ?? new Date().toISOString(),
|
|
463
|
+
kit: typeof old.kit === "string" ? old.kit : "none",
|
|
464
|
+
projectKits: Array.isArray(old.projectKits) ? old.projectKits : [],
|
|
465
|
+
iconSets: Array.isArray(old.iconSets) ? old.iconSets : [],
|
|
466
|
+
repo: hasRepo
|
|
467
|
+
? normalizeRepo({
|
|
468
|
+
detected: old.detected ?? null,
|
|
469
|
+
cssVars: old.cssVars ?? "",
|
|
470
|
+
tailwindCss: old.tailwindCss ?? "",
|
|
471
|
+
strippedImports: old.strippedImports ?? [],
|
|
472
|
+
designFiles: old.designFiles ?? null,
|
|
473
|
+
})
|
|
474
|
+
: null,
|
|
475
|
+
};
|
|
476
|
+
const { system } = deriveRepoTokens(base);
|
|
477
|
+
const p = old.palette?.light;
|
|
478
|
+
if (!p)
|
|
479
|
+
return system;
|
|
480
|
+
// Each name that holds a colour in light: the repo palette's roles and the repo's
|
|
481
|
+
// constant colours. A value two names hold says nothing about which was meant.
|
|
482
|
+
const holders = new Map();
|
|
483
|
+
const hold = (name, value) => {
|
|
484
|
+
const key = value.trim().toLowerCase();
|
|
485
|
+
holders.set(key, [...(holders.get(key) ?? []), name]);
|
|
486
|
+
};
|
|
487
|
+
const repoPalette = system.color.palettes.find((x) => x.owner === "repo");
|
|
488
|
+
for (const [role, value] of Object.entries(repoPalette?.light ?? {}))
|
|
489
|
+
hold(role, value);
|
|
490
|
+
for (const t of system.tokens)
|
|
491
|
+
if (t.owner === "repo" && t.type === "color" && typeof t.value === "string")
|
|
492
|
+
hold(t.name, t.value);
|
|
493
|
+
const pairs = [
|
|
494
|
+
["color-background-primary", p.surface],
|
|
495
|
+
["color-background-secondary", p.panel],
|
|
496
|
+
["color-text-primary", p.text],
|
|
497
|
+
["color-text-secondary", p.muted],
|
|
498
|
+
["color-border-secondary", p.border],
|
|
499
|
+
["color-text-info", p.info?.text],
|
|
500
|
+
["color-text-danger", p.danger?.text],
|
|
501
|
+
];
|
|
502
|
+
const as = {};
|
|
503
|
+
for (const [token, value] of pairs) {
|
|
504
|
+
if (!value)
|
|
505
|
+
continue;
|
|
506
|
+
const names = holders.get(value.trim().toLowerCase()) ?? [];
|
|
507
|
+
if (names.length === 1)
|
|
508
|
+
as[token] = names[0];
|
|
509
|
+
}
|
|
510
|
+
return Object.keys(as).length ? { ...system, color: { ...system.color, as } } : system;
|
|
511
|
+
}
|
|
512
|
+
export const SYSTEM_WRITE_KEYS = [
|
|
513
|
+
"sheet",
|
|
514
|
+
"classes",
|
|
515
|
+
"fonts",
|
|
516
|
+
"tokens",
|
|
517
|
+
"scales",
|
|
518
|
+
"roles",
|
|
519
|
+
"palettes",
|
|
520
|
+
"pairs",
|
|
521
|
+
"as",
|
|
522
|
+
"knobs",
|
|
523
|
+
"remove",
|
|
524
|
+
"repo",
|
|
525
|
+
"kit",
|
|
526
|
+
"projectKits",
|
|
527
|
+
"removeKits",
|
|
528
|
+
"iconSets",
|
|
529
|
+
];
|
|
530
|
+
// Notes, labels and values are printed one per line in the brief, where a newline
|
|
531
|
+
// would start a heading or break a table row.
|
|
532
|
+
export const oneLine = (v) => v.replace(/\s+/g, " ").trim();
|
|
533
|
+
const isRecord = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
534
|
+
const str = (v, max) => (typeof v === "string" ? v.slice(0, max) : "");
|
|
535
|
+
// The sheet is as trusted as page html (it runs in the same sandbox), so this
|
|
536
|
+
// checks correctness, not safety.
|
|
537
|
+
export function checkSheet(css) {
|
|
538
|
+
if (css.length > SYSTEM_LIMITS.sheet)
|
|
539
|
+
return `sheet is over ${SYSTEM_LIMITS.sheet / 1000} KB`;
|
|
540
|
+
if (/<\/style/i.test(css))
|
|
541
|
+
return "sheet must not contain </style";
|
|
542
|
+
const bare = css
|
|
543
|
+
.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
544
|
+
.replace(/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/g, '""');
|
|
545
|
+
if (/@import\b/i.test(bare))
|
|
546
|
+
return "sheet must not @import: put web fonts in `fonts` ({family, href})";
|
|
547
|
+
let depth = 0;
|
|
548
|
+
for (const ch of bare) {
|
|
549
|
+
if (ch === "{")
|
|
550
|
+
depth++;
|
|
551
|
+
else if (ch === "}" && --depth < 0)
|
|
552
|
+
return "sheet has a } with no {";
|
|
553
|
+
}
|
|
554
|
+
if (depth !== 0)
|
|
555
|
+
return "sheet has an unclosed {";
|
|
556
|
+
return null;
|
|
557
|
+
}
|
|
558
|
+
function checkClasses(raw) {
|
|
559
|
+
if (!Array.isArray(raw))
|
|
560
|
+
return "classes is a list of {name, note}";
|
|
561
|
+
if (raw.length > SYSTEM_LIMITS.classes)
|
|
562
|
+
return `at most ${SYSTEM_LIMITS.classes} classes`;
|
|
563
|
+
const out = [];
|
|
564
|
+
for (const c of raw) {
|
|
565
|
+
const item = typeof c === "string" ? { name: c, note: "" } : c;
|
|
566
|
+
if (!isRecord(item) ||
|
|
567
|
+
typeof item.name !== "string" ||
|
|
568
|
+
!/^\.?-?[_a-zA-Z][\w-]{0,63}$/.test(item.name))
|
|
569
|
+
return `class "${String(isRecord(item) ? item.name : item)}" is not a class name`;
|
|
570
|
+
const name = item.name.replace(/^\./, "");
|
|
571
|
+
if (out.some((x) => x.name === name))
|
|
572
|
+
return `class "${name}" is listed twice`;
|
|
573
|
+
out.push({ name, note: oneLine(str(item.note, SYSTEM_LIMITS.note)) });
|
|
574
|
+
}
|
|
575
|
+
return out;
|
|
576
|
+
}
|
|
577
|
+
function checkFonts(raw, checkUrl) {
|
|
578
|
+
if (!Array.isArray(raw))
|
|
579
|
+
return "fonts is a list of {family, href?}";
|
|
580
|
+
if (raw.length > SYSTEM_LIMITS.fonts)
|
|
581
|
+
return `at most ${SYSTEM_LIMITS.fonts} fonts`;
|
|
582
|
+
const out = [];
|
|
583
|
+
for (const f of raw) {
|
|
584
|
+
if (!isRecord(f) || typeof f.family !== "string" || !isSafeValue(f.family))
|
|
585
|
+
return "a font is {family, href?}";
|
|
586
|
+
if (f.href !== undefined) {
|
|
587
|
+
if (typeof f.href !== "string")
|
|
588
|
+
return `font "${f.family}": href is a URL`;
|
|
589
|
+
const error = checkUrl(f.href);
|
|
590
|
+
if (error)
|
|
591
|
+
return `font "${f.family}": ${error}`;
|
|
592
|
+
}
|
|
593
|
+
out.push({
|
|
594
|
+
family: oneLine(f.family),
|
|
595
|
+
...(typeof f.href === "string" ? { href: f.href } : {}),
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
return out;
|
|
599
|
+
}
|
|
600
|
+
function checkToken(raw) {
|
|
601
|
+
if (!isRecord(raw))
|
|
602
|
+
return "a token is {name, type, value, note?}";
|
|
603
|
+
const nameError = checkName(raw.name, "token");
|
|
604
|
+
if (nameError)
|
|
605
|
+
return nameError;
|
|
606
|
+
const name = raw.name;
|
|
607
|
+
if (!TOKEN_TYPES.includes(raw.type))
|
|
608
|
+
return `token "${name}": type is one of ${TOKEN_TYPES.join(", ")}`;
|
|
609
|
+
const type = raw.type;
|
|
610
|
+
const valueError = checkValue(type, raw.value, name);
|
|
611
|
+
if (valueError)
|
|
612
|
+
return valueError;
|
|
613
|
+
return {
|
|
614
|
+
name,
|
|
615
|
+
type,
|
|
616
|
+
value: type === "text"
|
|
617
|
+
? Object.fromEntries(Object.entries(raw.value).map(([k, v]) => [k, oneLine(v)]))
|
|
618
|
+
: oneLine(raw.value),
|
|
619
|
+
...(typeof raw.note === "string" && raw.note.trim()
|
|
620
|
+
? { note: oneLine(raw.note).slice(0, SYSTEM_LIMITS.note) }
|
|
621
|
+
: {}),
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
function checkRole(raw) {
|
|
625
|
+
const item = typeof raw === "string" ? { name: raw, note: "" } : raw;
|
|
626
|
+
if (!isRecord(item))
|
|
627
|
+
return "a role is {name, note}";
|
|
628
|
+
const error = checkName(item.name, "role");
|
|
629
|
+
if (error)
|
|
630
|
+
return error;
|
|
631
|
+
return { name: item.name, note: oneLine(str(item.note, SYSTEM_LIMITS.note)) };
|
|
632
|
+
}
|
|
633
|
+
function checkPalette(raw, previous) {
|
|
634
|
+
if (!isRecord(raw))
|
|
635
|
+
return "a palette is {id, label, note, light, dark, scheme?}";
|
|
636
|
+
if (typeof raw.id !== "string" || !NAME_RE.test(raw.id))
|
|
637
|
+
return `palette id "${String(raw.id)}" must match ^[a-z][a-z0-9-]{0,47}$`;
|
|
638
|
+
const id = raw.id;
|
|
639
|
+
const values = (v, mode) => {
|
|
640
|
+
if (v === undefined && previous)
|
|
641
|
+
return previous[mode];
|
|
642
|
+
if (!isRecord(v))
|
|
643
|
+
return `palette "${id}": ${mode} is {role: colour}`;
|
|
644
|
+
const out = {};
|
|
645
|
+
for (const [role, value] of Object.entries(v)) {
|
|
646
|
+
if (!NAME_RE.test(role))
|
|
647
|
+
return `palette "${id}": role "${role}" is not a name`;
|
|
648
|
+
if (!isSafeValue(value))
|
|
649
|
+
return `palette "${id}" ${mode}.${role}: not a plain CSS colour`;
|
|
650
|
+
out[role] = oneLine(value);
|
|
651
|
+
}
|
|
652
|
+
return out;
|
|
653
|
+
};
|
|
654
|
+
const light = values(raw.light, "light");
|
|
655
|
+
if (typeof light === "string")
|
|
656
|
+
return light;
|
|
657
|
+
const dark = values(raw.dark ?? (previous ? undefined : raw.light), "dark");
|
|
658
|
+
if (typeof dark === "string")
|
|
659
|
+
return dark;
|
|
660
|
+
let scheme = previous?.scheme;
|
|
661
|
+
if (raw.scheme !== undefined) {
|
|
662
|
+
const s = raw.scheme;
|
|
663
|
+
const ok = (x) => x === "light" || x === "dark";
|
|
664
|
+
if (s !== null && (!isRecord(s) || !ok(s.light) || !ok(s.dark)))
|
|
665
|
+
return `palette "${id}": scheme is {light: "light"|"dark", dark: "light"|"dark"}`;
|
|
666
|
+
scheme = s ? { light: s.light, dark: s.dark } : undefined;
|
|
667
|
+
}
|
|
668
|
+
const status = raw.status ?? previous?.status ?? "open";
|
|
669
|
+
if (!PALETTE_STATUSES.includes(status))
|
|
670
|
+
return `palette "${id}": status is one of ${PALETTE_STATUSES.join(", ")}`;
|
|
671
|
+
return {
|
|
672
|
+
id,
|
|
673
|
+
label: oneLine(str(raw.label, 60)) || previous?.label || id,
|
|
674
|
+
note: raw.note === undefined ? (previous?.note ?? "") : oneLine(str(raw.note, SYSTEM_LIMITS.note)),
|
|
675
|
+
light,
|
|
676
|
+
dark,
|
|
677
|
+
...(scheme ? { scheme } : {}),
|
|
678
|
+
status: status,
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
function checkPairs(raw) {
|
|
682
|
+
if (!Array.isArray(raw))
|
|
683
|
+
return "pairs is a list of [foreground, background, minimum]";
|
|
684
|
+
if (raw.length > SYSTEM_LIMITS.pairs)
|
|
685
|
+
return `at most ${SYSTEM_LIMITS.pairs} pairs`;
|
|
686
|
+
const out = [];
|
|
687
|
+
for (const p of raw) {
|
|
688
|
+
const [fg, bg, min] = Array.isArray(p) ? p : [];
|
|
689
|
+
if (typeof fg !== "string" || typeof bg !== "string" || !NAME_RE.test(fg) || !NAME_RE.test(bg))
|
|
690
|
+
return "a pair is [foreground, background, minimum], by token or role name";
|
|
691
|
+
const ratio = min === undefined ? 4.5 : min;
|
|
692
|
+
if (typeof ratio !== "number" || ratio < 1 || ratio > 21)
|
|
693
|
+
return `pair ${fg} on ${bg}: minimum is a ratio from 1 to 21`;
|
|
694
|
+
out.push([fg, bg, ratio]);
|
|
695
|
+
}
|
|
696
|
+
return out;
|
|
697
|
+
}
|
|
698
|
+
function checkAs(raw) {
|
|
699
|
+
if (raw === null)
|
|
700
|
+
return undefined;
|
|
701
|
+
if (!isRecord(raw))
|
|
702
|
+
return `as is {mockpit token: role}, e.g. {"color-background-primary": "surface"}`;
|
|
703
|
+
const out = {};
|
|
704
|
+
for (const [token, role] of Object.entries(raw)) {
|
|
705
|
+
const key = token.replace(/^--/, "");
|
|
706
|
+
if (!RESERVED.has(key))
|
|
707
|
+
return `as: "${token}" is not one of mockpit's tokens (${CHROME_TOKENS.slice(0, 4).join(", ")}, …)`;
|
|
708
|
+
if (typeof role !== "string" || !NAME_RE.test(role))
|
|
709
|
+
return `as.${key}: name a role or colour token`;
|
|
710
|
+
out[key] = role;
|
|
711
|
+
}
|
|
712
|
+
return out;
|
|
713
|
+
}
|
|
714
|
+
const OWNED_BY_REPO = "owned by the repo: change the code and rerun `mockpit init`";
|
|
715
|
+
// Who emits each custom property: every token (a text token by its fields), every
|
|
716
|
+
// scale step, every role, and every name the repo's own blocks declare. More than
|
|
717
|
+
// one entry under a name is a clash: the last declaration would silently win.
|
|
718
|
+
function nameOwners(system) {
|
|
719
|
+
const out = new Map();
|
|
720
|
+
const add = (name, who) => {
|
|
721
|
+
const list = out.get(name);
|
|
722
|
+
if (!list)
|
|
723
|
+
out.set(name, [who]);
|
|
724
|
+
else if (!list.includes(who))
|
|
725
|
+
list.push(who);
|
|
726
|
+
};
|
|
727
|
+
for (const t of system.tokens) {
|
|
728
|
+
if (t.scale) {
|
|
729
|
+
const step = system.scales.find((sc) => sc.id === t.scale);
|
|
730
|
+
add(t.name, `step "${step ? t.name.slice(step.prefix.length + 1) : t.name}" of scale "${t.scale}"`);
|
|
731
|
+
}
|
|
732
|
+
else if (t.type === "text" && typeof t.value === "object") {
|
|
733
|
+
for (const f of Object.keys(t.value))
|
|
734
|
+
add(`${t.name}-${f}`, `text token "${t.name}"`);
|
|
735
|
+
}
|
|
736
|
+
else
|
|
737
|
+
add(t.name, `${t.owner === "repo" ? "repo " : ""}token "${t.name}"`);
|
|
738
|
+
}
|
|
739
|
+
for (const r of system.color.roles)
|
|
740
|
+
add(r.name, `${r.owner === "repo" ? "repo " : ""}role "${r.name}"`);
|
|
741
|
+
const repo = system.repo;
|
|
742
|
+
if (repo) {
|
|
743
|
+
const own = new Set([
|
|
744
|
+
...system.tokens.filter((t) => t.owner === "repo").map((t) => t.name),
|
|
745
|
+
...system.color.roles.filter((r) => r.owner === "repo").map((r) => r.name),
|
|
746
|
+
]);
|
|
747
|
+
const { light, dark } = repoCustomProps(`${repo.cssVars}\n${repo.tailwindCss}`);
|
|
748
|
+
for (const name of new Set([...Object.keys(light), ...Object.keys(dark)])) {
|
|
749
|
+
if (!own.has(name))
|
|
750
|
+
add(name, `the repo's own --${name}`);
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
return out;
|
|
754
|
+
}
|
|
755
|
+
// How to free a name, by what holds it now.
|
|
756
|
+
function freeingHint(who) {
|
|
757
|
+
if (who.startsWith("step "))
|
|
758
|
+
return `remove ${who.replace(/^step "[^"]*" of /, "")} or give it another prefix`;
|
|
759
|
+
if (who.startsWith("role "))
|
|
760
|
+
return `write \`roles\` without ${who.slice(5)}`;
|
|
761
|
+
if (who.startsWith("token ") || who.startsWith("text token "))
|
|
762
|
+
return `remove ${who} with \`mockpit system --remove '{"tokens":["${who.match(/"([^"]+)"/)?.[1] ?? ""}"]}'\``;
|
|
763
|
+
return "rename it";
|
|
764
|
+
}
|
|
765
|
+
// The first name the write gave a second owner, said with both. Clashes already
|
|
766
|
+
// stored are left to the write that fixes them, so they never lock the system.
|
|
767
|
+
function newClash(before, after) {
|
|
768
|
+
const was = nameOwners(before);
|
|
769
|
+
for (const [name, who] of nameOwners(after)) {
|
|
770
|
+
if (who.length < 2)
|
|
771
|
+
continue;
|
|
772
|
+
if ((was.get(name)?.length ?? 0) >= who.length)
|
|
773
|
+
continue;
|
|
774
|
+
const byRepo = (w) => w.startsWith("repo ") || w.startsWith("the repo");
|
|
775
|
+
const added = who.find((w) => !was.get(name)?.includes(w)) ?? who[1];
|
|
776
|
+
const mine = who.find((w) => !byRepo(w));
|
|
777
|
+
const hint = !who.some(byRepo)
|
|
778
|
+
? ""
|
|
779
|
+
: byRepo(added)
|
|
780
|
+
? mine
|
|
781
|
+
? ` (${freeingHint(mine)})`
|
|
782
|
+
: ""
|
|
783
|
+
: added.startsWith("step ")
|
|
784
|
+
? ` (the repo owns it: give ${added.replace(/^step "[^"]*" of /, "")} another prefix)`
|
|
785
|
+
: " (the repo owns it: pick another name)";
|
|
786
|
+
return `"--${name}" would have two owners, ${who[0]} and ${who[1]}: a name has one owner${hint}`;
|
|
787
|
+
}
|
|
788
|
+
return null;
|
|
789
|
+
}
|
|
790
|
+
// Palette keys a palette may not value: mockpit's own names, knob names, and
|
|
791
|
+
// names a non-colour token or a scale step emits. Roles, repo-owned included, are
|
|
792
|
+
// what palettes are for.
|
|
793
|
+
function badPaletteKeys(system) {
|
|
794
|
+
const owned = new Map();
|
|
795
|
+
for (const t of system.tokens) {
|
|
796
|
+
if (t.scale)
|
|
797
|
+
owned.set(t.name, `a step of scale "${t.scale}"`);
|
|
798
|
+
else if (t.type === "text" && typeof t.value === "object")
|
|
799
|
+
for (const f of Object.keys(t.value))
|
|
800
|
+
owned.set(`${t.name}-${f}`, `text token "${t.name}"`);
|
|
801
|
+
else if (t.type !== "color")
|
|
802
|
+
owned.set(t.name, `${t.type} token "${t.name}"`);
|
|
803
|
+
}
|
|
804
|
+
const out = new Map();
|
|
805
|
+
for (const p of system.color.palettes) {
|
|
806
|
+
for (const key of new Set([...Object.keys(p.light), ...Object.keys(p.dark)])) {
|
|
807
|
+
const why = RESERVED.has(key)
|
|
808
|
+
? "one of mockpit's own tokens"
|
|
809
|
+
: key.startsWith("k-")
|
|
810
|
+
? "a knob name"
|
|
811
|
+
: owned.get(key);
|
|
812
|
+
if (why)
|
|
813
|
+
out.set(`${p.id}\u0000${key}`, `palette "${p.id}" values "${key}", ${why}`);
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
return out;
|
|
817
|
+
}
|
|
818
|
+
// Applies one write to a system, or says why it can't. Revision bookkeeping is
|
|
819
|
+
// the caller's.
|
|
820
|
+
export function applySystemWrite(current, write, ctx) {
|
|
821
|
+
let next = { ...current, color: { ...current.color } };
|
|
822
|
+
const repoNames = new Set([
|
|
823
|
+
...current.tokens.filter((t) => t.owner === "repo").map((t) => t.name),
|
|
824
|
+
...current.color.roles.filter((r) => r.owner === "repo").map((r) => r.name),
|
|
825
|
+
]);
|
|
826
|
+
if (write.sheet !== undefined || write.classes !== undefined) {
|
|
827
|
+
if (write.sheet !== undefined && write.sheet !== null && typeof write.sheet !== "string")
|
|
828
|
+
return { error: "sheet is css text" };
|
|
829
|
+
const css = write.sheet === undefined
|
|
830
|
+
? (current.sheet?.css ?? "")
|
|
831
|
+
: (write.sheet ?? "");
|
|
832
|
+
const error = checkSheet(css);
|
|
833
|
+
if (error)
|
|
834
|
+
return { error };
|
|
835
|
+
let classes = current.sheet?.classes ?? [];
|
|
836
|
+
if (write.classes !== undefined) {
|
|
837
|
+
const checked = checkClasses(write.classes);
|
|
838
|
+
if (typeof checked === "string")
|
|
839
|
+
return { error: checked };
|
|
840
|
+
classes = checked;
|
|
841
|
+
}
|
|
842
|
+
next.sheet = css.trim() || classes.length ? { css, classes } : null;
|
|
843
|
+
}
|
|
844
|
+
if (write.fonts !== undefined) {
|
|
845
|
+
const fonts = checkFonts(write.fonts, ctx.checkUrl);
|
|
846
|
+
if (typeof fonts === "string")
|
|
847
|
+
return { error: fonts };
|
|
848
|
+
next.fonts = fonts;
|
|
849
|
+
}
|
|
850
|
+
const remove = isRecord(write.remove) ? write.remove : write.remove === undefined ? {} : null;
|
|
851
|
+
if (remove === null)
|
|
852
|
+
return { error: "remove is {tokens?, palettes?, scales?}, lists of names" };
|
|
853
|
+
const names = (v) => Array.isArray(v) ? v.filter((x) => typeof x === "string") : [];
|
|
854
|
+
for (const name of names(remove.tokens)) {
|
|
855
|
+
if (repoNames.has(name))
|
|
856
|
+
return { error: `token "${name}" is ${OWNED_BY_REPO}` };
|
|
857
|
+
if (!next.tokens.some((t) => t.name === name && !t.scale))
|
|
858
|
+
return { error: `no token "${name}" to remove` };
|
|
859
|
+
}
|
|
860
|
+
for (const id of names(remove.palettes)) {
|
|
861
|
+
if (id === "repo")
|
|
862
|
+
return { error: `palette "repo" is ${OWNED_BY_REPO}` };
|
|
863
|
+
if (!next.color.palettes.some((p) => p.id === id))
|
|
864
|
+
return { error: `no palette "${id}" to remove` };
|
|
865
|
+
}
|
|
866
|
+
for (const id of names(remove.scales)) {
|
|
867
|
+
if (!next.scales.some((s) => s.id === id))
|
|
868
|
+
return { error: `no scale "${id}" to remove` };
|
|
869
|
+
}
|
|
870
|
+
const goneTokens = new Set(names(remove.tokens));
|
|
871
|
+
const goneScales = new Set(names(remove.scales));
|
|
872
|
+
next.tokens = next.tokens.filter((t) => !goneTokens.has(t.name) && !(t.scale && goneScales.has(t.scale)));
|
|
873
|
+
next.scales = next.scales.filter((s) => !goneScales.has(s.id));
|
|
874
|
+
const gonePalettes = new Set(names(remove.palettes));
|
|
875
|
+
next.color.palettes = next.color.palettes.filter((p) => !gonePalettes.has(p.id));
|
|
876
|
+
if (write.scales !== undefined) {
|
|
877
|
+
if (!Array.isArray(write.scales))
|
|
878
|
+
return { error: "scales is a list of scales" };
|
|
879
|
+
const scales = [...next.scales];
|
|
880
|
+
for (const raw of write.scales) {
|
|
881
|
+
const scale = checkScale(raw);
|
|
882
|
+
if (typeof scale === "string")
|
|
883
|
+
return { error: scale };
|
|
884
|
+
const at = scales.findIndex((s) => s.id === scale.id);
|
|
885
|
+
if (at >= 0)
|
|
886
|
+
scales[at] = scale;
|
|
887
|
+
else
|
|
888
|
+
scales.push(scale);
|
|
889
|
+
}
|
|
890
|
+
if (scales.length > SYSTEM_LIMITS.scales)
|
|
891
|
+
return { error: `at most ${SYSTEM_LIMITS.scales} scales` };
|
|
892
|
+
next.scales = scales;
|
|
893
|
+
}
|
|
894
|
+
// Scale tokens are always what their scale says now.
|
|
895
|
+
next.tokens = [...next.tokens.filter((t) => !t.scale), ...next.scales.flatMap(expandScale)];
|
|
896
|
+
if (write.tokens !== undefined) {
|
|
897
|
+
if (!Array.isArray(write.tokens))
|
|
898
|
+
return { error: "tokens is a list of {name, type, value, note?}" };
|
|
899
|
+
const tokens = [...next.tokens];
|
|
900
|
+
for (const raw of write.tokens) {
|
|
901
|
+
const token = checkToken(raw);
|
|
902
|
+
if (typeof token === "string")
|
|
903
|
+
return { error: token };
|
|
904
|
+
if (repoNames.has(token.name))
|
|
905
|
+
return { error: `token "${token.name}" is ${OWNED_BY_REPO}` };
|
|
906
|
+
const at = tokens.findIndex((t) => t.name === token.name);
|
|
907
|
+
if (at >= 0 && tokens[at].scale)
|
|
908
|
+
return { error: `token "${token.name}" is a step of scale "${tokens[at].scale}"` };
|
|
909
|
+
if (at >= 0)
|
|
910
|
+
tokens[at] = token;
|
|
911
|
+
else
|
|
912
|
+
tokens.push(token);
|
|
913
|
+
}
|
|
914
|
+
next.tokens = tokens;
|
|
915
|
+
}
|
|
916
|
+
if (next.tokens.length > SYSTEM_LIMITS.tokens)
|
|
917
|
+
return { error: `at most ${SYSTEM_LIMITS.tokens} tokens` };
|
|
918
|
+
if (write.roles !== undefined) {
|
|
919
|
+
if (!Array.isArray(write.roles))
|
|
920
|
+
return { error: "roles is a list of {name, note}" };
|
|
921
|
+
const roles = current.color.roles.filter((r) => r.owner === "repo");
|
|
922
|
+
for (const raw of write.roles) {
|
|
923
|
+
const role = checkRole(raw);
|
|
924
|
+
if (typeof role === "string")
|
|
925
|
+
return { error: role };
|
|
926
|
+
if (repoNames.has(role.name))
|
|
927
|
+
return { error: `role "${role.name}" is ${OWNED_BY_REPO}` };
|
|
928
|
+
if (roles.some((r) => r.name === role.name))
|
|
929
|
+
return { error: `role "${role.name}" is listed twice` };
|
|
930
|
+
roles.push(role);
|
|
931
|
+
}
|
|
932
|
+
if (roles.length > SYSTEM_LIMITS.roles)
|
|
933
|
+
return { error: `at most ${SYSTEM_LIMITS.roles} roles` };
|
|
934
|
+
next.color.roles = roles;
|
|
935
|
+
}
|
|
936
|
+
if (write.palettes !== undefined) {
|
|
937
|
+
if (!Array.isArray(write.palettes))
|
|
938
|
+
return { error: "palettes is a list of palettes" };
|
|
939
|
+
const palettes = [...next.color.palettes];
|
|
940
|
+
for (const raw of write.palettes) {
|
|
941
|
+
const id = isRecord(raw) ? raw.id : undefined;
|
|
942
|
+
if (id === "repo")
|
|
943
|
+
return { error: `palette "repo" is ${OWNED_BY_REPO}` };
|
|
944
|
+
const at = palettes.findIndex((p) => p.id === id);
|
|
945
|
+
const palette = checkPalette(raw, at >= 0 ? palettes[at] : undefined);
|
|
946
|
+
if (typeof palette === "string")
|
|
947
|
+
return { error: palette };
|
|
948
|
+
if (at >= 0)
|
|
949
|
+
palettes[at] = palette;
|
|
950
|
+
else
|
|
951
|
+
palettes.push(palette);
|
|
952
|
+
}
|
|
953
|
+
if (palettes.length > SYSTEM_LIMITS.palettes)
|
|
954
|
+
return { error: `at most ${SYSTEM_LIMITS.palettes} palettes` };
|
|
955
|
+
next.color.palettes = withOneDefault(palettes, write.palettes);
|
|
956
|
+
}
|
|
957
|
+
if (write.pairs !== undefined) {
|
|
958
|
+
const pairs = checkPairs(write.pairs);
|
|
959
|
+
if (typeof pairs === "string")
|
|
960
|
+
return { error: pairs };
|
|
961
|
+
next.color.pairs = pairs;
|
|
962
|
+
}
|
|
963
|
+
if (write.as !== undefined) {
|
|
964
|
+
const as = checkAs(write.as);
|
|
965
|
+
if (typeof as === "string")
|
|
966
|
+
return { error: as };
|
|
967
|
+
const { as: _gone, ...rest } = next.color;
|
|
968
|
+
next.color = as ? { ...rest, as } : rest;
|
|
969
|
+
}
|
|
970
|
+
if (write.knobs !== undefined) {
|
|
971
|
+
const checked = ctx.checkKnobs(write.knobs);
|
|
972
|
+
if (!checked.ok)
|
|
973
|
+
return { error: checked.error };
|
|
974
|
+
for (const path of Object.keys(checked.value)) {
|
|
975
|
+
const design = ctx.designKnobPaths.get(path);
|
|
976
|
+
if (design)
|
|
977
|
+
return {
|
|
978
|
+
error: `project knob "${path}" collides with design "${design}"'s knob "${path}"`,
|
|
979
|
+
};
|
|
980
|
+
}
|
|
981
|
+
next.knobs = checked.value;
|
|
982
|
+
next.settled = Object.fromEntries(Object.entries(next.settled).filter(([p]) => Object.hasOwn(checked.value, p)));
|
|
983
|
+
}
|
|
984
|
+
if (write.kit !== undefined ||
|
|
985
|
+
write.projectKits !== undefined ||
|
|
986
|
+
write.removeKits !== undefined) {
|
|
987
|
+
let projectKits = [...next.projectKits];
|
|
988
|
+
if (write.projectKits !== undefined) {
|
|
989
|
+
if (!Array.isArray(write.projectKits))
|
|
990
|
+
return { error: "projectKits is a list of kits" };
|
|
991
|
+
for (const raw of write.projectKits) {
|
|
992
|
+
const kit = ctx.checkKit(raw);
|
|
993
|
+
if (typeof kit === "string")
|
|
994
|
+
return { error: kit };
|
|
995
|
+
projectKits = [...projectKits.filter((k) => k.id !== kit.id), kit];
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
for (const id of names(write.removeKits)) {
|
|
999
|
+
if (!projectKits.some((k) => k.id === id))
|
|
1000
|
+
return { error: `${current.project} has no kit "${id}"` };
|
|
1001
|
+
projectKits = projectKits.filter((k) => k.id !== id);
|
|
1002
|
+
if (next.kit === id)
|
|
1003
|
+
next.kit = "none";
|
|
1004
|
+
}
|
|
1005
|
+
if (projectKits.length > 16)
|
|
1006
|
+
return { error: "at most 16 project kits" };
|
|
1007
|
+
next.projectKits = projectKits;
|
|
1008
|
+
if (write.kit !== undefined) {
|
|
1009
|
+
const kit = write.kit === null ? "none" : write.kit;
|
|
1010
|
+
const known = ["tailwind", "none", ...ctx.kitIds, ...projectKits.map((k) => k.id)];
|
|
1011
|
+
if (typeof kit !== "string" || !known.includes(kit))
|
|
1012
|
+
return { error: `unknown kit "${String(kit)}" — known: ${known.join(", ")}` };
|
|
1013
|
+
next.kit = kit;
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
if (write.iconSets !== undefined)
|
|
1017
|
+
next.iconSets = write.iconSets;
|
|
1018
|
+
let clashes = [];
|
|
1019
|
+
if (write.repo !== undefined) {
|
|
1020
|
+
next.repo = write.repo === null ? null : normalizeRepo(write.repo);
|
|
1021
|
+
const derived = deriveRepoTokens(next);
|
|
1022
|
+
if (derived.clashes.length) {
|
|
1023
|
+
const owners = nameOwners(next);
|
|
1024
|
+
const each = derived.clashes.slice(0, 6).map((n) => {
|
|
1025
|
+
const who = owners.get(n)?.find((w) => !w.startsWith("repo ") && !w.startsWith("the repo"));
|
|
1026
|
+
return who ? `"--${n}" (${who}: ${freeingHint(who)})` : `"--${n}"`;
|
|
1027
|
+
});
|
|
1028
|
+
return {
|
|
1029
|
+
error: `the repo now declares ${each.join(", ")}${derived.clashes.length > 6 ? `, +${derived.clashes.length - 6} more` : ""}, which the system already owns. Free ${derived.clashes.length > 1 ? "them" : "it"}, then rerun \`mockpit init\``,
|
|
1030
|
+
};
|
|
1031
|
+
}
|
|
1032
|
+
next = derived.system;
|
|
1033
|
+
clashes = derived.clashes;
|
|
1034
|
+
}
|
|
1035
|
+
const clash = newClash(current, next);
|
|
1036
|
+
if (clash)
|
|
1037
|
+
return { error: clash };
|
|
1038
|
+
const wasBad = badPaletteKeys(current);
|
|
1039
|
+
for (const [key, why] of badPaletteKeys(next)) {
|
|
1040
|
+
if (!wasBad.has(key))
|
|
1041
|
+
return { error: `${why}: a palette values roles` };
|
|
1042
|
+
}
|
|
1043
|
+
return { system: { ...next, updatedAt: new Date().toISOString() }, clashes };
|
|
1044
|
+
}
|
|
1045
|
+
// Asking one palette to be the default makes the one that was the default kept.
|
|
1046
|
+
function withOneDefault(palettes, written) {
|
|
1047
|
+
const asked = written
|
|
1048
|
+
.filter((p) => isRecord(p) && p.status === "default")
|
|
1049
|
+
.map((p) => p.id)
|
|
1050
|
+
.at(-1);
|
|
1051
|
+
if (!asked)
|
|
1052
|
+
return palettes;
|
|
1053
|
+
return palettes.map((p) => p.id === asked ? p : p.status === "default" ? { ...p, status: "kept" } : p);
|
|
1054
|
+
}
|
|
1055
|
+
// Restoring a revision brings back everything but what the repo owns, which
|
|
1056
|
+
// stays as init last read it (its palette keeps the status it had then).
|
|
1057
|
+
//
|
|
1058
|
+
// An agent's restore (`keepDecided`) also keeps what the user decided: every
|
|
1059
|
+
// palette's status, a default or kept palette the old revision lacks, the settled
|
|
1060
|
+
// project knobs and the knobs and answer styles those values need. `kept` says it.
|
|
1061
|
+
export function restoreSystem(current, old, opts = {}) {
|
|
1062
|
+
let restored = {
|
|
1063
|
+
...old,
|
|
1064
|
+
project: current.project,
|
|
1065
|
+
rev: current.rev,
|
|
1066
|
+
repo: current.repo,
|
|
1067
|
+
tokens: old.tokens.filter((t) => t.owner !== "repo"),
|
|
1068
|
+
color: {
|
|
1069
|
+
...old.color,
|
|
1070
|
+
roles: old.color.roles.filter((r) => r.owner !== "repo"),
|
|
1071
|
+
},
|
|
1072
|
+
updatedAt: new Date().toISOString(),
|
|
1073
|
+
};
|
|
1074
|
+
restored = deriveRepoTokens(restored).system;
|
|
1075
|
+
if (!opts.keepDecided)
|
|
1076
|
+
return { system: restored, kept: [] };
|
|
1077
|
+
const kept = [];
|
|
1078
|
+
const now = new Map(current.color.palettes.map((p) => [p.id, p]));
|
|
1079
|
+
let palettes = restored.color.palettes.map((p) => {
|
|
1080
|
+
const was = now.get(p.id);
|
|
1081
|
+
if (!was || was.status === p.status)
|
|
1082
|
+
return p;
|
|
1083
|
+
kept.push(`palette "${p.id}" stays ${was.status}`);
|
|
1084
|
+
return { ...p, status: was.status };
|
|
1085
|
+
});
|
|
1086
|
+
for (const p of current.color.palettes) {
|
|
1087
|
+
if (palettes.some((x) => x.id === p.id))
|
|
1088
|
+
continue;
|
|
1089
|
+
if (p.status !== "default" && p.status !== "kept")
|
|
1090
|
+
continue;
|
|
1091
|
+
palettes.push(p);
|
|
1092
|
+
kept.push(`palette "${p.id}" stays ${p.status}`);
|
|
1093
|
+
}
|
|
1094
|
+
// Statuses came from two revisions: the current default is the one.
|
|
1095
|
+
const def = current.color.palettes.find((p) => p.status === "default")?.id;
|
|
1096
|
+
if (def && palettes.some((p) => p.id === def))
|
|
1097
|
+
palettes = palettes.map((p) => p.status === "default" && p.id !== def ? { ...p, status: "kept" } : p);
|
|
1098
|
+
const knobs = { ...restored.knobs };
|
|
1099
|
+
const settled = {};
|
|
1100
|
+
const additions = [...restored.additions];
|
|
1101
|
+
for (const [path, value] of Object.entries(current.settled)) {
|
|
1102
|
+
settled[path] = value;
|
|
1103
|
+
if (!Object.hasOwn(knobs, path) && Object.hasOwn(current.knobs, path))
|
|
1104
|
+
knobs[path] = current.knobs[path];
|
|
1105
|
+
for (const a of current.additions) {
|
|
1106
|
+
if (a.knob !== path || a.value !== String(value))
|
|
1107
|
+
continue;
|
|
1108
|
+
if (!additions.some((x) => x.knob === a.knob && x.value === a.value))
|
|
1109
|
+
additions.push(a);
|
|
1110
|
+
}
|
|
1111
|
+
if (JSON.stringify(old.settled[path]) !== JSON.stringify(value))
|
|
1112
|
+
kept.push(`project knob "${path}" stays settled on ${JSON.stringify(value)}`);
|
|
1113
|
+
}
|
|
1114
|
+
return {
|
|
1115
|
+
system: {
|
|
1116
|
+
...restored,
|
|
1117
|
+
color: { ...restored.color, palettes },
|
|
1118
|
+
knobs,
|
|
1119
|
+
settled: { ...restored.settled, ...settled },
|
|
1120
|
+
additions,
|
|
1121
|
+
},
|
|
1122
|
+
kept,
|
|
1123
|
+
};
|
|
1124
|
+
}
|
|
1125
|
+
// --- what changed, in words ----------------------------------------------------------
|
|
1126
|
+
const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
|
1127
|
+
// Key order differs between a stored system and one a write built, so equality
|
|
1128
|
+
// is on sorted keys.
|
|
1129
|
+
function stableJson(v) {
|
|
1130
|
+
if (Array.isArray(v))
|
|
1131
|
+
return `[${v.map(stableJson).join(",")}]`;
|
|
1132
|
+
if (v && typeof v === "object") {
|
|
1133
|
+
return `{${Object.keys(v)
|
|
1134
|
+
.filter((k) => v[k] !== undefined)
|
|
1135
|
+
.sort()
|
|
1136
|
+
.map((k) => `${JSON.stringify(k)}:${stableJson(v[k])}`)
|
|
1137
|
+
.join(",")}}`;
|
|
1138
|
+
}
|
|
1139
|
+
return JSON.stringify(v) ?? "null";
|
|
1140
|
+
}
|
|
1141
|
+
// Whether two systems differ in anything but their revision bookkeeping.
|
|
1142
|
+
export function systemDiffers(before, after) {
|
|
1143
|
+
const strip = ({ rev: _r, updatedAt: _u, ...rest }) => rest;
|
|
1144
|
+
return stableJson(strip(before)) !== stableJson(strip(after));
|
|
1145
|
+
}
|
|
1146
|
+
export function describeChange(before, after) {
|
|
1147
|
+
const out = [];
|
|
1148
|
+
const bSheet = before.sheet?.css ?? "";
|
|
1149
|
+
const aSheet = after.sheet?.css ?? "";
|
|
1150
|
+
if (bSheet !== aSheet)
|
|
1151
|
+
out.push(aSheet
|
|
1152
|
+
? `sheet ${bSheet ? "changed" : "added"} (${(aSheet.length / 1000).toFixed(1)} KB)`
|
|
1153
|
+
: "sheet removed");
|
|
1154
|
+
const bClasses = new Set((before.sheet?.classes ?? []).map((c) => c.name));
|
|
1155
|
+
const aClasses = new Set((after.sheet?.classes ?? []).map((c) => c.name));
|
|
1156
|
+
const addedClasses = [...aClasses].filter((c) => !bClasses.has(c)).length;
|
|
1157
|
+
const goneClasses = [...bClasses].filter((c) => !aClasses.has(c)).length;
|
|
1158
|
+
const bNotes = new Map((before.sheet?.classes ?? []).map((c) => [c.name, c.note]));
|
|
1159
|
+
const renoted = (after.sheet?.classes ?? []).filter((c) => bNotes.has(c.name) && bNotes.get(c.name) !== c.note).length;
|
|
1160
|
+
if (addedClasses || goneClasses || renoted)
|
|
1161
|
+
out.push(`classes ${[addedClasses ? `+${addedClasses}` : "", goneClasses ? `−${goneClasses}` : "", renoted ? `${renoted} ${renoted === 1 ? "note" : "notes"} changed` : ""].filter(Boolean).join(" ")}`);
|
|
1162
|
+
else {
|
|
1163
|
+
const order = (sys) => (sys.sheet?.classes ?? []).map((c) => c.name).join(" ");
|
|
1164
|
+
if (order(before) !== order(after))
|
|
1165
|
+
out.push("classes reordered");
|
|
1166
|
+
}
|
|
1167
|
+
if (JSON.stringify(before.fonts) !== JSON.stringify(after.fonts))
|
|
1168
|
+
out.push(`fonts: ${after.fonts.map((f) => f.family).join(", ") || "none"}`);
|
|
1169
|
+
const diffBy = (a, b, key) => {
|
|
1170
|
+
const am = new Map(a.map((x) => [key(x), JSON.stringify(x)]));
|
|
1171
|
+
const bm = new Map(b.map((x) => [key(x), JSON.stringify(x)]));
|
|
1172
|
+
let added = 0;
|
|
1173
|
+
let changed = 0;
|
|
1174
|
+
let removed = 0;
|
|
1175
|
+
for (const [k, v] of bm) {
|
|
1176
|
+
if (!am.has(k))
|
|
1177
|
+
added++;
|
|
1178
|
+
else if (am.get(k) !== v)
|
|
1179
|
+
changed++;
|
|
1180
|
+
}
|
|
1181
|
+
for (const k of am.keys())
|
|
1182
|
+
if (!bm.has(k))
|
|
1183
|
+
removed++;
|
|
1184
|
+
return { added, changed, removed };
|
|
1185
|
+
};
|
|
1186
|
+
const counts = (label, d) => {
|
|
1187
|
+
const bits = [
|
|
1188
|
+
d.added ? `+${d.added}` : "",
|
|
1189
|
+
d.changed ? `${d.changed} changed` : "",
|
|
1190
|
+
d.removed ? `−${d.removed}` : "",
|
|
1191
|
+
].filter(Boolean);
|
|
1192
|
+
if (bits.length)
|
|
1193
|
+
out.push(`${label} ${bits.join(", ")}`);
|
|
1194
|
+
};
|
|
1195
|
+
for (const s of after.scales) {
|
|
1196
|
+
const old = before.scales.find((x) => x.id === s.id);
|
|
1197
|
+
if (!old)
|
|
1198
|
+
out.push(`scale ${s.id} added`);
|
|
1199
|
+
else if (JSON.stringify(old) !== JSON.stringify(s)) {
|
|
1200
|
+
if (old.ratio !== s.ratio)
|
|
1201
|
+
out.push(`scale ${s.id} ratio ${old.ratio} → ${s.ratio}`);
|
|
1202
|
+
else if (old.base !== s.base)
|
|
1203
|
+
out.push(`scale ${s.id} base ${old.base} → ${s.base}`);
|
|
1204
|
+
else
|
|
1205
|
+
out.push(`scale ${s.id} changed`);
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
for (const s of before.scales)
|
|
1209
|
+
if (!after.scales.some((x) => x.id === s.id))
|
|
1210
|
+
out.push(`scale ${s.id} removed`);
|
|
1211
|
+
counts("tokens", diffBy(before.tokens.filter((t) => !t.scale && !t.owner), after.tokens.filter((t) => !t.scale && !t.owner), (t) => t.name));
|
|
1212
|
+
counts("roles", diffBy(before.color.roles, after.color.roles, (r) => r.name));
|
|
1213
|
+
for (const p of after.color.palettes) {
|
|
1214
|
+
const old = before.color.palettes.find((x) => x.id === p.id);
|
|
1215
|
+
if (!old) {
|
|
1216
|
+
out.push(`palette ${p.label} added`);
|
|
1217
|
+
continue;
|
|
1218
|
+
}
|
|
1219
|
+
let colours = 0;
|
|
1220
|
+
for (const mode of ["light", "dark"]) {
|
|
1221
|
+
for (const role of new Set([...Object.keys(old[mode]), ...Object.keys(p[mode])])) {
|
|
1222
|
+
if (old[mode][role] !== p[mode][role])
|
|
1223
|
+
colours++;
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
if (old.label !== p.label)
|
|
1227
|
+
out.push(`palette ${old.label} renamed ${p.label}`);
|
|
1228
|
+
if (old.note !== p.note)
|
|
1229
|
+
out.push(`palette ${p.label} note changed`);
|
|
1230
|
+
if (stableJson(old.scheme ?? null) !== stableJson(p.scheme ?? null))
|
|
1231
|
+
out.push(`palette ${p.label} scheme ${p.scheme ? `light ${p.scheme.light}, dark ${p.scheme.dark}` : "removed"}`);
|
|
1232
|
+
if (colours)
|
|
1233
|
+
out.push(`${plural(colours, "colour")} in ${p.label}`);
|
|
1234
|
+
if (old.status !== p.status)
|
|
1235
|
+
out.push(`${p.label} ${p.status === "default" ? "is the default" : p.status}`);
|
|
1236
|
+
}
|
|
1237
|
+
for (const p of before.color.palettes)
|
|
1238
|
+
if (!after.color.palettes.some((x) => x.id === p.id))
|
|
1239
|
+
out.push(`palette ${p.label} removed`);
|
|
1240
|
+
if (JSON.stringify(before.color.pairs) !== JSON.stringify(after.color.pairs))
|
|
1241
|
+
out.push(`${plural(after.color.pairs.length, "pair")} to check`);
|
|
1242
|
+
if (JSON.stringify(before.color.as ?? {}) !== JSON.stringify(after.color.as ?? {}))
|
|
1243
|
+
out.push("mockpit tokens remapped");
|
|
1244
|
+
if (JSON.stringify(before.knobs) !== JSON.stringify(after.knobs))
|
|
1245
|
+
out.push(`${plural(Object.keys(after.knobs).length, "project knob")}`);
|
|
1246
|
+
if (before.kit !== after.kit)
|
|
1247
|
+
out.push(`kit ${after.kit}`);
|
|
1248
|
+
counts("project kits", diffBy(before.projectKits, after.projectKits, (k) => k.id));
|
|
1249
|
+
counts("icon sets", diffBy(before.iconSets, after.iconSets, (s) => s.prefix));
|
|
1250
|
+
if (stableJson(before.repo) !== stableJson(after.repo))
|
|
1251
|
+
out.push("read from the repo again");
|
|
1252
|
+
if (stableJson(before.settled) !== stableJson(after.settled))
|
|
1253
|
+
out.push(Object.keys(after.settled).length
|
|
1254
|
+
? `${plural(Object.keys(after.settled).length, "project knob")} settled`
|
|
1255
|
+
: "no project knob settled");
|
|
1256
|
+
// A real change never goes unsaid.
|
|
1257
|
+
if (out.length === 0 && systemDiffers(before, after))
|
|
1258
|
+
out.push("system changed");
|
|
1259
|
+
return out;
|
|
1260
|
+
}
|
|
1261
|
+
// --- emission ------------------------------------------------------------------
|
|
1262
|
+
export function defaultPalette(system) {
|
|
1263
|
+
const visible = system.color.palettes;
|
|
1264
|
+
return (visible.find((p) => p.status === "default") ??
|
|
1265
|
+
visible.find((p) => p.status !== "hidden") ??
|
|
1266
|
+
null);
|
|
1267
|
+
}
|
|
1268
|
+
// The palette a frame wears: the one `?p=` names if it exists, else the default.
|
|
1269
|
+
export function activePalette(system, id) {
|
|
1270
|
+
return (id && system.color.palettes.find((p) => p.id === id)) || defaultPalette(system);
|
|
1271
|
+
}
|
|
1272
|
+
const decl = (name, value) => `--${name}:${value};`;
|
|
1273
|
+
function tokenDecls(token) {
|
|
1274
|
+
if (token.type !== "text" || typeof token.value === "string")
|
|
1275
|
+
return decl(token.name, token.value);
|
|
1276
|
+
return Object.entries(token.value)
|
|
1277
|
+
.map(([field, v]) => decl(`${token.name}-${field}`, v))
|
|
1278
|
+
.join("");
|
|
1279
|
+
}
|
|
1280
|
+
// The CSS a frame gets from the system. Repo-owned tokens aren't emitted: the
|
|
1281
|
+
// repo's raw block (or compiled Tailwind) declares them already; nor are the
|
|
1282
|
+
// repo palette's values while it is the one worn.
|
|
1283
|
+
export function systemCss(system, opts) {
|
|
1284
|
+
const own = system.tokens.filter((t) => t.owner !== "repo");
|
|
1285
|
+
const palette = activePalette(system, opts.palette);
|
|
1286
|
+
const block = (vars) => (vars ? `:root{${vars}}` : "");
|
|
1287
|
+
// The layout kit's default gap: the base step of the project's space scale.
|
|
1288
|
+
const space = system.scales.find((s) => s.family === "space");
|
|
1289
|
+
// First, so a token the project names layout-space wins.
|
|
1290
|
+
const constants = (space ? decl("layout-space", `var(--${space.prefix}-${space.zero})`) : "") +
|
|
1291
|
+
own.map(tokenDecls).join("");
|
|
1292
|
+
const paletteVars = (mode) => palette && palette.owner !== "repo"
|
|
1293
|
+
? Object.entries(palette[mode])
|
|
1294
|
+
.map(([role, value]) => decl(role, value))
|
|
1295
|
+
.join("")
|
|
1296
|
+
: "";
|
|
1297
|
+
// Old versions keep mockpit's own colours under their kits and base styles:
|
|
1298
|
+
// pointing them at the project's would change how an approved version looks.
|
|
1299
|
+
const as = (opts.contract ?? CURRENT_CONTRACT) >= 2
|
|
1300
|
+
? Object.entries(system.color.as ?? {})
|
|
1301
|
+
.map(([token, role]) => decl(token, `var(--${role})`))
|
|
1302
|
+
.join("")
|
|
1303
|
+
: "";
|
|
1304
|
+
let colours;
|
|
1305
|
+
if (opts.mode)
|
|
1306
|
+
colours = block(paletteVars(opts.mode) + as);
|
|
1307
|
+
else {
|
|
1308
|
+
const light = paletteVars("light");
|
|
1309
|
+
const dark = paletteVars("dark");
|
|
1310
|
+
colours = `${block(light + as)}${dark ? `@media (prefers-color-scheme: dark){${block(dark)}}` : ""}`;
|
|
1311
|
+
}
|
|
1312
|
+
const scheme = palette?.scheme && opts.mode ? palette.scheme[opts.mode] : opts.mode;
|
|
1313
|
+
return {
|
|
1314
|
+
sheet: system.sheet?.css ?? "",
|
|
1315
|
+
tokens: `${block(constants)}${colours}`,
|
|
1316
|
+
fonts: system.fonts.flatMap((f) => (f.href ? [f.href] : [])),
|
|
1317
|
+
attrs: {
|
|
1318
|
+
...(scheme ? { scheme } : {}),
|
|
1319
|
+
...(palette ? { palette: palette.id } : {}),
|
|
1320
|
+
},
|
|
1321
|
+
};
|
|
1322
|
+
}
|
|
1323
|
+
// What a Send decides for the whole project: a palette chosen by a question becomes
|
|
1324
|
+
// the default (the one it replaces is kept, not lost), Keep and Hide on a palette's
|
|
1325
|
+
// answer set its status, and an answer's `set` on a project knob settles it. Null
|
|
1326
|
+
// when the reply changes nothing there.
|
|
1327
|
+
export function systemAfterAnswers(system, asks, answers, standings) {
|
|
1328
|
+
const ids = new Set(system.color.palettes.map((p) => p.id));
|
|
1329
|
+
const status = new Map();
|
|
1330
|
+
const settled = {};
|
|
1331
|
+
let chosenDefault = null;
|
|
1332
|
+
for (const ask of asks) {
|
|
1333
|
+
const picked = answers[ask.id] === undefined ? [] : [answers[ask.id]].flat();
|
|
1334
|
+
for (const id of picked) {
|
|
1335
|
+
const opt = ask.options.find((o) => o.id === id);
|
|
1336
|
+
if (opt?.palette && ids.has(opt.palette))
|
|
1337
|
+
chosenDefault = opt.palette;
|
|
1338
|
+
for (const [path, v] of Object.entries(opt?.set ?? {})) {
|
|
1339
|
+
if (Object.hasOwn(system.knobs, path))
|
|
1340
|
+
settled[path] = v;
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
const st = standings[ask.id];
|
|
1344
|
+
if (!st)
|
|
1345
|
+
continue;
|
|
1346
|
+
const paletteOf = (optionId) => ask.options.find((o) => o.id === optionId)?.palette;
|
|
1347
|
+
for (const id of st.kept) {
|
|
1348
|
+
const p = paletteOf(id);
|
|
1349
|
+
if (p && ids.has(p))
|
|
1350
|
+
status.set(p, "kept");
|
|
1351
|
+
}
|
|
1352
|
+
for (const h of st.hidden) {
|
|
1353
|
+
const p = paletteOf(h.id);
|
|
1354
|
+
if (p && ids.has(p))
|
|
1355
|
+
status.set(p, "hidden");
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
const changes = [];
|
|
1359
|
+
const was = defaultPalette(system);
|
|
1360
|
+
let palettes = system.color.palettes.map((p) => {
|
|
1361
|
+
const s = status.get(p.id);
|
|
1362
|
+
// The default leaves only by another becoming it.
|
|
1363
|
+
if (!s || s === p.status || p.status === "default" || p.id === chosenDefault)
|
|
1364
|
+
return p;
|
|
1365
|
+
changes.push(`palette "${p.id}" ${s}`);
|
|
1366
|
+
return { ...p, status: s };
|
|
1367
|
+
});
|
|
1368
|
+
if (chosenDefault && was?.id !== chosenDefault) {
|
|
1369
|
+
palettes = palettes.map((p) => p.id === chosenDefault
|
|
1370
|
+
? { ...p, status: "default" }
|
|
1371
|
+
: p.status === "default"
|
|
1372
|
+
? { ...p, status: "kept" }
|
|
1373
|
+
: p);
|
|
1374
|
+
changes.unshift(`palette "${chosenDefault}" is the default${was ? ` ("${was.id}" kept)` : ""}`);
|
|
1375
|
+
}
|
|
1376
|
+
const nextSettled = { ...system.settled };
|
|
1377
|
+
for (const [path, v] of Object.entries(settled)) {
|
|
1378
|
+
if (JSON.stringify(system.settled[path]) === JSON.stringify(v))
|
|
1379
|
+
continue;
|
|
1380
|
+
nextSettled[path] = v;
|
|
1381
|
+
changes.push(`project knob "${path}" settled on ${JSON.stringify(v)}`);
|
|
1382
|
+
}
|
|
1383
|
+
if (changes.length === 0)
|
|
1384
|
+
return null;
|
|
1385
|
+
return {
|
|
1386
|
+
system: { ...system, color: { ...system.color, palettes }, settled: nextSettled },
|
|
1387
|
+
changes,
|
|
1388
|
+
};
|
|
1389
|
+
}
|
|
1390
|
+
// What every render of the project carries for its knobs: the settled value, else
|
|
1391
|
+
// the knob's own (a select without one starts on its first option).
|
|
1392
|
+
export function projectKnobValues(system) {
|
|
1393
|
+
const out = {};
|
|
1394
|
+
for (const [path, config] of Object.entries(system?.knobs ?? {})) {
|
|
1395
|
+
if (Object.hasOwn(system.settled, path)) {
|
|
1396
|
+
out[path] = system.settled[path];
|
|
1397
|
+
continue;
|
|
1398
|
+
}
|
|
1399
|
+
const k = explicitKnob(config);
|
|
1400
|
+
const first = k.options?.[0];
|
|
1401
|
+
const v = k.value ??
|
|
1402
|
+
(typeof first === "object" && first !== null ? first.value : first);
|
|
1403
|
+
if (v !== undefined)
|
|
1404
|
+
out[path] = v;
|
|
1405
|
+
}
|
|
1406
|
+
return out;
|
|
1407
|
+
}
|
|
1408
|
+
export function systemExport(system) {
|
|
1409
|
+
const own = system.tokens.filter((t) => t.owner !== "repo");
|
|
1410
|
+
const space = system.scales.find((s) => s.family === "space");
|
|
1411
|
+
// The default first: a kept palette's block, of equal specificity, must follow it.
|
|
1412
|
+
const palettes = system.color.palettes
|
|
1413
|
+
.filter((p) => p.owner !== "repo" && (p.status === "default" || p.status === "kept"))
|
|
1414
|
+
.sort((a, b) => Number(b.status === "default") - Number(a.status === "default"));
|
|
1415
|
+
const rule = (sel, decls, indent = "") => `${indent}${sel} {\n${decls.map((d) => `${indent} ${d}\n`).join("")}${indent}}`;
|
|
1416
|
+
const roleDecls = (values) => Object.entries(values).map(([role, v]) => decl(role, v));
|
|
1417
|
+
const blocks = [];
|
|
1418
|
+
const constants = [
|
|
1419
|
+
...own.flatMap((t) => tokenDecls(t)
|
|
1420
|
+
.split(/(?<=;)/)
|
|
1421
|
+
.filter(Boolean)),
|
|
1422
|
+
...(space ? [decl("layout-space", `var(--${space.prefix}-${space.zero})`)] : []),
|
|
1423
|
+
];
|
|
1424
|
+
if (constants.length)
|
|
1425
|
+
blocks.push(rule(":root", constants));
|
|
1426
|
+
for (const p of palettes) {
|
|
1427
|
+
const sel = p.status === "default" ? ":root" : `[data-palette="${p.id}"]`;
|
|
1428
|
+
blocks.push(`/* ${p.label || p.id}${p.status === "default" ? ", the default" : ", kept"} */`);
|
|
1429
|
+
blocks.push(rule(sel, roleDecls(p.light)));
|
|
1430
|
+
if (Object.keys(p.dark).length) {
|
|
1431
|
+
blocks.push(`@media (prefers-color-scheme: dark) {\n${rule(sel, roleDecls(p.dark), " ")}\n}`);
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
return {
|
|
1435
|
+
project: system.project,
|
|
1436
|
+
rev: system.rev,
|
|
1437
|
+
sheet: system.sheet?.css ?? "",
|
|
1438
|
+
css: blocks.length ? `${blocks.join("\n\n")}\n` : "",
|
|
1439
|
+
tokens: {
|
|
1440
|
+
constants: Object.fromEntries(own.map((t) => [t.name, t.value])),
|
|
1441
|
+
palettes: Object.fromEntries(palettes.map((p) => [
|
|
1442
|
+
p.id,
|
|
1443
|
+
{ label: p.label, status: p.status, light: p.light, dark: p.dark },
|
|
1444
|
+
])),
|
|
1445
|
+
},
|
|
1446
|
+
fonts: system.fonts,
|
|
1447
|
+
knobs: projectKnobValues(system),
|
|
1448
|
+
additions: system.additions
|
|
1449
|
+
.filter((a) => Object.hasOwn(system.settled, a.knob) && String(system.settled[a.knob]) === a.value)
|
|
1450
|
+
.map(({ knob, value, style }) => ({ knob, value, style })),
|
|
1451
|
+
};
|
|
1452
|
+
}
|
|
1453
|
+
// One line for a read of the whole project: what its renders wear.
|
|
1454
|
+
export function systemLine(system, failing) {
|
|
1455
|
+
const def = defaultPalette(system);
|
|
1456
|
+
const kept = system.color.palettes.filter((p) => p.status === "kept").map((p) => p.label || p.id);
|
|
1457
|
+
return [
|
|
1458
|
+
`r${system.rev}`,
|
|
1459
|
+
def ? `palette ${def.label || def.id}${kept.length ? ` (kept: ${kept.join(", ")})` : ""}` : "",
|
|
1460
|
+
system.tokens.length ? plural(system.tokens.length, "token") : "",
|
|
1461
|
+
system.scales.length ? plural(system.scales.length, "scale") : "",
|
|
1462
|
+
system.sheet ? plural(system.sheet.classes.length, "sheet class", "sheet classes") : "",
|
|
1463
|
+
Object.keys(system.knobs).length
|
|
1464
|
+
? plural(Object.keys(system.knobs).length, "project knob")
|
|
1465
|
+
: "",
|
|
1466
|
+
failing ? `${plural(failing, "check")} failing` : "",
|
|
1467
|
+
]
|
|
1468
|
+
.filter(Boolean)
|
|
1469
|
+
.join(" · ");
|
|
1470
|
+
}
|
|
1471
|
+
// Every name the system declares, for the checks and the brief.
|
|
1472
|
+
export function declaredNames(system) {
|
|
1473
|
+
const out = new Set();
|
|
1474
|
+
for (const t of system.tokens) {
|
|
1475
|
+
if (t.type === "text" && typeof t.value === "object") {
|
|
1476
|
+
for (const f of Object.keys(t.value))
|
|
1477
|
+
out.add(`${t.name}-${f}`);
|
|
1478
|
+
}
|
|
1479
|
+
else
|
|
1480
|
+
out.add(t.name);
|
|
1481
|
+
}
|
|
1482
|
+
for (const r of system.color.roles)
|
|
1483
|
+
out.add(r.name);
|
|
1484
|
+
for (const p of system.color.palettes) {
|
|
1485
|
+
for (const k of Object.keys(p.light))
|
|
1486
|
+
out.add(k);
|
|
1487
|
+
for (const k of Object.keys(p.dark))
|
|
1488
|
+
out.add(k);
|
|
1489
|
+
}
|
|
1490
|
+
for (const name of CHROME_TOKENS)
|
|
1491
|
+
out.add(name);
|
|
1492
|
+
const repo = system.repo;
|
|
1493
|
+
if (repo) {
|
|
1494
|
+
const { light, dark } = repoCustomProps(`${repo.cssVars}\n${repo.tailwindCss}`);
|
|
1495
|
+
for (const k of Object.keys(light))
|
|
1496
|
+
out.add(k);
|
|
1497
|
+
for (const k of Object.keys(dark))
|
|
1498
|
+
out.add(k);
|
|
1499
|
+
}
|
|
1500
|
+
return out;
|
|
1501
|
+
}
|
|
1502
|
+
export const hasSystemContent = (s) => !!s &&
|
|
1503
|
+
!!(s.sheet ||
|
|
1504
|
+
s.tokens.length ||
|
|
1505
|
+
s.scales.length ||
|
|
1506
|
+
s.color.palettes.length ||
|
|
1507
|
+
s.fonts.length ||
|
|
1508
|
+
Object.keys(s.knobs).length);
|