@theme-registry/refract 0.1.1 → 0.1.3
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 +21 -5
- package/dist/_errors-be768b25.cjs.js.map +1 -1
- package/dist/_errors-d75a5c74.esm.js.map +1 -1
- package/dist/build/createCommand.d.ts +47 -0
- package/dist/build/createInterview.d.ts +56 -0
- package/dist/build/index.d.ts +4 -0
- package/dist/build/init.d.ts +42 -0
- package/dist/build/prompt.d.ts +38 -0
- package/dist/build/scaffold.d.ts +146 -0
- package/dist/build.cjs.js +1 -1
- package/dist/build.cjs.js.map +1 -1
- package/dist/build.esm.js +1 -1
- package/dist/build.esm.js.map +1 -1
- package/dist/cli.js +993 -38
- package/dist/cli.js.map +1 -1
- package/dist/core/assertRawTheme.d.ts +2 -0
- package/dist/core/errors.d.ts +5 -2
- package/dist/core/index.d.ts +1 -0
- package/dist/index.cjs.js +1 -1
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.esm.js +1 -1
- package/dist/index.esm.js.map +1 -1
- package/package.json +1 -1
- package/skills/adapter-scaffold/SKILL.md +2 -2
- package/skills/adapter-usage/SKILL.md +1 -1
- package/skills/colors/SKILL.md +1 -1
- package/skills/layout/SKILL.md +1 -1
- package/skills/theme-authoring/SKILL.md +4 -1
- package/skills/theme-scaffold/SKILL.md +130 -0
- package/skills/typography/SKILL.md +1 -1
- package/skills/visual-effects/SKILL.md +1 -1
package/dist/cli.js
CHANGED
|
@@ -7,6 +7,7 @@ var node_path = require('node:path');
|
|
|
7
7
|
var node_fs = require('node:fs');
|
|
8
8
|
var node_url = require('node:url');
|
|
9
9
|
var node_os = require('node:os');
|
|
10
|
+
var node_readline = require('node:readline');
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* Build-layer path + transpile helpers (Node-only).
|
|
@@ -3150,7 +3151,7 @@ const apcaLc = (text, bg) => {
|
|
|
3150
3151
|
}
|
|
3151
3152
|
return out * 100;
|
|
3152
3153
|
};
|
|
3153
|
-
const round$
|
|
3154
|
+
const round$2 = (n, dp) => {
|
|
3154
3155
|
const f = 10 ** dp;
|
|
3155
3156
|
return Math.round(n * f) / f;
|
|
3156
3157
|
};
|
|
@@ -3200,9 +3201,9 @@ const scorePair = (kind, label, fg, bg, options) => {
|
|
|
3200
3201
|
const pass = LEVEL_RANK[level] >= LEVEL_RANK[options.minWcag];
|
|
3201
3202
|
return {
|
|
3202
3203
|
kind, label, fg, bg,
|
|
3203
|
-
wcagRatio: round$
|
|
3204
|
+
wcagRatio: round$2(ratio, 2),
|
|
3204
3205
|
wcagLevel: level,
|
|
3205
|
-
apcaLc: round$
|
|
3206
|
+
apcaLc: round$2(apcaLc(fgRgb, bgRgb), 1),
|
|
3206
3207
|
pass,
|
|
3207
3208
|
};
|
|
3208
3209
|
};
|
|
@@ -3591,7 +3592,7 @@ const FORCE_NONE_PROPERTIES = new Set(["spacing", "gutters"]);
|
|
|
3591
3592
|
const SCALE_PRECISION = 4;
|
|
3592
3593
|
/** The default step ladder used when a curve is declared without an explicit `steps`. */
|
|
3593
3594
|
const DEFAULT_LADDER = ["xs", "sm", "md", "lg", "xl", "2xl", "3xl", "4xl"];
|
|
3594
|
-
const round = (n) => Number(n.toFixed(SCALE_PRECISION));
|
|
3595
|
+
const round$1 = (n) => Number(n.toFixed(SCALE_PRECISION));
|
|
3595
3596
|
/**
|
|
3596
3597
|
* The `scaleStep` derivation fn (registered by the layout subsystem). Re-derives a synthesized step
|
|
3597
3598
|
* from its curve config: **geometric** `base × ratio^exp` (from the resolved base token, so an
|
|
@@ -3603,10 +3604,10 @@ const round = (n) => Number(n.toFixed(SCALE_PRECISION));
|
|
|
3603
3604
|
const scaleStep = (value, arg) => {
|
|
3604
3605
|
const a = (arg !== null && arg !== void 0 ? arg : {});
|
|
3605
3606
|
if (a.curve === "linear") {
|
|
3606
|
-
return round(Number(a.step) * Number(a.mult));
|
|
3607
|
+
return round$1(Number(a.step) * Number(a.mult));
|
|
3607
3608
|
}
|
|
3608
3609
|
const base = a.base !== undefined ? Number(a.base) : Number(value);
|
|
3609
|
-
return round(base * Math.pow(Number(a.ratio), Number(a.exp)));
|
|
3610
|
+
return round$1(base * Math.pow(Number(a.ratio), Number(a.exp)));
|
|
3610
3611
|
};
|
|
3611
3612
|
/**
|
|
3612
3613
|
* Read the top-level curve config off a normalized property. `ratio` selects geometric, `step`
|
|
@@ -3654,14 +3655,14 @@ const synthesizeVariants = (propertyKey, base, config) => {
|
|
|
3654
3655
|
const out = {};
|
|
3655
3656
|
for (const spec of config.specs) {
|
|
3656
3657
|
if (config.curve === "geometric") {
|
|
3657
|
-
const value = round(Number(base) * Math.pow(config.ratio, spec.exp));
|
|
3658
|
+
const value = round$1(Number(base) * Math.pow(config.ratio, spec.exp));
|
|
3658
3659
|
out[spec.name] = {
|
|
3659
3660
|
base: value,
|
|
3660
3661
|
derive: { ref, fn: "scaleStep", arg: { curve: "geometric", ratio: config.ratio, exp: spec.exp } },
|
|
3661
3662
|
};
|
|
3662
3663
|
}
|
|
3663
3664
|
else {
|
|
3664
|
-
const value = round(config.step * spec.mult);
|
|
3665
|
+
const value = round$1(config.step * spec.mult);
|
|
3665
3666
|
out[spec.name] = {
|
|
3666
3667
|
base: value,
|
|
3667
3668
|
derive: { ref, fn: "scaleStep", arg: { curve: "linear", step: config.step, mult: spec.mult } },
|
|
@@ -3707,7 +3708,7 @@ const expandResponsive = (propertyKey, propBase, responsive, config) => {
|
|
|
3707
3708
|
const ratio = Number(e.ratio);
|
|
3708
3709
|
const hasBase = e.base !== undefined;
|
|
3709
3710
|
const b = hasBase ? Number(e.base) : Number(propBase);
|
|
3710
|
-
value = round(b * Math.pow(ratio, spec.exp));
|
|
3711
|
+
value = round$1(b * Math.pow(ratio, spec.exp));
|
|
3711
3712
|
arg = { curve: "geometric", ratio, exp: spec.exp, ...(hasBase ? { base: b } : {}) };
|
|
3712
3713
|
}
|
|
3713
3714
|
else {
|
|
@@ -3715,7 +3716,7 @@ const expandResponsive = (propertyKey, propBase, responsive, config) => {
|
|
|
3715
3716
|
throw new RefractError("REFRACT_E_LAYOUT", `layout.${propertyKey}: a linear responsive ramp ("step") requires a linear base scale (a name→multiplier "steps").`);
|
|
3716
3717
|
}
|
|
3717
3718
|
const step = Number(e.step);
|
|
3718
|
-
value = round(step * spec.mult);
|
|
3719
|
+
value = round$1(step * spec.mult);
|
|
3719
3720
|
arg = { curve: "linear", step, mult: spec.mult };
|
|
3720
3721
|
}
|
|
3721
3722
|
const expanded = {
|
|
@@ -6118,6 +6119,33 @@ const resolveReferences = (value, tokensByPath, visited = new Set()) => {
|
|
|
6118
6119
|
return value;
|
|
6119
6120
|
};
|
|
6120
6121
|
|
|
6122
|
+
/**
|
|
6123
|
+
* Fail-loud shape guard for a value that is *supposed* to be a {@link RawTheme}.
|
|
6124
|
+
*
|
|
6125
|
+
* `createTheme` accepts a bare object, and an empty `{}` is a valid (empty) theme — so a value of the
|
|
6126
|
+
* *wrong* shape doesn't necessarily throw during a build. That's the trap `refract diff` fell into: hand
|
|
6127
|
+
* it a `defineConfig({ raw, targets })` where it wanted the raw theme and it built the config as an
|
|
6128
|
+
* (effectively empty) theme, then reported a nonsense "every token removed" diff and exited 0. A
|
|
6129
|
+
* governance tool must not quietly mis-report. This guard is the loud, coded gate for that class of
|
|
6130
|
+
* mistake — used by `diff` (and available to `validate` / the MCP server) before anything is compared.
|
|
6131
|
+
*
|
|
6132
|
+
* It is deliberately narrow: it rejects only values that cannot be a RawTheme (non-objects, arrays,
|
|
6133
|
+
* `null`) or that are affirmatively something else (a `defineConfig`, spotted by its `targets` array).
|
|
6134
|
+
* A bare `{}` still passes — an empty theme is legitimately empty.
|
|
6135
|
+
*/
|
|
6136
|
+
function assertRawTheme(value, source) {
|
|
6137
|
+
const at = source ? ` (${source})` : "";
|
|
6138
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
6139
|
+
const got = Array.isArray(value) ? "an array" : value === null ? "null" : typeof value;
|
|
6140
|
+
throw new RefractError("REFRACT_E_RAW_SHAPE", `Expected a RawTheme object${at}, got ${got}. Pass the raw theme (a module's default export, or a .json raw theme).`);
|
|
6141
|
+
}
|
|
6142
|
+
// The documented mistake: a defineConfig({ raw, targets }) passed where the bare RawTheme was wanted.
|
|
6143
|
+
// A RawTheme never has a top-level `targets`; a config always does.
|
|
6144
|
+
if (Array.isArray(value.targets)) {
|
|
6145
|
+
throw new RefractError("REFRACT_E_RAW_SHAPE", `Expected a RawTheme object${at}, but got what looks like a defineConfig({ raw, targets }). Pass its \`raw\` (or a module that default-exports the raw theme), not the whole config.`);
|
|
6146
|
+
}
|
|
6147
|
+
}
|
|
6148
|
+
|
|
6121
6149
|
/**
|
|
6122
6150
|
* `defineAdapter` — turns an `AdapterSpec` (identity + a `bind` returning the four
|
|
6123
6151
|
* required primitives) into a full `ThemeAdapter` whose `bind` yields a complete
|
|
@@ -7158,6 +7186,78 @@ function readOwnPackageName() {
|
|
|
7158
7186
|
const pkg = JSON.parse(node_fs.readFileSync(pkgPath, "utf8"));
|
|
7159
7187
|
return (_a = pkg.name) !== null && _a !== void 0 ? _a : "@theme-registry/refract";
|
|
7160
7188
|
}
|
|
7189
|
+
/**
|
|
7190
|
+
* Raw-theme filenames `init` will wire up, in resolution order. `.ts` first (the richest), then the
|
|
7191
|
+
* plain-ESM flavours, then `.json`. Matches what `refract create` and `refract import` write.
|
|
7192
|
+
*/
|
|
7193
|
+
const RAW_BASENAME = "theme.raw";
|
|
7194
|
+
const RAW_EXT_ORDER = [".ts", ".mts", ".mjs", ".js", ".json"];
|
|
7195
|
+
/**
|
|
7196
|
+
* Look for an authored raw theme in `fromDir`.
|
|
7197
|
+
*
|
|
7198
|
+
* This is the seam between `create` and `init`: `create` designs the theme, `init` wires the build.
|
|
7199
|
+
* When a theme is already there, `init` must not invent a second one — a project with two sources of
|
|
7200
|
+
* truth for its tokens is worse than a project with none.
|
|
7201
|
+
*/
|
|
7202
|
+
const findRawTheme = (fromDir = process.cwd()) => {
|
|
7203
|
+
for (const ext of RAW_EXT_ORDER) {
|
|
7204
|
+
const candidate = node_path.join(fromDir, `${RAW_BASENAME}${ext}`);
|
|
7205
|
+
if (node_fs.existsSync(candidate)) {
|
|
7206
|
+
return { path: candidate, filename: `${RAW_BASENAME}${ext}`, ext };
|
|
7207
|
+
}
|
|
7208
|
+
}
|
|
7209
|
+
return undefined;
|
|
7210
|
+
};
|
|
7211
|
+
/**
|
|
7212
|
+
* The import line (or lines) a config uses to reach a detected raw theme.
|
|
7213
|
+
*
|
|
7214
|
+
* `.ts` is graph-compiled alongside the config, so an extensionless specifier resolves and the
|
|
7215
|
+
* transformer rewrites it. `.mjs`/`.js` are imported by Node directly, so they keep their extension.
|
|
7216
|
+
* `.json` is read rather than imported: an import attribute (`with { type: "json" }`) would work on
|
|
7217
|
+
* current Node but pins the scaffolded config to a version floor for no benefit, and `readFileSync`
|
|
7218
|
+
* behaves identically in every flavour.
|
|
7219
|
+
*/
|
|
7220
|
+
function rawThemeImport(detected) {
|
|
7221
|
+
if (detected.ext === ".json") {
|
|
7222
|
+
return {
|
|
7223
|
+
head: `import { readFileSync } from "node:fs";\n` +
|
|
7224
|
+
`\n// The theme is JSON, so it's read rather than imported — no module-attribute support needed.\n` +
|
|
7225
|
+
`const raw = JSON.parse(readFileSync(new URL("./${detected.filename}", import.meta.url), "utf8"));\n`,
|
|
7226
|
+
expression: "raw",
|
|
7227
|
+
};
|
|
7228
|
+
}
|
|
7229
|
+
const specifier = detected.ext === ".ts" || detected.ext === ".mts"
|
|
7230
|
+
? `./${RAW_BASENAME}`
|
|
7231
|
+
: `./${detected.filename}`;
|
|
7232
|
+
return { head: `import { raw } from "${specifier}";\n`, expression: "raw" };
|
|
7233
|
+
}
|
|
7234
|
+
/**
|
|
7235
|
+
* The config body when a raw theme already exists — it imports that theme instead of carrying one.
|
|
7236
|
+
* Deliberately short: everything about the design lives in the theme file, and this is only wiring.
|
|
7237
|
+
*/
|
|
7238
|
+
function scaffoldConfigForRaw(packageName, detected) {
|
|
7239
|
+
const { head, expression } = rawThemeImport(detected);
|
|
7240
|
+
return `import { defineConfig } from "${packageName}/build";
|
|
7241
|
+
import { createCssAdapter } from "${CSS_ADAPTER_PACKAGE}";
|
|
7242
|
+
${head}
|
|
7243
|
+
// refract build config. \`refract build\` reads this file, builds the theme once, and writes each
|
|
7244
|
+
// target's emitted files into that target's \`outDir\`. The config is your code: it \`import\`s the
|
|
7245
|
+
// adapters it wants and passes their options at construction (not via CLI flags).
|
|
7246
|
+
//
|
|
7247
|
+
// Your tokens live in \`${detected.filename}\` — edit them there, not here.
|
|
7248
|
+
export default defineConfig({
|
|
7249
|
+
raw: ${expression},
|
|
7250
|
+
targets: [
|
|
7251
|
+
// The CSS adapter (from @theme-registry/refract-css). Pass its options here, at construction.
|
|
7252
|
+
{ adapter: createCssAdapter(/* { colors: { prefix: "app" } } */), outDir: "dist/theme" },
|
|
7253
|
+
|
|
7254
|
+
// Add another adapter target once its package is installed, e.g.:
|
|
7255
|
+
// import { createStyledComponentsAdapter } from "@theme-registry/refract-styled-components";
|
|
7256
|
+
// { adapter: createStyledComponentsAdapter(), outDir: "dist/theme-sc" },
|
|
7257
|
+
],
|
|
7258
|
+
});
|
|
7259
|
+
`;
|
|
7260
|
+
}
|
|
7161
7261
|
/** The scaffolded config body — one shared ESM template across ts/js/mjs. */
|
|
7162
7262
|
function scaffoldConfig(packageName) {
|
|
7163
7263
|
return `import { defineConfig } from "${packageName}/build";
|
|
@@ -7171,8 +7271,10 @@ export default defineConfig({
|
|
|
7171
7271
|
raw: {
|
|
7172
7272
|
breakpoints: { sm: 576, md: 768, lg: 1024, xl: 1280 },
|
|
7173
7273
|
colors: {
|
|
7174
|
-
|
|
7175
|
-
|
|
7274
|
+
// Starter palette passes its own contrast audit (WCAG AA): white text clears 4.5:1 on both
|
|
7275
|
+
// bases. Keep that when you retune — the auditor is a shipped feature; the default should model it.
|
|
7276
|
+
primary: { base: "#1864ab", text: "#fff", variants: { dark: "#0b4a86", light: "#a5d8ff" } },
|
|
7277
|
+
neutral: { base: "#495057", text: "#fff", variants: { light: "#f1f3f5", dark: "#212529" } },
|
|
7176
7278
|
recipes: {
|
|
7177
7279
|
solid: {
|
|
7178
7280
|
primary: { background: "primary", color: "primary.text" },
|
|
@@ -7197,7 +7299,7 @@ export default defineConfig({
|
|
|
7197
7299
|
* (never silently clobbers a user's config).
|
|
7198
7300
|
*/
|
|
7199
7301
|
function runInit(options = {}) {
|
|
7200
|
-
var _a, _b, _c;
|
|
7302
|
+
var _a, _b, _c, _d;
|
|
7201
7303
|
const cwd = (_a = options.cwd) !== null && _a !== void 0 ? _a : process.cwd();
|
|
7202
7304
|
const variant = (_b = options.variant) !== null && _b !== void 0 ? _b : "ts";
|
|
7203
7305
|
const packageName = (_c = options.packageName) !== null && _c !== void 0 ? _c : readOwnPackageName();
|
|
@@ -7205,8 +7307,13 @@ function runInit(options = {}) {
|
|
|
7205
7307
|
if (node_fs.existsSync(path) && !options.force) {
|
|
7206
7308
|
throw new Error(`theme.config.${variant} already exists at "${path}". Pass --force to overwrite it.`);
|
|
7207
7309
|
}
|
|
7208
|
-
|
|
7209
|
-
|
|
7310
|
+
// If the project already has a theme, wire it up rather than inventing a second one. Only when
|
|
7311
|
+
// there's nothing to find does the config carry a starter palette of its own — so `refract init`
|
|
7312
|
+
// on its own still produces something runnable, exactly as it always has.
|
|
7313
|
+
const detected = options.rawTheme === null ? undefined : (_d = options.rawTheme) !== null && _d !== void 0 ? _d : findRawTheme(cwd);
|
|
7314
|
+
const body = detected ? scaffoldConfigForRaw(packageName, detected) : scaffoldConfig(packageName);
|
|
7315
|
+
node_fs.writeFileSync(path, body, "utf8");
|
|
7316
|
+
return { path, variant, packageName, rawTheme: detected };
|
|
7210
7317
|
}
|
|
7211
7318
|
|
|
7212
7319
|
/**
|
|
@@ -7745,26 +7852,35 @@ function diffThemes(base, candidate) {
|
|
|
7745
7852
|
*/
|
|
7746
7853
|
const WCAG_RANK = { fail: 0, "AA-large": 1, AA: 2, AAA: 3 };
|
|
7747
7854
|
let counter = 0;
|
|
7748
|
-
/**
|
|
7855
|
+
/**
|
|
7856
|
+
* Load a candidate raw theme from a module (default export) or a `.json` file, and assert it's actually
|
|
7857
|
+
* theme-shaped before returning — a mis-shaped candidate (e.g. a `defineConfig`) must fail loud here,
|
|
7858
|
+
* not silently diff as "everything removed" (see {@link assertRawTheme}).
|
|
7859
|
+
*/
|
|
7749
7860
|
async function loadCandidate(path) {
|
|
7750
7861
|
var _a, _b, _c, _d;
|
|
7751
7862
|
if (!node_fs.existsSync(path))
|
|
7752
7863
|
throw new Error(`candidate theme not found at "${path}".`);
|
|
7864
|
+
let candidate;
|
|
7753
7865
|
if (node_path.extname(path) === ".json") {
|
|
7754
|
-
|
|
7866
|
+
candidate = JSON.parse(node_fs.readFileSync(path, "utf8"));
|
|
7755
7867
|
}
|
|
7756
|
-
if (node_path.extname(path) === ".ts") {
|
|
7868
|
+
else if (node_path.extname(path) === ".ts") {
|
|
7757
7869
|
const { entry, cleanup } = await compileTsConfigGraph(path);
|
|
7758
7870
|
try {
|
|
7759
7871
|
const mod = (await import(node_url.pathToFileURL(entry).href));
|
|
7760
|
-
|
|
7872
|
+
candidate = (_b = (_a = mod.default) !== null && _a !== void 0 ? _a : mod.raw) !== null && _b !== void 0 ? _b : mod;
|
|
7761
7873
|
}
|
|
7762
7874
|
finally {
|
|
7763
7875
|
cleanup();
|
|
7764
7876
|
}
|
|
7765
7877
|
}
|
|
7766
|
-
|
|
7767
|
-
|
|
7878
|
+
else {
|
|
7879
|
+
const mod = (await import(`${node_url.pathToFileURL(path).href}?v=${++counter}`));
|
|
7880
|
+
candidate = (_d = (_c = mod.default) !== null && _c !== void 0 ? _c : mod.raw) !== null && _d !== void 0 ? _d : mod;
|
|
7881
|
+
}
|
|
7882
|
+
assertRawTheme(candidate, path);
|
|
7883
|
+
return candidate;
|
|
7768
7884
|
}
|
|
7769
7885
|
/** Does this built theme expose the class-emitting surface (CSS-style adapter)? */
|
|
7770
7886
|
const emitsClasses = (theme) => typeof theme.renderRecipe === "function";
|
|
@@ -7831,6 +7947,746 @@ async function runDiff(options) {
|
|
|
7831
7947
|
return { configPath, candidatePath, diff, targets, ok: violations.length === 0, violations };
|
|
7832
7948
|
}
|
|
7833
7949
|
|
|
7950
|
+
/**
|
|
7951
|
+
* Guided raw-theme generator (Node-only, pure) — the engine behind `refract create`.
|
|
7952
|
+
*
|
|
7953
|
+
* Turns a seed colour plus a handful of answers into a complete `RawTheme`. Deliberately split from
|
|
7954
|
+
* the prompting (`cli.ts`) and from the file writing (`createCommand.ts`) so the whole generator is
|
|
7955
|
+
* testable without a TTY and reusable programmatically.
|
|
7956
|
+
*
|
|
7957
|
+
* The governing rule for what lands in the output:
|
|
7958
|
+
*
|
|
7959
|
+
* **Bake a literal** where the value came from an opinion the engine does not hold — the harmony
|
|
7960
|
+
* rotation, the contrast nudge, the leading/tracking curves. Those are this module's taste, and a
|
|
7961
|
+
* scaffolded theme is a style guide the user then owns, so it must hold still.
|
|
7962
|
+
*
|
|
7963
|
+
* **Write the declaration** where the engine already synthesizes — `fontSize.ratio`,
|
|
7964
|
+
* `layout.spacing.step`. Baking those would throw the intent away: re-tuning a scale should stay a
|
|
7965
|
+
* one-word edit, not a regeneration of eight numbers.
|
|
7966
|
+
*
|
|
7967
|
+
* Nothing here changes the palette model. It calls the colour helpers the package already ships
|
|
7968
|
+
* (`rotateHue`/`darken`, and `audit` for the contrast gate) and writes down what they return, once.
|
|
7969
|
+
* Callers who want a companion that *re-derives* on `override()` should use `colors.harmony` instead —
|
|
7970
|
+
* that is the other tool, for the other job.
|
|
7971
|
+
*/
|
|
7972
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
7973
|
+
// Tunable constants — the generator's opinions, deliberately in the open.
|
|
7974
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
7975
|
+
/**
|
|
7976
|
+
* Leading falls as size grows: `LEADING_AT_BASE × (BASE_PX / size) ^ LEADING_EXPONENT`, clamped.
|
|
7977
|
+
* Fitted to land on conventional values at 16px (1.50) and to tighten a display line enough that a
|
|
7978
|
+
* dramatic ratio doesn't ship a 67px headline set at 1.5. Another designer would pick differently.
|
|
7979
|
+
*/
|
|
7980
|
+
const LEADING_AT_BASE = 1.5;
|
|
7981
|
+
const LEADING_EXPONENT = 0.22;
|
|
7982
|
+
const LEADING_MIN = 1.1;
|
|
7983
|
+
const LEADING_MAX = 1.7;
|
|
7984
|
+
/**
|
|
7985
|
+
* Tracking crosses from positive (small text wants air) to negative (display text wants tightening):
|
|
7986
|
+
* `TRACKING_NUMERATOR / size − TRACKING_OFFSET`, in em. Zero at 16px by construction.
|
|
7987
|
+
*/
|
|
7988
|
+
const TRACKING_NUMERATOR = 0.36;
|
|
7989
|
+
const TRACKING_OFFSET = 0.0225;
|
|
7990
|
+
/** The reference size both curves are anchored at. */
|
|
7991
|
+
const CURVE_ANCHOR_PX = 16;
|
|
7992
|
+
/** The engine's default `fontSize` step names and their exponent off `base`. Mirrors typography. */
|
|
7993
|
+
const TYPE_STEPS = [
|
|
7994
|
+
["xs", -2], ["sm", -1], ["md", 0], ["lg", 1],
|
|
7995
|
+
["xl", 2], ["2xl", 3], ["3xl", 4], ["4xl", 5],
|
|
7996
|
+
];
|
|
7997
|
+
/** Numeric value of each named type ratio. Mirrors `TYPOGRAPHY_RATIOS` in the typography subsystem. */
|
|
7998
|
+
const RATIO_VALUES = {
|
|
7999
|
+
"minor-second": 1.067, "major-second": 1.125, "minor-third": 1.2, "major-third": 1.25,
|
|
8000
|
+
"perfect-fourth": 1.333, "augmented-fourth": 1.414, "perfect-fifth": 1.5, golden: 1.618,
|
|
8001
|
+
};
|
|
8002
|
+
/**
|
|
8003
|
+
* Hue rotations per scheme. Aligned with the colours subsystem's own `HARMONY_SCHEMES` where they
|
|
8004
|
+
* overlap, so a scaffolded literal and an authored `harmony:` key agree. `pentadic` is local — the
|
|
8005
|
+
* subsystem has no five-way scheme, and the scaffolder needs one to reach five brand colours.
|
|
8006
|
+
*/
|
|
8007
|
+
const SCHEME_ROTATIONS = {
|
|
8008
|
+
complement: [180],
|
|
8009
|
+
analogous: [-30, 30],
|
|
8010
|
+
"split-complement": [150, 210],
|
|
8011
|
+
triadic: [120, 240],
|
|
8012
|
+
tetradic: [90, 180, 270],
|
|
8013
|
+
pentadic: [72, 144, 216, 288],
|
|
8014
|
+
};
|
|
8015
|
+
/** Ordinal names for the generated brand palettes. Rename freely — it's your file. */
|
|
8016
|
+
const BRAND_NAMES = ["primary", "secondary", "tertiary", "quaternary", "quinary"];
|
|
8017
|
+
/**
|
|
8018
|
+
* Semantic colours start from fixed hue anchors, NOT from a rotation off the seed. Rotating would
|
|
8019
|
+
* make "danger" whatever hue lands at +150° — from a red seed you'd get a green danger, which is
|
|
8020
|
+
* worse than useless. These are conventional anchors; the contrast pass then adapts their lightness.
|
|
8021
|
+
*/
|
|
8022
|
+
const SEMANTIC_ANCHORS = [
|
|
8023
|
+
["success", "#2f9e44", "#ffffff"],
|
|
8024
|
+
["info", "#1c7ed6", "#ffffff"],
|
|
8025
|
+
["warning", "#e89012", "#1f2733"],
|
|
8026
|
+
["danger", "#e03131", "#ffffff"],
|
|
8027
|
+
];
|
|
8028
|
+
/** The neutral seed and the shadow ink. Both are deliberately cool — they sit under everything. */
|
|
8029
|
+
const NEUTRAL_SEED = "#6b7280";
|
|
8030
|
+
const SHADOW_INK = "#18274b";
|
|
8031
|
+
/** The absolute lightness ladder every palette carries. `L = (1000 − label) / 10`. */
|
|
8032
|
+
const LADDER = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900];
|
|
8033
|
+
/** Alpha levels for the shadow tints, matched to the three elevation levels. */
|
|
8034
|
+
const SHADOW_ALPHAS = [8, 14, 22];
|
|
8035
|
+
/** How many times the contrast pass may darken a failing palette before giving up. */
|
|
8036
|
+
const MAX_CONTRAST_ITERATIONS = 40;
|
|
8037
|
+
const SYSTEM_SANS = "system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif";
|
|
8038
|
+
const SYSTEM_MONO = "ui-monospace, 'SF Mono', Menlo, Consolas, monospace";
|
|
8039
|
+
const SYSTEM_SERIF = "'Iowan Old Style', 'Palatino Linotype', Palatino, Georgia, serif";
|
|
8040
|
+
const FEEL_PRESETS = {
|
|
8041
|
+
neutral: {
|
|
8042
|
+
label: "Neutral",
|
|
8043
|
+
blurb: "system type, comfortable spacing, soft corners",
|
|
8044
|
+
fontFamily: { base: SYSTEM_SANS, mono: SYSTEM_MONO },
|
|
8045
|
+
spacing: { xs: 1, sm: 2, md: 4, lg: 6, xl: 8, "2xl": 12 },
|
|
8046
|
+
radius: { base: 8, variants: { none: 0, sm: 4, lg: 14, pill: "9999px" } },
|
|
8047
|
+
ratio: "major-third",
|
|
8048
|
+
},
|
|
8049
|
+
compact: {
|
|
8050
|
+
label: "Compact",
|
|
8051
|
+
blurb: "dense UI, tight scale, small radius",
|
|
8052
|
+
fontFamily: { base: SYSTEM_SANS, mono: SYSTEM_MONO },
|
|
8053
|
+
spacing: { xs: 1, sm: 2, md: 3, lg: 4, xl: 6, "2xl": 8 },
|
|
8054
|
+
radius: { base: 4, variants: { none: 0, sm: 2, lg: 8, pill: "9999px" } },
|
|
8055
|
+
ratio: "major-second",
|
|
8056
|
+
},
|
|
8057
|
+
editorial: {
|
|
8058
|
+
label: "Editorial",
|
|
8059
|
+
blurb: "serif display, spacious, generous leading",
|
|
8060
|
+
fontFamily: { base: SYSTEM_SERIF, mono: SYSTEM_MONO },
|
|
8061
|
+
spacing: { xs: 2, sm: 4, md: 6, lg: 8, xl: 12, "2xl": 16 },
|
|
8062
|
+
radius: { base: 2, variants: { none: 0, sm: 1, lg: 4, pill: "9999px" } },
|
|
8063
|
+
ratio: "perfect-fourth",
|
|
8064
|
+
},
|
|
8065
|
+
technical: {
|
|
8066
|
+
label: "Technical",
|
|
8067
|
+
blurb: "mono accents, compact, sharp corners",
|
|
8068
|
+
fontFamily: { base: SYSTEM_MONO, mono: SYSTEM_MONO },
|
|
8069
|
+
spacing: { xs: 1, sm: 2, md: 3, lg: 4, xl: 6, "2xl": 8 },
|
|
8070
|
+
radius: { base: 0, variants: { none: 0, sm: 0, lg: 2, pill: "9999px" } },
|
|
8071
|
+
ratio: "minor-third",
|
|
8072
|
+
},
|
|
8073
|
+
};
|
|
8074
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
8075
|
+
// Helpers
|
|
8076
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
8077
|
+
const round = (n, places) => Number(n.toFixed(places));
|
|
8078
|
+
/** Normalize any accepted colour input to a `#rrggbb` literal, so the written file is uniform. */
|
|
8079
|
+
const toHex = (value) => convertRgbToHex(parseColor(value).rgb);
|
|
8080
|
+
/** Leading for a size, from the curve above. */
|
|
8081
|
+
const deriveLeading = (sizePx) => round(Math.min(LEADING_MAX, Math.max(LEADING_MIN, LEADING_AT_BASE * Math.pow(CURVE_ANCHOR_PX / sizePx, LEADING_EXPONENT))), 2);
|
|
8082
|
+
/** Tracking (em) for a size, from the curve above. */
|
|
8083
|
+
const deriveTracking = (sizePx) => `${round(TRACKING_NUMERATOR / sizePx - TRACKING_OFFSET, 3)}em`;
|
|
8084
|
+
/**
|
|
8085
|
+
* The ladder label a colour's lightness sits nearest. `base` stays the brand colour — this exists so
|
|
8086
|
+
* the CLI can tell you which stops are your hover and active shades without moving the hex you typed.
|
|
8087
|
+
*/
|
|
8088
|
+
const nearestLadderStep = (lightness) => {
|
|
8089
|
+
const label = 1000 - lightness * 10;
|
|
8090
|
+
let best = LADDER[0];
|
|
8091
|
+
for (const stop of LADDER)
|
|
8092
|
+
if (Math.abs(stop - label) < Math.abs(best - label))
|
|
8093
|
+
best = stop;
|
|
8094
|
+
return best;
|
|
8095
|
+
};
|
|
8096
|
+
/** Which scheme a brand count implies when the caller doesn't name one. */
|
|
8097
|
+
const defaultSchemeFor = (brandCount) => {
|
|
8098
|
+
if (brandCount <= 1)
|
|
8099
|
+
return undefined;
|
|
8100
|
+
if (brandCount === 2)
|
|
8101
|
+
return "complement";
|
|
8102
|
+
if (brandCount === 3)
|
|
8103
|
+
return "triadic";
|
|
8104
|
+
if (brandCount === 4)
|
|
8105
|
+
return "tetradic";
|
|
8106
|
+
return "pentadic";
|
|
8107
|
+
};
|
|
8108
|
+
/** Schemes that produce exactly `brandCount − 1` members — the valid choices at that count. */
|
|
8109
|
+
const schemesFor = (brandCount) => Object.keys(SCHEME_ROTATIONS).filter((s) => SCHEME_ROTATIONS[s].length === brandCount - 1);
|
|
8110
|
+
/**
|
|
8111
|
+
* The contrast gate. Builds the palette set, audits every text-on-base pairing, and walks the
|
|
8112
|
+
* lightness of each failing colour down one OKLCH point at a time until it clears the bar.
|
|
8113
|
+
*
|
|
8114
|
+
* Iterative rather than closed-form because the ratio is not monotonic in a way worth inverting, and
|
|
8115
|
+
* because `audit` owns the thresholds — re-running it is how the generator stays honest about what
|
|
8116
|
+
* the library considers a pass.
|
|
8117
|
+
*/
|
|
8118
|
+
function applyContrastPass(palettes, bar) {
|
|
8119
|
+
var _a;
|
|
8120
|
+
const current = palettes.map((p) => ({ ...p }));
|
|
8121
|
+
const nudges = new Map(current.map((p) => [p.name, 0]));
|
|
8122
|
+
const firstRatio = new Map();
|
|
8123
|
+
const scoreAll = () => {
|
|
8124
|
+
var _a, _b;
|
|
8125
|
+
const colors = {};
|
|
8126
|
+
for (const p of current)
|
|
8127
|
+
colors[p.name] = { base: p.base, text: p.text };
|
|
8128
|
+
const theme = createTheme({ colors }, { adapter: createNoopAdapter() });
|
|
8129
|
+
const result = audit(theme, { minWcag: bar, includeRecipes: false });
|
|
8130
|
+
const out = new Map();
|
|
8131
|
+
for (const pairing of result.pairings) {
|
|
8132
|
+
// A pairing whose fg/bg isn't a derivable colour is reported as `skipped` with no score. It
|
|
8133
|
+
// can't be nudged toward a bar it was never measured against, so leave it out entirely.
|
|
8134
|
+
if (pairing.skipped || pairing.wcagRatio === undefined)
|
|
8135
|
+
continue;
|
|
8136
|
+
const name = pairing.label.replace(/^colors\./, "");
|
|
8137
|
+
out.set(name, {
|
|
8138
|
+
ratio: pairing.wcagRatio,
|
|
8139
|
+
level: (_a = pairing.wcagLevel) !== null && _a !== void 0 ? _a : "fail",
|
|
8140
|
+
pass: (_b = pairing.pass) !== null && _b !== void 0 ? _b : false,
|
|
8141
|
+
});
|
|
8142
|
+
}
|
|
8143
|
+
return out;
|
|
8144
|
+
};
|
|
8145
|
+
let scores = scoreAll();
|
|
8146
|
+
for (const [name, s] of scores)
|
|
8147
|
+
if (!firstRatio.has(name))
|
|
8148
|
+
firstRatio.set(name, s.ratio);
|
|
8149
|
+
for (let i = 0; i < MAX_CONTRAST_ITERATIONS; i++) {
|
|
8150
|
+
const failing = current.filter((p) => scores.get(p.name) && !scores.get(p.name).pass);
|
|
8151
|
+
if (!failing.length)
|
|
8152
|
+
break;
|
|
8153
|
+
for (const p of failing) {
|
|
8154
|
+
p.base = toHex(darken(p.base, 1));
|
|
8155
|
+
nudges.set(p.name, ((_a = nudges.get(p.name)) !== null && _a !== void 0 ? _a : 0) + 1);
|
|
8156
|
+
}
|
|
8157
|
+
scores = scoreAll();
|
|
8158
|
+
}
|
|
8159
|
+
const adjustments = current.map((p, idx) => {
|
|
8160
|
+
var _a, _b, _c, _d;
|
|
8161
|
+
const s = scores.get(p.name);
|
|
8162
|
+
return {
|
|
8163
|
+
name: p.name,
|
|
8164
|
+
seed: palettes[idx].base,
|
|
8165
|
+
final: p.base,
|
|
8166
|
+
nudge: (_a = nudges.get(p.name)) !== null && _a !== void 0 ? _a : 0,
|
|
8167
|
+
ratioBefore: round((_b = firstRatio.get(p.name)) !== null && _b !== void 0 ? _b : 0, 2),
|
|
8168
|
+
ratioAfter: round((_c = s === null || s === void 0 ? void 0 : s.ratio) !== null && _c !== void 0 ? _c : 0, 2),
|
|
8169
|
+
levelAfter: (_d = s === null || s === void 0 ? void 0 : s.level) !== null && _d !== void 0 ? _d : "fail",
|
|
8170
|
+
unresolved: !!s && !s.pass,
|
|
8171
|
+
};
|
|
8172
|
+
});
|
|
8173
|
+
return { palettes: current, adjustments };
|
|
8174
|
+
}
|
|
8175
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
8176
|
+
// The generator
|
|
8177
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
8178
|
+
/**
|
|
8179
|
+
* Build a complete `RawTheme` from the interview answers. Pure — no filesystem, no prompting, no
|
|
8180
|
+
* randomness — so the same answers always produce the same theme.
|
|
8181
|
+
*/
|
|
8182
|
+
function scaffoldTheme(answers) {
|
|
8183
|
+
var _a, _b, _c, _d, _e, _f, _g, _h, _j;
|
|
8184
|
+
const mode = (_a = answers.mode) !== null && _a !== void 0 ? _a : "auto";
|
|
8185
|
+
const feel = FEEL_PRESETS[(_b = answers.feel) !== null && _b !== void 0 ? _b : "neutral"];
|
|
8186
|
+
const brandCount = Math.min(5, Math.max(1, (_c = answers.brandCount) !== null && _c !== void 0 ? _c : 2));
|
|
8187
|
+
const baseFontSize = (_d = answers.baseFontSize) !== null && _d !== void 0 ? _d : CURVE_ANCHOR_PX;
|
|
8188
|
+
const ratioName = (_e = answers.ratio) !== null && _e !== void 0 ? _e : feel.ratio;
|
|
8189
|
+
const ratioValue = RATIO_VALUES[ratioName];
|
|
8190
|
+
if (!ratioValue) {
|
|
8191
|
+
throw new Error(`Unknown type ratio "${ratioName}". Use one of: ${Object.keys(RATIO_VALUES).join(", ")}.`);
|
|
8192
|
+
}
|
|
8193
|
+
// ── brand colours ──────────────────────────────────────────────────────────
|
|
8194
|
+
const seedHex = toHex(answers.seed);
|
|
8195
|
+
const brand = [
|
|
8196
|
+
{ name: BRAND_NAMES[0], hex: seedHex, rotation: 0 },
|
|
8197
|
+
];
|
|
8198
|
+
if (mode === "manual") {
|
|
8199
|
+
((_f = answers.extraColors) !== null && _f !== void 0 ? _f : []).slice(0, BRAND_NAMES.length - 1).forEach((c, i) => {
|
|
8200
|
+
brand.push({ name: BRAND_NAMES[i + 1], hex: toHex(c), rotation: 0 });
|
|
8201
|
+
});
|
|
8202
|
+
}
|
|
8203
|
+
else if (brandCount > 1) {
|
|
8204
|
+
const scheme = (_g = answers.scheme) !== null && _g !== void 0 ? _g : defaultSchemeFor(brandCount);
|
|
8205
|
+
const rotations = SCHEME_ROTATIONS[scheme];
|
|
8206
|
+
if (!rotations)
|
|
8207
|
+
throw new Error(`Unknown harmony scheme "${scheme}".`);
|
|
8208
|
+
rotations.slice(0, brandCount - 1).forEach((deg, i) => {
|
|
8209
|
+
brand.push({ name: BRAND_NAMES[i + 1], hex: toHex(rotateHue(seedHex, deg)), rotation: deg });
|
|
8210
|
+
});
|
|
8211
|
+
}
|
|
8212
|
+
// ── the contrast gate ──────────────────────────────────────────────────────
|
|
8213
|
+
const contrastTarget = (_h = answers.contrast) !== null && _h !== void 0 ? _h : "AA";
|
|
8214
|
+
const candidates = [
|
|
8215
|
+
...brand.map((b) => ({ name: b.name, base: b.hex, text: "#ffffff" })),
|
|
8216
|
+
...(answers.semantics === false ? [] : SEMANTIC_ANCHORS.map(([n, base, text]) => ({ name: n, base, text }))),
|
|
8217
|
+
...(answers.neutral === false ? [] : [{ name: "neutral", base: NEUTRAL_SEED, text: "#ffffff" }]),
|
|
8218
|
+
];
|
|
8219
|
+
const { palettes, adjustments } = contrastTarget === "none"
|
|
8220
|
+
? { palettes: candidates, adjustments: [] }
|
|
8221
|
+
: applyContrastPass(candidates, contrastTarget);
|
|
8222
|
+
// ── colors ─────────────────────────────────────────────────────────────────
|
|
8223
|
+
const colors = {};
|
|
8224
|
+
for (const p of palettes)
|
|
8225
|
+
colors[p.name] = { base: p.base, text: p.text, steps: [...LADDER] };
|
|
8226
|
+
if (answers.shadows !== false) {
|
|
8227
|
+
const variants = {};
|
|
8228
|
+
for (const a of SHADOW_ALPHAS)
|
|
8229
|
+
variants[`a${String(a).padStart(2, "0")}`] = { modifiers: [{ alpha: a }] };
|
|
8230
|
+
colors.shadow = { base: SHADOW_INK, variants };
|
|
8231
|
+
}
|
|
8232
|
+
// ── typography ─────────────────────────────────────────────────────────────
|
|
8233
|
+
// `fontSize` is a DECLARATION — the engine synthesizes xs…4xl from base + ratio. Leading and
|
|
8234
|
+
// tracking are baked, because the curves are this module's opinion, and they're named after the
|
|
8235
|
+
// size step they were tuned for so the pairing documents itself without a recipe.
|
|
8236
|
+
const sizes = TYPE_STEPS.map(([step, exp]) => ({ step, px: round(baseFontSize * Math.pow(ratioValue, exp), 2) }));
|
|
8237
|
+
const leading = {};
|
|
8238
|
+
const tracking = {};
|
|
8239
|
+
for (const { step, px } of sizes) {
|
|
8240
|
+
if (step === "md")
|
|
8241
|
+
continue; // md === base; the base value covers it
|
|
8242
|
+
leading[step] = deriveLeading(px);
|
|
8243
|
+
tracking[step] = deriveTracking(px);
|
|
8244
|
+
}
|
|
8245
|
+
tracking.caps = "0.06em"; // caps always want more air than the curve gives
|
|
8246
|
+
const typography = {
|
|
8247
|
+
fontFamily: { base: feel.fontFamily.base, variants: { mono: feel.fontFamily.mono } },
|
|
8248
|
+
fontWeight: { base: 400, variants: { medium: 500, semibold: 600, bold: 700 } },
|
|
8249
|
+
fontSize: { base: baseFontSize, ratio: ratioName },
|
|
8250
|
+
lineHeight: { base: deriveLeading(baseFontSize), variants: leading },
|
|
8251
|
+
letterSpacing: { base: "0em", variants: tracking },
|
|
8252
|
+
};
|
|
8253
|
+
// ── layout ─────────────────────────────────────────────────────────────────
|
|
8254
|
+
// The LINEAR curve, not the geometric one: `step: 4` keeps every stop on a 4px grid, where
|
|
8255
|
+
// `ratio: 1.5` would give 8·12·18·27·40.5·60.75. Geometric is right for type and wrong for space.
|
|
8256
|
+
const layout = { spacing: { base: 4, step: 4, steps: { ...feel.spacing } } };
|
|
8257
|
+
// ── borders · effects · animation · globals ────────────────────────────────
|
|
8258
|
+
const borders = {
|
|
8259
|
+
width: { base: 1, variants: { thick: 2 } },
|
|
8260
|
+
style: { base: "solid" },
|
|
8261
|
+
radius: { base: feel.radius.base, variants: { ...feel.radius.variants } },
|
|
8262
|
+
};
|
|
8263
|
+
const effects = answers.shadows === false
|
|
8264
|
+
? undefined
|
|
8265
|
+
: {
|
|
8266
|
+
shadow: {
|
|
8267
|
+
offsetY: 1, blur: 3, color: "colors.shadow.a08",
|
|
8268
|
+
variants: {
|
|
8269
|
+
md: { offsetY: 6, blur: 16, color: "colors.shadow.a14" },
|
|
8270
|
+
lg: { offsetY: 14, blur: 34, color: "colors.shadow.a22" },
|
|
8271
|
+
none: "none",
|
|
8272
|
+
},
|
|
8273
|
+
},
|
|
8274
|
+
};
|
|
8275
|
+
// No `keyframes`: one would only pay off with a recipe to reference it, and the scaffold writes
|
|
8276
|
+
// no recipes — so generating one would ship dead CSS.
|
|
8277
|
+
const animation = {
|
|
8278
|
+
duration: { base: 200, variants: { fast: 120, slow: 400 } },
|
|
8279
|
+
easing: { base: "cubic-bezier(.2,.7,.3,1)", variants: { out: "cubic-bezier(.16,.84,.44,1)" } },
|
|
8280
|
+
};
|
|
8281
|
+
const raw = {
|
|
8282
|
+
colors,
|
|
8283
|
+
typography,
|
|
8284
|
+
layout,
|
|
8285
|
+
borders,
|
|
8286
|
+
...(effects ? { effects } : {}),
|
|
8287
|
+
animation,
|
|
8288
|
+
...(answers.reset === "none" ? {} : { globals: { preset: (_j = answers.reset) !== null && _j !== void 0 ? _j : "preflight" } }),
|
|
8289
|
+
};
|
|
8290
|
+
const seedLightness = round(rgbToOklch(parseColor(seedHex).rgb).L, 1);
|
|
8291
|
+
return {
|
|
8292
|
+
raw,
|
|
8293
|
+
report: {
|
|
8294
|
+
seedLightness,
|
|
8295
|
+
nearestStep: nearestLadderStep(seedLightness),
|
|
8296
|
+
brand: brand.map((b) => {
|
|
8297
|
+
var _a;
|
|
8298
|
+
const adjusted = adjustments.find((a) => a.name === b.name);
|
|
8299
|
+
return { name: b.name, hex: (_a = adjusted === null || adjusted === void 0 ? void 0 : adjusted.final) !== null && _a !== void 0 ? _a : b.hex, rotation: b.rotation };
|
|
8300
|
+
}),
|
|
8301
|
+
contrast: adjustments,
|
|
8302
|
+
type: sizes.map(({ step, px }) => ({
|
|
8303
|
+
step, px, leading: deriveLeading(px), tracking: deriveTracking(px),
|
|
8304
|
+
})),
|
|
8305
|
+
spacing: Object.entries(feel.spacing).map(([step, mult]) => ({ step, px: mult * 4 })),
|
|
8306
|
+
},
|
|
8307
|
+
};
|
|
8308
|
+
}
|
|
8309
|
+
|
|
8310
|
+
/**
|
|
8311
|
+
* `refract create` — write a scaffolded `theme.raw.(ts|js|json)` (Node-only).
|
|
8312
|
+
*
|
|
8313
|
+
* The interview lives in `cli.ts`; the generator lives in `scaffold.ts`. This module is only the
|
|
8314
|
+
* seam between them: resolve answers → generate → serialize → write, refusing to clobber.
|
|
8315
|
+
*
|
|
8316
|
+
* One artefact per command. `create` writes the raw theme and nothing else — no build config (that's
|
|
8317
|
+
* `init`, which detects this file), and no rendered specimen (that's a build-time concern, since a
|
|
8318
|
+
* `RawTheme` has no rendered form and this command has no idea which adapter you'll build with).
|
|
8319
|
+
*
|
|
8320
|
+
* Mirrors `import`'s conventions on purpose — same default filename, same refuse-to-clobber, same
|
|
8321
|
+
* "a theme you then own and edit" framing — because they're the same job from a different input.
|
|
8322
|
+
*/
|
|
8323
|
+
const DEFAULT_RAW_BASENAME = "theme.raw";
|
|
8324
|
+
/** A key that can be written bare in JS/TS source rather than quoted. */
|
|
8325
|
+
const BARE_KEY = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
|
8326
|
+
/** Characters that must be escaped inside a double-quoted source string. */
|
|
8327
|
+
const escapeString = (s) => JSON.stringify(s);
|
|
8328
|
+
/** Width at which a primitive-only array stops being inlined and goes one-per-line. */
|
|
8329
|
+
const INLINE_ARRAY_WIDTH = 76;
|
|
8330
|
+
/**
|
|
8331
|
+
* Pretty-print a value as JS/TS source.
|
|
8332
|
+
*
|
|
8333
|
+
* `JSON.stringify` would be shorter, but it quotes every key and explodes a ten-number ladder over
|
|
8334
|
+
* ten lines — and this file's entire premise is that you open it and edit it. So: bare keys where
|
|
8335
|
+
* they're legal, and primitive arrays kept on one line while they fit.
|
|
8336
|
+
*/
|
|
8337
|
+
function formatLiteral(value, indent = 0) {
|
|
8338
|
+
const pad = " ".repeat(indent);
|
|
8339
|
+
const padInner = " ".repeat(indent + 1);
|
|
8340
|
+
if (value === null)
|
|
8341
|
+
return "null";
|
|
8342
|
+
if (typeof value === "string")
|
|
8343
|
+
return escapeString(value);
|
|
8344
|
+
if (typeof value === "number" || typeof value === "boolean")
|
|
8345
|
+
return String(value);
|
|
8346
|
+
if (Array.isArray(value)) {
|
|
8347
|
+
if (!value.length)
|
|
8348
|
+
return "[]";
|
|
8349
|
+
const primitives = value.every(v => v === null || typeof v !== "object");
|
|
8350
|
+
if (primitives) {
|
|
8351
|
+
const inline = `[${value.map(v => formatLiteral(v)).join(", ")}]`;
|
|
8352
|
+
if (pad.length + inline.length <= INLINE_ARRAY_WIDTH)
|
|
8353
|
+
return inline;
|
|
8354
|
+
}
|
|
8355
|
+
const items = value.map(v => `${padInner}${formatLiteral(v, indent + 1)}`);
|
|
8356
|
+
return `[\n${items.join(",\n")}\n${pad}]`;
|
|
8357
|
+
}
|
|
8358
|
+
const entries = Object.entries(value);
|
|
8359
|
+
if (!entries.length)
|
|
8360
|
+
return "{}";
|
|
8361
|
+
const lines = entries.map(([key, v]) => {
|
|
8362
|
+
const name = BARE_KEY.test(key) ? key : escapeString(key);
|
|
8363
|
+
return `${padInner}${name}: ${formatLiteral(v, indent + 1)}`;
|
|
8364
|
+
});
|
|
8365
|
+
return `{\n${lines.join(",\n")}\n${pad}}`;
|
|
8366
|
+
}
|
|
8367
|
+
/** Shared preamble for the code flavours. Kept short — the file is the user's from here on. */
|
|
8368
|
+
const banner = (seed, detail) => `// Generated by \`refract create\` — seed ${seed} · ${detail}.\n` +
|
|
8369
|
+
`// This is YOUR theme now: every value is a literal you can read, edit and delete. The generator\n` +
|
|
8370
|
+
`// does not run again. Tonal ladders, the type scale and the spacing ramp are synthesized by the\n` +
|
|
8371
|
+
`// engine from the declarations below — re-tune a scale by editing one word, not eight numbers.\n`;
|
|
8372
|
+
/**
|
|
8373
|
+
* Serialize a generated theme to source text.
|
|
8374
|
+
*
|
|
8375
|
+
* `ts` and `js` share one body and differ only in how the type is attached (a real `satisfies` vs a
|
|
8376
|
+
* JSDoc cast), so a JS user gets the same editor completion without adding a compile step. `json`
|
|
8377
|
+
* carries no types and no shared `ladder` const — it is the portable flavour, and for a scaffolded
|
|
8378
|
+
* theme it is exactly as faithful, because nothing here is a function or a recipe.
|
|
8379
|
+
*/
|
|
8380
|
+
function renderRawTheme(raw, options) {
|
|
8381
|
+
// JSON stays strict JSON — quoted keys, no trailing anything — so it parses anywhere.
|
|
8382
|
+
if (options.format === "json")
|
|
8383
|
+
return `${JSON.stringify(raw, null, 2)}\n`;
|
|
8384
|
+
const body = formatLiteral(raw);
|
|
8385
|
+
if (options.format === "js") {
|
|
8386
|
+
return (`${banner(options.seed, options.detail)}\n` +
|
|
8387
|
+
`/** @type {import("${options.packageName}/build").RawTheme} */\n` +
|
|
8388
|
+
`export const raw = ${body};\n`);
|
|
8389
|
+
}
|
|
8390
|
+
return (`${banner(options.seed, options.detail)}` +
|
|
8391
|
+
`import type { RawTheme } from "${options.packageName}/build";\n\n` +
|
|
8392
|
+
`export const raw = ${body} satisfies RawTheme;\n`);
|
|
8393
|
+
}
|
|
8394
|
+
/** A one-line description of the choices that produced a theme, for the file banner. */
|
|
8395
|
+
function describeChoices(options) {
|
|
8396
|
+
var _a, _b, _c, _d, _e, _f;
|
|
8397
|
+
const parts = [];
|
|
8398
|
+
const count = options.mode === "manual" ? ((_b = (_a = options.extraColors) === null || _a === void 0 ? void 0 : _a.length) !== null && _b !== void 0 ? _b : 0) + 1 : (_c = options.brandCount) !== null && _c !== void 0 ? _c : 2;
|
|
8399
|
+
parts.push(`${count} brand colour${count === 1 ? "" : "s"}`);
|
|
8400
|
+
if (options.mode !== "manual" && count > 1)
|
|
8401
|
+
parts.push((_d = options.scheme) !== null && _d !== void 0 ? _d : "auto");
|
|
8402
|
+
parts.push((_e = options.feel) !== null && _e !== void 0 ? _e : "neutral");
|
|
8403
|
+
if (options.contrast !== "none")
|
|
8404
|
+
parts.push(`WCAG ${(_f = options.contrast) !== null && _f !== void 0 ? _f : "AA"}`);
|
|
8405
|
+
return parts.join(" · ");
|
|
8406
|
+
}
|
|
8407
|
+
/**
|
|
8408
|
+
* Generate and write the raw theme. Throws when the target exists and `force` is not set — the same
|
|
8409
|
+
* guard `init` and `import` use, so no command in this CLI can silently destroy authored work.
|
|
8410
|
+
*/
|
|
8411
|
+
function runCreate(options) {
|
|
8412
|
+
var _a, _b, _c, _d;
|
|
8413
|
+
const cwd = (_a = options.cwd) !== null && _a !== void 0 ? _a : process.cwd();
|
|
8414
|
+
const format = (_b = options.format) !== null && _b !== void 0 ? _b : "ts";
|
|
8415
|
+
const filename = (_c = options.out) !== null && _c !== void 0 ? _c : `${DEFAULT_RAW_BASENAME}.${format}`;
|
|
8416
|
+
const path = node_path.join(cwd, filename);
|
|
8417
|
+
if (node_fs.existsSync(path) && !options.force) {
|
|
8418
|
+
throw new Error(`"${filename}" already exists. Re-run with --force to overwrite it.`);
|
|
8419
|
+
}
|
|
8420
|
+
const { raw, report } = scaffoldTheme(options);
|
|
8421
|
+
const packageName = (_d = options.packageName) !== null && _d !== void 0 ? _d : readOwnPackageName();
|
|
8422
|
+
const source = renderRawTheme(raw, {
|
|
8423
|
+
format,
|
|
8424
|
+
packageName,
|
|
8425
|
+
seed: options.seed,
|
|
8426
|
+
detail: describeChoices(options),
|
|
8427
|
+
});
|
|
8428
|
+
node_fs.writeFileSync(path, source, "utf8");
|
|
8429
|
+
return { path, format, raw, report, sections: Object.keys(raw) };
|
|
8430
|
+
}
|
|
8431
|
+
|
|
8432
|
+
/**
|
|
8433
|
+
* Minimal interactive prompts over `node:readline` (Node-only).
|
|
8434
|
+
*
|
|
8435
|
+
* The package ships zero runtime dependencies and this is the build layer, so rather than pull in a
|
|
8436
|
+
* prompt library for one command these are the four shapes the CLI actually needs. They are
|
|
8437
|
+
* line-based, not raw-mode: you type a number or a value and press Enter. That loses arrow-key
|
|
8438
|
+
* navigation, and buys a prompt that behaves predictably over SSH, in CI logs, in a Docker build,
|
|
8439
|
+
* and under any terminal that doesn't grant raw mode — where a fancier prompt would hang or garble.
|
|
8440
|
+
*
|
|
8441
|
+
* Every prompt is **non-interactive-safe**: with no TTY (a pipe, CI, `--yes`) it takes the default
|
|
8442
|
+
* without blocking, so a scripted run never deadlocks waiting on stdin that will never arrive.
|
|
8443
|
+
*/
|
|
8444
|
+
/** ANSI helpers — no-ops when the stream isn't a TTY, so piped output stays clean. */
|
|
8445
|
+
const useColor = () => Boolean(process.stdout.isTTY) && !process.env.NO_COLOR;
|
|
8446
|
+
const paint = (code, s) => (useColor() ? `\u001b[${code}m${s}\u001b[0m` : s);
|
|
8447
|
+
const bold = (s) => paint("1", s);
|
|
8448
|
+
const dim = (s) => paint("2", s);
|
|
8449
|
+
const cyan = (s) => paint("36", s);
|
|
8450
|
+
const green = (s) => paint("32", s);
|
|
8451
|
+
const yellow = (s) => paint("33", s);
|
|
8452
|
+
/**
|
|
8453
|
+
* A prompt session. Holds one readline interface for the whole interview so stdin is opened and
|
|
8454
|
+
* closed exactly once; `interactive` is false when there's no TTY, and every ask short-circuits to
|
|
8455
|
+
* its default.
|
|
8456
|
+
*/
|
|
8457
|
+
class Prompter {
|
|
8458
|
+
constructor(interactive = Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY)) {
|
|
8459
|
+
this.interactive = interactive;
|
|
8460
|
+
}
|
|
8461
|
+
get io() {
|
|
8462
|
+
if (!this.rl)
|
|
8463
|
+
this.rl = node_readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
8464
|
+
return this.rl;
|
|
8465
|
+
}
|
|
8466
|
+
close() {
|
|
8467
|
+
var _a;
|
|
8468
|
+
(_a = this.rl) === null || _a === void 0 ? void 0 : _a.close();
|
|
8469
|
+
this.rl = undefined;
|
|
8470
|
+
}
|
|
8471
|
+
question(text) {
|
|
8472
|
+
return new Promise(resolve => this.io.question(text, answer => resolve(answer)));
|
|
8473
|
+
}
|
|
8474
|
+
/** Print a line to stdout. Kept on the prompter so command code never touches `process.stdout`. */
|
|
8475
|
+
write(line = "") {
|
|
8476
|
+
process.stdout.write(`${line}\n`);
|
|
8477
|
+
}
|
|
8478
|
+
/** Free-text, with a default shown in the prompt. Blank input takes the default. */
|
|
8479
|
+
async text(label, fallback, validate) {
|
|
8480
|
+
if (!this.interactive)
|
|
8481
|
+
return fallback;
|
|
8482
|
+
for (;;) {
|
|
8483
|
+
const answer = (await this.question(`${cyan("?")} ${bold(label)} ${dim(`(${fallback})`)} `)).trim();
|
|
8484
|
+
const value = answer || fallback;
|
|
8485
|
+
const error = validate === null || validate === void 0 ? void 0 : validate(value);
|
|
8486
|
+
if (!error)
|
|
8487
|
+
return value;
|
|
8488
|
+
this.write(` ${yellow("!")} ${error}`);
|
|
8489
|
+
}
|
|
8490
|
+
}
|
|
8491
|
+
/** A number, validated as finite. */
|
|
8492
|
+
async number(label, fallback, validate) {
|
|
8493
|
+
const answer = await this.text(label, String(fallback), v => {
|
|
8494
|
+
const n = Number(v);
|
|
8495
|
+
if (!Number.isFinite(n))
|
|
8496
|
+
return `"${v}" is not a number.`;
|
|
8497
|
+
return validate === null || validate === void 0 ? void 0 : validate(n);
|
|
8498
|
+
});
|
|
8499
|
+
return Number(answer);
|
|
8500
|
+
}
|
|
8501
|
+
/** Pick one. Answers are 1-based indices; blank takes the default (the first choice unless given). */
|
|
8502
|
+
async select(label, choices, defaultIndex = 0) {
|
|
8503
|
+
var _a, _b;
|
|
8504
|
+
if (!this.interactive || choices.length === 1)
|
|
8505
|
+
return (_b = (_a = choices[defaultIndex]) === null || _a === void 0 ? void 0 : _a.value) !== null && _b !== void 0 ? _b : choices[0].value;
|
|
8506
|
+
this.write(`${cyan("?")} ${bold(label)}`);
|
|
8507
|
+
choices.forEach((c, i) => {
|
|
8508
|
+
const marker = i === defaultIndex ? green("❯") : " ";
|
|
8509
|
+
const hint = c.hint ? ` ${dim(`— ${c.hint}`)}` : "";
|
|
8510
|
+
this.write(` ${marker} ${dim(`${i + 1}.`)} ${i === defaultIndex ? bold(c.label) : c.label}${hint}`);
|
|
8511
|
+
});
|
|
8512
|
+
for (;;) {
|
|
8513
|
+
const answer = (await this.question(` ${dim(`1–${choices.length}`)} `)).trim();
|
|
8514
|
+
if (!answer)
|
|
8515
|
+
return choices[defaultIndex].value;
|
|
8516
|
+
const index = Number(answer) - 1;
|
|
8517
|
+
if (Number.isInteger(index) && index >= 0 && index < choices.length)
|
|
8518
|
+
return choices[index].value;
|
|
8519
|
+
this.write(` ${yellow("!")} Enter a number between 1 and ${choices.length}.`);
|
|
8520
|
+
}
|
|
8521
|
+
}
|
|
8522
|
+
/**
|
|
8523
|
+
* Pick any. Answers are comma-separated indices; blank keeps the pre-selected set, and an explicit
|
|
8524
|
+
* `none` clears it — otherwise there'd be no way to deselect everything with a line-based prompt.
|
|
8525
|
+
*/
|
|
8526
|
+
async multiselect(label, choices, preselected) {
|
|
8527
|
+
if (!this.interactive)
|
|
8528
|
+
return preselected.map(i => choices[i].value);
|
|
8529
|
+
this.write(`${cyan("?")} ${bold(label)} ${dim("(comma-separated, blank = keep, \"none\" = clear)")}`);
|
|
8530
|
+
choices.forEach((c, i) => {
|
|
8531
|
+
const on = preselected.includes(i);
|
|
8532
|
+
const hint = c.hint ? ` ${dim(`— ${c.hint}`)}` : "";
|
|
8533
|
+
this.write(` ${on ? green("◉") : dim("◯")} ${dim(`${i + 1}.`)} ${c.label}${hint}`);
|
|
8534
|
+
});
|
|
8535
|
+
for (;;) {
|
|
8536
|
+
const answer = (await this.question(` ${dim(`1–${choices.length}`)} `)).trim().toLowerCase();
|
|
8537
|
+
if (!answer)
|
|
8538
|
+
return preselected.map(i => choices[i].value);
|
|
8539
|
+
if (answer === "none")
|
|
8540
|
+
return [];
|
|
8541
|
+
const parts = answer.split(",").map(s => Number(s.trim()) - 1);
|
|
8542
|
+
if (parts.every(i => Number.isInteger(i) && i >= 0 && i < choices.length)) {
|
|
8543
|
+
return parts.map(i => choices[i].value);
|
|
8544
|
+
}
|
|
8545
|
+
this.write(` ${yellow("!")} Enter numbers between 1 and ${choices.length}, separated by commas.`);
|
|
8546
|
+
}
|
|
8547
|
+
}
|
|
8548
|
+
}
|
|
8549
|
+
|
|
8550
|
+
/** What each harmony scheme is for, in one line — the names mean nothing to most people. */
|
|
8551
|
+
const SCHEME_HINTS = {
|
|
8552
|
+
complement: "one companion, 180° opposite — maximum separation",
|
|
8553
|
+
analogous: "two neighbours, ±30° — quiet and cohesive",
|
|
8554
|
+
"split-complement": "the complement's neighbours — contrast with less tension",
|
|
8555
|
+
triadic: "even thirds, ±120° — three colours of equal weight",
|
|
8556
|
+
tetradic: "two complementary pairs — the most range",
|
|
8557
|
+
pentadic: "five evenly spaced hues — needs a dominant colour picked by hand",
|
|
8558
|
+
};
|
|
8559
|
+
/**
|
|
8560
|
+
* Ratio choices, tightest to most dramatic, each showing the ladder it produces from 16px. The ratio
|
|
8561
|
+
* is the most consequential typographic choice in the theme and its name carries no information, so
|
|
8562
|
+
* the numbers do the explaining.
|
|
8563
|
+
*/
|
|
8564
|
+
const TYPE_SCALE_CHOICES = [
|
|
8565
|
+
["major-second", "1.125 · 16 · 18 · 20 · 23 · 26 · 29"],
|
|
8566
|
+
["minor-third", "1.2 · 16 · 19 · 23 · 28 · 33 · 40"],
|
|
8567
|
+
["major-third", "1.25 · 16 · 20 · 25 · 31 · 39 · 49"],
|
|
8568
|
+
["perfect-fourth", "1.333 · 16 · 21 · 28 · 38 · 51 · 67"],
|
|
8569
|
+
["perfect-fifth", "1.5 · 16 · 24 · 36 · 54 · 81 · 122"],
|
|
8570
|
+
["golden", "1.618 · 16 · 26 · 42 · 68 · 110 · 178"],
|
|
8571
|
+
];
|
|
8572
|
+
const isColor = (v) => {
|
|
8573
|
+
try {
|
|
8574
|
+
parseColor(v);
|
|
8575
|
+
return undefined;
|
|
8576
|
+
}
|
|
8577
|
+
catch {
|
|
8578
|
+
return `"${v}" isn't a colour refract can parse. Try a hex like #4c6ef5.`;
|
|
8579
|
+
}
|
|
8580
|
+
};
|
|
8581
|
+
/**
|
|
8582
|
+
* Run the interview. Anything present in `given` is taken as answered and never asked.
|
|
8583
|
+
*
|
|
8584
|
+
* The scheme question only appears when the brand count admits more than one rotation set — which is
|
|
8585
|
+
* only at three colours. Two is a complement, four a tetradic, five a pentadic: asking would be a
|
|
8586
|
+
* prompt with a single option.
|
|
8587
|
+
*/
|
|
8588
|
+
async function promptCreateAnswers(p, given = {}) {
|
|
8589
|
+
var _a, _b, _c, _d, _e, _f, _g, _h;
|
|
8590
|
+
const seed = (_a = given.seed) !== null && _a !== void 0 ? _a : (await p.text("Primary colour", "#4c6ef5", isColor));
|
|
8591
|
+
const lightness = Math.round(rgbToOklch(parseColor(seed).rgb).L * 10) / 10;
|
|
8592
|
+
p.write(` ${dim(`parsed · lightness ${lightness}% — lands at ≈${nearestLadderStep(lightness)} on the ladder`)}`);
|
|
8593
|
+
p.write();
|
|
8594
|
+
const manual = Boolean(given.manual);
|
|
8595
|
+
let brandCount = 2;
|
|
8596
|
+
let scheme;
|
|
8597
|
+
let extraColors = [];
|
|
8598
|
+
if (manual) {
|
|
8599
|
+
extraColors = ((_b = given.colors) !== null && _b !== void 0 ? _b : "").split(",").map(s => s.trim()).filter(Boolean);
|
|
8600
|
+
if (!extraColors.length && p.interactive) {
|
|
8601
|
+
const total = await p.number("How many brand colours in total?", 2, n => Number.isInteger(n) && n >= 1 && n <= 5 ? undefined : "Pick a whole number from 1 to 5.");
|
|
8602
|
+
for (let i = 1; i < total; i++) {
|
|
8603
|
+
extraColors.push(await p.text(`Brand colour ${i + 1}`, "#e64980", isColor));
|
|
8604
|
+
}
|
|
8605
|
+
}
|
|
8606
|
+
brandCount = extraColors.length + 1;
|
|
8607
|
+
}
|
|
8608
|
+
else {
|
|
8609
|
+
brandCount = given.colors
|
|
8610
|
+
? Number(given.colors)
|
|
8611
|
+
: await p.number("How many brand colours? (including the primary)", 2, n => Number.isInteger(n) && n >= 1 && n <= 5 ? undefined : "Pick a whole number from 1 to 5.");
|
|
8612
|
+
const options = schemesFor(brandCount);
|
|
8613
|
+
scheme = (_c = given.scheme) !== null && _c !== void 0 ? _c : defaultSchemeFor(brandCount);
|
|
8614
|
+
if (!given.scheme && options.length > 1) {
|
|
8615
|
+
scheme = await p.select("Harmony scheme", options.map(s => ({ value: s, label: s, hint: SCHEME_HINTS[s] })), Math.max(0, options.indexOf(scheme)));
|
|
8616
|
+
}
|
|
8617
|
+
if (scheme && brandCount > 1) {
|
|
8618
|
+
p.write(` ${dim(`→ ${scheme} · each member becomes its own palette with a full ladder`)}`);
|
|
8619
|
+
p.write();
|
|
8620
|
+
}
|
|
8621
|
+
}
|
|
8622
|
+
const anyExtraFlag = given.noSemantics || given.noNeutral || given.noShadows;
|
|
8623
|
+
const extras = anyExtraFlag || !p.interactive
|
|
8624
|
+
? { semantics: !given.noSemantics, neutral: !given.noNeutral, shadows: !given.noShadows }
|
|
8625
|
+
: await (async () => {
|
|
8626
|
+
const picked = await p.multiselect("Also add", [
|
|
8627
|
+
{ value: "semantics", label: "Semantic colours", hint: "success · info · warning · danger" },
|
|
8628
|
+
{ value: "neutral", label: "Neutral ramp", hint: "50 … 900" },
|
|
8629
|
+
{ value: "shadows", label: "Shadow tints", hint: "3 alpha levels + the effects ramp" },
|
|
8630
|
+
], [0, 1, 2]);
|
|
8631
|
+
return {
|
|
8632
|
+
semantics: picked.includes("semantics"),
|
|
8633
|
+
neutral: picked.includes("neutral"),
|
|
8634
|
+
shadows: picked.includes("shadows"),
|
|
8635
|
+
};
|
|
8636
|
+
})();
|
|
8637
|
+
const contrast = (_d = given.contrast) !== null && _d !== void 0 ? _d : await p.select("Contrast target", [
|
|
8638
|
+
{ value: "AA", label: "WCAG AA", hint: "4.5:1 body text" },
|
|
8639
|
+
{ value: "AAA", label: "WCAG AAA", hint: "7:1 — expect heavier nudges" },
|
|
8640
|
+
{ value: "none", label: "Skip", hint: "write the colours exactly as given" },
|
|
8641
|
+
]);
|
|
8642
|
+
const baseFontSize = given.baseSize !== undefined
|
|
8643
|
+
? Number(given.baseSize)
|
|
8644
|
+
: await p.number("Base font size (px)", 16, n => (n > 0 ? undefined : "Must be greater than zero."));
|
|
8645
|
+
const feel = (_e = given.feel) !== null && _e !== void 0 ? _e : await p.select("Overall feel", Object.keys(FEEL_PRESETS).map(k => ({
|
|
8646
|
+
value: k, label: FEEL_PRESETS[k].label, hint: FEEL_PRESETS[k].blurb,
|
|
8647
|
+
})));
|
|
8648
|
+
const ratio = (_f = given.ratio) !== null && _f !== void 0 ? _f : await p.select("Type scale", TYPE_SCALE_CHOICES.map(([value, hint]) => ({ value, label: value, hint })), Math.max(0, TYPE_SCALE_CHOICES.findIndex(([v]) => v === FEEL_PRESETS[feel].ratio)));
|
|
8649
|
+
const reset = (_g = given.reset) !== null && _g !== void 0 ? _g : await p.select("CSS reset", [
|
|
8650
|
+
{ value: "preflight", label: "preflight", hint: "full normalization + an h1–h6 size map" },
|
|
8651
|
+
{ value: "normalize", label: "normalize", hint: "the classic, no heading map" },
|
|
8652
|
+
{ value: "none", label: "none", hint: "you already ship one" },
|
|
8653
|
+
]);
|
|
8654
|
+
const format = (_h = given.format) !== null && _h !== void 0 ? _h : await p.select("Format", [
|
|
8655
|
+
{ value: "ts", label: "theme.raw.ts", hint: "typed, `satisfies RawTheme`" },
|
|
8656
|
+
{ value: "js", label: "theme.raw.js", hint: "plain ESM, no build step" },
|
|
8657
|
+
{ value: "json", label: "theme.raw.json", hint: "portable, no code at all" },
|
|
8658
|
+
]);
|
|
8659
|
+
return {
|
|
8660
|
+
seed, mode: manual ? "manual" : "auto", brandCount, scheme, extraColors,
|
|
8661
|
+
...extras, contrast, baseFontSize, ratio, feel, reset, format,
|
|
8662
|
+
};
|
|
8663
|
+
}
|
|
8664
|
+
/**
|
|
8665
|
+
* The post-generation summary: what the contrast pass did, and what landed. Returned as lines rather
|
|
8666
|
+
* than printed so each host can frame them — the standalone command and the project scaffolder put
|
|
8667
|
+
* different things around the same facts.
|
|
8668
|
+
*/
|
|
8669
|
+
function createReportLines(result, variableCount) {
|
|
8670
|
+
const lines = [];
|
|
8671
|
+
const { contrast } = result.report;
|
|
8672
|
+
if (contrast.length) {
|
|
8673
|
+
lines.push(` ${dim(`Contrast · ${contrast.length} pairings checked`)}`);
|
|
8674
|
+
for (const c of contrast) {
|
|
8675
|
+
const shift = c.nudge > 0
|
|
8676
|
+
? `${yellow(`−${c.nudge}`)} ${green(`${c.ratioAfter} ${c.levelAfter}`)}`
|
|
8677
|
+
: dim("unchanged");
|
|
8678
|
+
const flag = c.unresolved ? ` ${yellow("! still short of the bar")}` : "";
|
|
8679
|
+
lines.push(` ${c.name.padEnd(10)} ${String(c.ratioBefore).padStart(5)} ${shift}${flag}`);
|
|
8680
|
+
}
|
|
8681
|
+
lines.push("");
|
|
8682
|
+
}
|
|
8683
|
+
const nudged = contrast.filter(c => c.nudge > 0).length;
|
|
8684
|
+
lines.push(` ${dim(`${result.sections.length} subsystems · ${variableCount} variables · 0 recipes`)}`);
|
|
8685
|
+
if (nudged)
|
|
8686
|
+
lines.push(` ${dim(`${nudged} colour${nudged === 1 ? "" : "s"} darkened to clear the contrast bar`)}`);
|
|
8687
|
+
return lines;
|
|
8688
|
+
}
|
|
8689
|
+
|
|
7834
8690
|
/**
|
|
7835
8691
|
* `refract` CLI (Node-only, §7 Step 10c) — a thin subcommand dispatcher over the build layer.
|
|
7836
8692
|
*
|
|
@@ -7847,6 +8703,11 @@ async function runDiff(options) {
|
|
|
7847
8703
|
const HELP = `refract — build a refract theme to disk.
|
|
7848
8704
|
|
|
7849
8705
|
Usage:
|
|
8706
|
+
refract create [--seed <color>] [--colors <n|list>] [--scheme <name>] [--manual]
|
|
8707
|
+
[--feel <neutral|compact|editorial|technical>] [--ratio <name>]
|
|
8708
|
+
[--base-size <px>] [--contrast <AA|AAA|none>] [--reset <name>]
|
|
8709
|
+
[--format <ts|js|json>] [--out <file>] [--no-semantics]
|
|
8710
|
+
[--no-neutral] [--no-shadows] [--yes] [--force]
|
|
7850
8711
|
refract init [--js | --mjs] [--force]
|
|
7851
8712
|
refract import <tokens.json> [--out <file>] [--raw-only] [--force]
|
|
7852
8713
|
[--breakpoint-group <name>] [--breakpoints <n:px,…>]
|
|
@@ -7860,6 +8721,7 @@ Usage:
|
|
|
7860
8721
|
refract help
|
|
7861
8722
|
|
|
7862
8723
|
Commands:
|
|
8724
|
+
create Design a theme.raw.(ts|js|json) from one seed colour and a short interview.
|
|
7863
8725
|
init Scaffold a runnable theme.config.(ts|js|mjs) in the current directory.
|
|
7864
8726
|
import Seed a theme.raw.ts (+ theme.config.ts) from a DTCG tokens.json (one-shot).
|
|
7865
8727
|
build Load the config and emit every target's files to its outDir.
|
|
@@ -7868,6 +8730,21 @@ Commands:
|
|
|
7868
8730
|
audit Score colour pairings for WCAG-2 contrast (+ advisory APCA). Reports; --strict fails.
|
|
7869
8731
|
skills Install the bundled AI skills into your agent CLI(s) (claude/codex/…).
|
|
7870
8732
|
`;
|
|
8733
|
+
/**
|
|
8734
|
+
* Uniform error reporter for a command's catch block. Surfaces a `RefractError`'s stable `code`
|
|
8735
|
+
* (`[REFRACT_E_…] message`) so an agent or CI log sees the machine-readable code, and lists each
|
|
8736
|
+
* collect-all `failure` — a plain `Error` just prints its message. Returns the exit code (1).
|
|
8737
|
+
*/
|
|
8738
|
+
function reportError(cmd, err) {
|
|
8739
|
+
var _a;
|
|
8740
|
+
const e = err;
|
|
8741
|
+
const code = typeof e.code === "string" && e.code.startsWith("REFRACT_E_") ? `[${e.code}] ` : "";
|
|
8742
|
+
process.stderr.write(`refract ${cmd}: ${code}${(_a = e.message) !== null && _a !== void 0 ? _a : String(err)}\n`);
|
|
8743
|
+
if (e.failures)
|
|
8744
|
+
for (const f of e.failures)
|
|
8745
|
+
process.stderr.write(` - ${f}\n`);
|
|
8746
|
+
return 1;
|
|
8747
|
+
}
|
|
7871
8748
|
async function cmdInit(argv) {
|
|
7872
8749
|
const { values } = node_util.parseArgs({
|
|
7873
8750
|
args: argv,
|
|
@@ -7885,13 +8762,96 @@ async function cmdInit(argv) {
|
|
|
7885
8762
|
const variant = values.mjs ? "mjs" : values.js ? "js" : "ts";
|
|
7886
8763
|
try {
|
|
7887
8764
|
const result = runInit({ variant, force: Boolean(values.force) });
|
|
8765
|
+
if (result.rawTheme) {
|
|
8766
|
+
process.stdout.write(`Found ${result.rawTheme.filename} — wired it up.\n`);
|
|
8767
|
+
}
|
|
7888
8768
|
process.stdout.write(`Created ${result.path}\n`);
|
|
7889
|
-
process.stdout.write(
|
|
8769
|
+
process.stdout.write(result.rawTheme
|
|
8770
|
+
? `Next: run \`refract build\`.\n`
|
|
8771
|
+
: `Next: edit the starter theme in the config (or run \`refract create\` to design one), then \`refract build\`.\n`);
|
|
7890
8772
|
return 0;
|
|
7891
8773
|
}
|
|
7892
8774
|
catch (err) {
|
|
7893
|
-
|
|
7894
|
-
|
|
8775
|
+
return reportError("init", err);
|
|
8776
|
+
}
|
|
8777
|
+
}
|
|
8778
|
+
/**
|
|
8779
|
+
* `refract create` — the guided raw-theme scaffolder.
|
|
8780
|
+
*
|
|
8781
|
+
* Flags only here: the question flow lives in `createInterview.ts` so `create-refract-theme` runs
|
|
8782
|
+
* the identical script, and the generation lives in `scaffold.ts`. Every prompt has a flag and every
|
|
8783
|
+
* flag has a default, so this command is fully scriptable (`--yes`) and never blocks in CI.
|
|
8784
|
+
*/
|
|
8785
|
+
async function cmdCreate(argv) {
|
|
8786
|
+
var _a;
|
|
8787
|
+
const { values } = node_util.parseArgs({
|
|
8788
|
+
args: argv,
|
|
8789
|
+
options: {
|
|
8790
|
+
seed: { type: "string" },
|
|
8791
|
+
colors: { type: "string" },
|
|
8792
|
+
scheme: { type: "string" },
|
|
8793
|
+
manual: { type: "boolean", default: false },
|
|
8794
|
+
feel: { type: "string" },
|
|
8795
|
+
ratio: { type: "string" },
|
|
8796
|
+
"base-size": { type: "string" },
|
|
8797
|
+
contrast: { type: "string" },
|
|
8798
|
+
reset: { type: "string" },
|
|
8799
|
+
format: { type: "string" },
|
|
8800
|
+
out: { type: "string" },
|
|
8801
|
+
"no-semantics": { type: "boolean", default: false },
|
|
8802
|
+
"no-neutral": { type: "boolean", default: false },
|
|
8803
|
+
"no-shadows": { type: "boolean", default: false },
|
|
8804
|
+
yes: { type: "boolean", default: false },
|
|
8805
|
+
force: { type: "boolean", default: false },
|
|
8806
|
+
},
|
|
8807
|
+
allowPositionals: false,
|
|
8808
|
+
});
|
|
8809
|
+
const p = new Prompter(values.yes ? false : undefined);
|
|
8810
|
+
try {
|
|
8811
|
+
p.write();
|
|
8812
|
+
p.write(` ${bold("refract")} ${dim("· raw-theme scaffold")}`);
|
|
8813
|
+
p.write();
|
|
8814
|
+
const answers = await promptCreateAnswers(p, {
|
|
8815
|
+
seed: values.seed,
|
|
8816
|
+
colors: values.colors,
|
|
8817
|
+
scheme: values.scheme,
|
|
8818
|
+
manual: values.manual,
|
|
8819
|
+
feel: values.feel,
|
|
8820
|
+
ratio: values.ratio,
|
|
8821
|
+
baseSize: values["base-size"],
|
|
8822
|
+
contrast: values.contrast,
|
|
8823
|
+
reset: values.reset,
|
|
8824
|
+
format: values.format,
|
|
8825
|
+
noSemantics: values["no-semantics"],
|
|
8826
|
+
noNeutral: values["no-neutral"],
|
|
8827
|
+
noShadows: values["no-shadows"],
|
|
8828
|
+
});
|
|
8829
|
+
const result = runCreate({ ...answers, out: values.out, force: Boolean(values.force) });
|
|
8830
|
+
p.write();
|
|
8831
|
+
for (const line of createReportLines(result, countVariables(result.raw)))
|
|
8832
|
+
p.write(line);
|
|
8833
|
+
p.write();
|
|
8834
|
+
p.write(` ${bold((_a = result.path.split(/[\\/]/).pop()) !== null && _a !== void 0 ? _a : "")} written`);
|
|
8835
|
+
p.write();
|
|
8836
|
+
p.write(` ${dim("Next — wire up a build:")} ${bold("refract init")}`);
|
|
8837
|
+
p.write();
|
|
8838
|
+
return 0;
|
|
8839
|
+
}
|
|
8840
|
+
catch (err) {
|
|
8841
|
+
return reportError("create", err);
|
|
8842
|
+
}
|
|
8843
|
+
finally {
|
|
8844
|
+
p.close();
|
|
8845
|
+
}
|
|
8846
|
+
}
|
|
8847
|
+
/** Compile the generated theme with the null adapter purely to count what it emits, for the report. */
|
|
8848
|
+
function countVariables(raw) {
|
|
8849
|
+
try {
|
|
8850
|
+
const theme = createTheme(raw, { adapter: createNoopAdapter() });
|
|
8851
|
+
return Object.keys(theme.tokens).length;
|
|
8852
|
+
}
|
|
8853
|
+
catch {
|
|
8854
|
+
return 0;
|
|
7895
8855
|
}
|
|
7896
8856
|
}
|
|
7897
8857
|
async function cmdImport(argv) {
|
|
@@ -7935,8 +8895,7 @@ async function cmdImport(argv) {
|
|
|
7935
8895
|
return 0;
|
|
7936
8896
|
}
|
|
7937
8897
|
catch (err) {
|
|
7938
|
-
|
|
7939
|
-
return 1;
|
|
8898
|
+
return reportError("import", err);
|
|
7940
8899
|
}
|
|
7941
8900
|
}
|
|
7942
8901
|
async function cmdBuild(argv) {
|
|
@@ -7965,8 +8924,7 @@ async function cmdBuild(argv) {
|
|
|
7965
8924
|
return 0;
|
|
7966
8925
|
}
|
|
7967
8926
|
catch (err) {
|
|
7968
|
-
|
|
7969
|
-
return 1;
|
|
8927
|
+
return reportError("build", err);
|
|
7970
8928
|
}
|
|
7971
8929
|
}
|
|
7972
8930
|
async function cmdTokens(argv) {
|
|
@@ -7988,8 +8946,7 @@ async function cmdTokens(argv) {
|
|
|
7988
8946
|
return 0;
|
|
7989
8947
|
}
|
|
7990
8948
|
catch (err) {
|
|
7991
|
-
|
|
7992
|
-
return 1;
|
|
8949
|
+
return reportError("tokens", err);
|
|
7993
8950
|
}
|
|
7994
8951
|
}
|
|
7995
8952
|
const WCAG_LEVELS = new Set(["AAA", "AA", "AA-large"]);
|
|
@@ -8046,8 +9003,7 @@ async function cmdDiff(argv) {
|
|
|
8046
9003
|
return 0;
|
|
8047
9004
|
}
|
|
8048
9005
|
catch (err) {
|
|
8049
|
-
|
|
8050
|
-
return 1;
|
|
9006
|
+
return reportError("diff", err);
|
|
8051
9007
|
}
|
|
8052
9008
|
}
|
|
8053
9009
|
async function cmdAudit(argv) {
|
|
@@ -8089,8 +9045,7 @@ async function cmdAudit(argv) {
|
|
|
8089
9045
|
return 0;
|
|
8090
9046
|
}
|
|
8091
9047
|
catch (err) {
|
|
8092
|
-
|
|
8093
|
-
return 1;
|
|
9048
|
+
return reportError("audit", err);
|
|
8094
9049
|
}
|
|
8095
9050
|
}
|
|
8096
9051
|
/** Parse an `--agent` value (`"all"` or a comma list) into validated targets. */
|
|
@@ -8142,8 +9097,7 @@ async function cmdSkills(argv) {
|
|
|
8142
9097
|
return 0;
|
|
8143
9098
|
}
|
|
8144
9099
|
catch (err) {
|
|
8145
|
-
|
|
8146
|
-
return 1;
|
|
9100
|
+
return reportError("skills list", err);
|
|
8147
9101
|
}
|
|
8148
9102
|
}
|
|
8149
9103
|
const { values } = node_util.parseArgs({
|
|
@@ -8202,14 +9156,15 @@ async function cmdSkills(argv) {
|
|
|
8202
9156
|
return 1;
|
|
8203
9157
|
}
|
|
8204
9158
|
catch (err) {
|
|
8205
|
-
|
|
8206
|
-
return 1;
|
|
9159
|
+
return reportError("skills", err);
|
|
8207
9160
|
}
|
|
8208
9161
|
}
|
|
8209
9162
|
/** Dispatch a parsed argv (without the node/script prefix). Returns a process exit code. */
|
|
8210
9163
|
async function main(argv) {
|
|
8211
9164
|
const [command, ...rest] = argv;
|
|
8212
9165
|
switch (command) {
|
|
9166
|
+
case "create":
|
|
9167
|
+
return cmdCreate(rest);
|
|
8213
9168
|
case "init":
|
|
8214
9169
|
return cmdInit(rest);
|
|
8215
9170
|
case "import":
|