@theme-registry/refract 0.1.2 → 0.1.4
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 +17 -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 +76 -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 +1139 -14
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
- package/skills/theme-authoring/SKILL.md +3 -0
- package/skills/theme-scaffold/SKILL.md +130 -0
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 = {
|
|
@@ -7185,6 +7186,78 @@ function readOwnPackageName() {
|
|
|
7185
7186
|
const pkg = JSON.parse(node_fs.readFileSync(pkgPath, "utf8"));
|
|
7186
7187
|
return (_a = pkg.name) !== null && _a !== void 0 ? _a : "@theme-registry/refract";
|
|
7187
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
|
+
}
|
|
7188
7261
|
/** The scaffolded config body — one shared ESM template across ts/js/mjs. */
|
|
7189
7262
|
function scaffoldConfig(packageName) {
|
|
7190
7263
|
return `import { defineConfig } from "${packageName}/build";
|
|
@@ -7226,7 +7299,7 @@ export default defineConfig({
|
|
|
7226
7299
|
* (never silently clobbers a user's config).
|
|
7227
7300
|
*/
|
|
7228
7301
|
function runInit(options = {}) {
|
|
7229
|
-
var _a, _b, _c;
|
|
7302
|
+
var _a, _b, _c, _d;
|
|
7230
7303
|
const cwd = (_a = options.cwd) !== null && _a !== void 0 ? _a : process.cwd();
|
|
7231
7304
|
const variant = (_b = options.variant) !== null && _b !== void 0 ? _b : "ts";
|
|
7232
7305
|
const packageName = (_c = options.packageName) !== null && _c !== void 0 ? _c : readOwnPackageName();
|
|
@@ -7234,8 +7307,13 @@ function runInit(options = {}) {
|
|
|
7234
7307
|
if (node_fs.existsSync(path) && !options.force) {
|
|
7235
7308
|
throw new Error(`theme.config.${variant} already exists at "${path}". Pass --force to overwrite it.`);
|
|
7236
7309
|
}
|
|
7237
|
-
|
|
7238
|
-
|
|
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 };
|
|
7239
7317
|
}
|
|
7240
7318
|
|
|
7241
7319
|
/**
|
|
@@ -7869,6 +7947,961 @@ async function runDiff(options) {
|
|
|
7869
7947
|
return { configPath, candidatePath, diff, targets, ok: violations.length === 0, violations };
|
|
7870
7948
|
}
|
|
7871
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
|
+
* Interactive prompts over `node:readline` and raw stdin (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 these are the shapes the CLIs actually need, implemented directly.
|
|
8437
|
+
*
|
|
8438
|
+
* Three tiers, chosen per call:
|
|
8439
|
+
*
|
|
8440
|
+
* 1. **Raw mode** (a TTY that grants `setRawMode`) — arrow keys move, space toggles, Enter confirms.
|
|
8441
|
+
* What people expect from `npm create`.
|
|
8442
|
+
* 2. **Line mode** (a TTY that refuses raw mode — some CI shells, some remote terminals) — the same
|
|
8443
|
+
* questions answered by typing a number. Plainer, but never wedged.
|
|
8444
|
+
* 3. **Non-interactive** (no TTY at all: a pipe, CI, `--yes`) — every prompt takes its default
|
|
8445
|
+
* without blocking, so a scripted run can't deadlock on stdin that will never arrive.
|
|
8446
|
+
*
|
|
8447
|
+
* Key handling is a **pure reducer** (`applyKey`), so navigation is unit-testable without a terminal;
|
|
8448
|
+
* the raw loop is only plumbing around it.
|
|
8449
|
+
*/
|
|
8450
|
+
/** ANSI helpers — no-ops when the stream isn't a TTY, so piped output stays clean. */
|
|
8451
|
+
const useColor = () => Boolean(process.stdout.isTTY) && !process.env.NO_COLOR;
|
|
8452
|
+
const paint = (code, s) => (useColor() ? `\u001b[${code}m${s}\u001b[0m` : s);
|
|
8453
|
+
const bold = (s) => paint("1", s);
|
|
8454
|
+
const dim = (s) => paint("2", s);
|
|
8455
|
+
const cyan = (s) => paint("36", s);
|
|
8456
|
+
const green = (s) => paint("32", s);
|
|
8457
|
+
const yellow = (s) => paint("33", s);
|
|
8458
|
+
/** Terminal control, named so the rendering below reads as intent rather than escape soup. */
|
|
8459
|
+
const cursorUp = (n) => (n > 0 ? `\u001b[${n}A` : "");
|
|
8460
|
+
const CLEAR_DOWN = "\u001b[0J";
|
|
8461
|
+
const HIDE_CURSOR = "\u001b[?25l";
|
|
8462
|
+
const SHOW_CURSOR = "\u001b[?25h";
|
|
8463
|
+
/** Decode a raw stdin chunk into a {@link ListKey}. Arrows arrive as escape sequences; j/k mirror vim. */
|
|
8464
|
+
function decodeKey(chunk) {
|
|
8465
|
+
switch (chunk) {
|
|
8466
|
+
case "\u001b[A":
|
|
8467
|
+
case "k":
|
|
8468
|
+
return "up";
|
|
8469
|
+
case "\u001b[B":
|
|
8470
|
+
case "j":
|
|
8471
|
+
return "down";
|
|
8472
|
+
case " ":
|
|
8473
|
+
return "space";
|
|
8474
|
+
case "\r":
|
|
8475
|
+
case "\n":
|
|
8476
|
+
return "submit";
|
|
8477
|
+
case "a":
|
|
8478
|
+
return "all";
|
|
8479
|
+
case "\u0003": // Ctrl-C
|
|
8480
|
+
case "\u001b": // bare Escape
|
|
8481
|
+
return "cancel";
|
|
8482
|
+
default:
|
|
8483
|
+
return "none";
|
|
8484
|
+
}
|
|
8485
|
+
}
|
|
8486
|
+
/**
|
|
8487
|
+
* Split one stdin chunk into the keys it contains.
|
|
8488
|
+
*
|
|
8489
|
+
* A held-down arrow key, or fast typing, delivers several sequences in a single `data` event — so
|
|
8490
|
+
* decoding the chunk as one key would swallow all but the first and make the list feel like it drops
|
|
8491
|
+
* input. Escape sequences (`ESC [ <letter>`) are taken as a unit; everything else is one key each.
|
|
8492
|
+
*/
|
|
8493
|
+
function decodeKeys(chunk) {
|
|
8494
|
+
const keys = [];
|
|
8495
|
+
for (let i = 0; i < chunk.length;) {
|
|
8496
|
+
if (chunk[i] === "\u001b" && chunk[i + 1] === "[" && i + 2 < chunk.length) {
|
|
8497
|
+
keys.push(decodeKey(chunk.slice(i, i + 3)));
|
|
8498
|
+
i += 3;
|
|
8499
|
+
}
|
|
8500
|
+
else {
|
|
8501
|
+
keys.push(decodeKey(chunk[i]));
|
|
8502
|
+
i += 1;
|
|
8503
|
+
}
|
|
8504
|
+
}
|
|
8505
|
+
return keys;
|
|
8506
|
+
}
|
|
8507
|
+
/**
|
|
8508
|
+
* Advance a list prompt by one key. Pure — no I/O — so navigation is testable without a terminal.
|
|
8509
|
+
*
|
|
8510
|
+
* The cursor **wraps** at both ends: with six options, up from the first should land on the last
|
|
8511
|
+
* rather than stick. `space` and `a` toggle only in multi mode; in single mode Enter is the commit,
|
|
8512
|
+
* so space would be ambiguous.
|
|
8513
|
+
*/
|
|
8514
|
+
function applyKey(state, key, count, multi) {
|
|
8515
|
+
if (state.done || state.cancelled || count === 0)
|
|
8516
|
+
return state;
|
|
8517
|
+
switch (key) {
|
|
8518
|
+
case "up":
|
|
8519
|
+
return { ...state, cursor: (state.cursor - 1 + count) % count };
|
|
8520
|
+
case "down":
|
|
8521
|
+
return { ...state, cursor: (state.cursor + 1) % count };
|
|
8522
|
+
case "space": {
|
|
8523
|
+
if (!multi)
|
|
8524
|
+
return state;
|
|
8525
|
+
const next = new Set(state.selected);
|
|
8526
|
+
if (next.has(state.cursor))
|
|
8527
|
+
next.delete(state.cursor);
|
|
8528
|
+
else
|
|
8529
|
+
next.add(state.cursor);
|
|
8530
|
+
return { ...state, selected: next };
|
|
8531
|
+
}
|
|
8532
|
+
case "all": {
|
|
8533
|
+
if (!multi)
|
|
8534
|
+
return state;
|
|
8535
|
+
const everything = state.selected.size === count;
|
|
8536
|
+
return {
|
|
8537
|
+
...state,
|
|
8538
|
+
selected: everything ? new Set() : new Set(Array.from({ length: count }, (_, i) => i)),
|
|
8539
|
+
};
|
|
8540
|
+
}
|
|
8541
|
+
case "submit":
|
|
8542
|
+
return { ...state, done: true };
|
|
8543
|
+
case "cancel":
|
|
8544
|
+
return { ...state, cancelled: true };
|
|
8545
|
+
default:
|
|
8546
|
+
return state;
|
|
8547
|
+
}
|
|
8548
|
+
}
|
|
8549
|
+
/** Can we drive the terminal directly? Some TTYs (and most CI) don't grant raw mode. */
|
|
8550
|
+
const rawCapable = () => Boolean(process.stdin.isTTY) && typeof process.stdin.setRawMode === "function";
|
|
8551
|
+
/**
|
|
8552
|
+
* A prompt session. Holds at most one readline interface, so stdin is opened and closed once;
|
|
8553
|
+
* `interactive` is false when there's no TTY, and every ask short-circuits to its default.
|
|
8554
|
+
*/
|
|
8555
|
+
class Prompter {
|
|
8556
|
+
constructor(interactive = Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY)) {
|
|
8557
|
+
this.interactive = interactive;
|
|
8558
|
+
}
|
|
8559
|
+
get io() {
|
|
8560
|
+
if (!this.rl)
|
|
8561
|
+
this.rl = node_readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
8562
|
+
return this.rl;
|
|
8563
|
+
}
|
|
8564
|
+
close() {
|
|
8565
|
+
var _a;
|
|
8566
|
+
(_a = this.rl) === null || _a === void 0 ? void 0 : _a.close();
|
|
8567
|
+
this.rl = undefined;
|
|
8568
|
+
}
|
|
8569
|
+
question(text) {
|
|
8570
|
+
return new Promise(resolve => this.io.question(text, answer => resolve(answer)));
|
|
8571
|
+
}
|
|
8572
|
+
/** Raw write, no newline — for cursor control during a live render. */
|
|
8573
|
+
out(s) {
|
|
8574
|
+
process.stdout.write(s);
|
|
8575
|
+
}
|
|
8576
|
+
/** Print a line. Kept on the prompter so command code never touches `process.stdout`. */
|
|
8577
|
+
write(line = "") {
|
|
8578
|
+
process.stdout.write(`${line}\n`);
|
|
8579
|
+
}
|
|
8580
|
+
/** Free-text, with a default shown in the prompt. Blank input takes the default. */
|
|
8581
|
+
async text(label, fallback, validate) {
|
|
8582
|
+
if (!this.interactive)
|
|
8583
|
+
return fallback;
|
|
8584
|
+
for (;;) {
|
|
8585
|
+
const answer = (await this.question(`${cyan("?")} ${bold(label)} ${dim(`(${fallback})`)} `)).trim();
|
|
8586
|
+
const value = answer || fallback;
|
|
8587
|
+
const error = validate === null || validate === void 0 ? void 0 : validate(value);
|
|
8588
|
+
if (!error)
|
|
8589
|
+
return value;
|
|
8590
|
+
this.write(` ${yellow("!")} ${error}`);
|
|
8591
|
+
}
|
|
8592
|
+
}
|
|
8593
|
+
/** A number, validated as finite. */
|
|
8594
|
+
async number(label, fallback, validate) {
|
|
8595
|
+
const answer = await this.text(label, String(fallback), v => {
|
|
8596
|
+
const n = Number(v);
|
|
8597
|
+
if (!Number.isFinite(n))
|
|
8598
|
+
return `"${v}" is not a number.`;
|
|
8599
|
+
return validate === null || validate === void 0 ? void 0 : validate(n);
|
|
8600
|
+
});
|
|
8601
|
+
return Number(answer);
|
|
8602
|
+
}
|
|
8603
|
+
/** Pick one — arrows to move, Enter to choose. */
|
|
8604
|
+
async select(label, choices, defaultIndex = 0) {
|
|
8605
|
+
var _a, _b, _c;
|
|
8606
|
+
if (!this.interactive || choices.length === 1)
|
|
8607
|
+
return (_b = (_a = choices[defaultIndex]) === null || _a === void 0 ? void 0 : _a.value) !== null && _b !== void 0 ? _b : choices[0].value;
|
|
8608
|
+
if (rawCapable()) {
|
|
8609
|
+
const picked = await this.runList(label, choices, {
|
|
8610
|
+
multi: false,
|
|
8611
|
+
cursor: defaultIndex,
|
|
8612
|
+
selected: new Set(),
|
|
8613
|
+
});
|
|
8614
|
+
return choices[(_c = picked[0]) !== null && _c !== void 0 ? _c : defaultIndex].value;
|
|
8615
|
+
}
|
|
8616
|
+
return this.selectByNumber(label, choices, defaultIndex);
|
|
8617
|
+
}
|
|
8618
|
+
/** Pick any — arrows to move, space to toggle, `a` for all/none, Enter to confirm. */
|
|
8619
|
+
async multiselect(label, choices, preselected) {
|
|
8620
|
+
var _a;
|
|
8621
|
+
if (!this.interactive)
|
|
8622
|
+
return preselected.map(i => choices[i].value);
|
|
8623
|
+
if (rawCapable()) {
|
|
8624
|
+
const picked = await this.runList(label, choices, {
|
|
8625
|
+
multi: true,
|
|
8626
|
+
cursor: (_a = preselected[0]) !== null && _a !== void 0 ? _a : 0,
|
|
8627
|
+
selected: new Set(preselected),
|
|
8628
|
+
});
|
|
8629
|
+
return picked.map(i => choices[i].value);
|
|
8630
|
+
}
|
|
8631
|
+
return this.multiselectByNumber(label, choices, preselected);
|
|
8632
|
+
}
|
|
8633
|
+
/**
|
|
8634
|
+
* The raw-mode list loop. Renders in place: each keypress rewinds over the block just drawn and
|
|
8635
|
+
* repaints it, so the list stays put instead of scrolling the terminal away.
|
|
8636
|
+
*
|
|
8637
|
+
* Any readline interface is closed first — it would otherwise swallow the keystrokes we need.
|
|
8638
|
+
* `cleanup` always restores the terminal (raw mode off, cursor shown), including on cancel, so a
|
|
8639
|
+
* Ctrl-C can't leave the shell in a state where typing is invisible.
|
|
8640
|
+
*/
|
|
8641
|
+
runList(label, choices, init) {
|
|
8642
|
+
this.close(); // readline and raw mode can't both own stdin
|
|
8643
|
+
const stdin = process.stdin;
|
|
8644
|
+
const { multi } = init;
|
|
8645
|
+
let state = {
|
|
8646
|
+
cursor: Math.max(0, Math.min(init.cursor, choices.length - 1)),
|
|
8647
|
+
selected: init.selected,
|
|
8648
|
+
done: false,
|
|
8649
|
+
cancelled: false,
|
|
8650
|
+
};
|
|
8651
|
+
let painted = 0;
|
|
8652
|
+
const help = multi ? "↑↓ move · space toggle · a all · enter confirm" : "↑↓ move · enter select";
|
|
8653
|
+
const frame = () => {
|
|
8654
|
+
const rows = choices.map((c, i) => {
|
|
8655
|
+
const here = i === state.cursor;
|
|
8656
|
+
const mark = multi ? (state.selected.has(i) ? green("◉") : dim("◯")) : here ? green("❯") : " ";
|
|
8657
|
+
const name = here ? bold(c.label) : c.label;
|
|
8658
|
+
const hint = c.hint ? ` ${dim(`— ${c.hint}`)}` : "";
|
|
8659
|
+
return ` ${mark} ${name}${hint}`;
|
|
8660
|
+
});
|
|
8661
|
+
return [`${cyan("?")} ${bold(label)} ${dim(help)}`, ...rows].join("\n");
|
|
8662
|
+
};
|
|
8663
|
+
const paintFrame = () => {
|
|
8664
|
+
if (painted)
|
|
8665
|
+
this.out(cursorUp(painted) + CLEAR_DOWN);
|
|
8666
|
+
const text = frame();
|
|
8667
|
+
this.out(`${text}\n`);
|
|
8668
|
+
painted = text.split("\n").length;
|
|
8669
|
+
};
|
|
8670
|
+
return new Promise((resolve, reject) => {
|
|
8671
|
+
const cleanup = () => {
|
|
8672
|
+
stdin.removeListener("data", onData);
|
|
8673
|
+
stdin.removeListener("end", onEnd);
|
|
8674
|
+
if (stdin.isTTY)
|
|
8675
|
+
stdin.setRawMode(false);
|
|
8676
|
+
stdin.pause();
|
|
8677
|
+
this.out(SHOW_CURSOR);
|
|
8678
|
+
};
|
|
8679
|
+
/**
|
|
8680
|
+
* stdin closed while we were waiting — a piped run, a closed terminal, a killed parent. There
|
|
8681
|
+
* is no further input coming, so commit what's on screen instead of blocking forever.
|
|
8682
|
+
*/
|
|
8683
|
+
const onEnd = () => {
|
|
8684
|
+
const chosen = multi ? [...state.selected].sort((a, b) => a - b) : [state.cursor];
|
|
8685
|
+
this.out(cursorUp(painted) + CLEAR_DOWN);
|
|
8686
|
+
cleanup();
|
|
8687
|
+
resolve(chosen);
|
|
8688
|
+
};
|
|
8689
|
+
const onData = (chunk) => {
|
|
8690
|
+
// One event can carry several keys (a held-down arrow); apply them all, then paint once.
|
|
8691
|
+
for (const key of decodeKeys(chunk)) {
|
|
8692
|
+
state = applyKey(state, key, choices.length, multi);
|
|
8693
|
+
if (state.done || state.cancelled)
|
|
8694
|
+
break;
|
|
8695
|
+
}
|
|
8696
|
+
if (state.cancelled) {
|
|
8697
|
+
this.out(cursorUp(painted) + CLEAR_DOWN);
|
|
8698
|
+
cleanup();
|
|
8699
|
+
reject(new Error("Cancelled."));
|
|
8700
|
+
return;
|
|
8701
|
+
}
|
|
8702
|
+
if (state.done) {
|
|
8703
|
+
const chosen = multi ? [...state.selected].sort((a, b) => a - b) : [state.cursor];
|
|
8704
|
+
// Replace the live block with a one-line record of the answer, so a finished interview
|
|
8705
|
+
// reads back as a transcript rather than a wall of spent menus.
|
|
8706
|
+
this.out(cursorUp(painted) + CLEAR_DOWN);
|
|
8707
|
+
const summary = chosen.length ? chosen.map(i => choices[i].label).join(", ") : "none";
|
|
8708
|
+
this.write(`${cyan("?")} ${bold(label)} ${dim("›")} ${green(summary)}`);
|
|
8709
|
+
cleanup();
|
|
8710
|
+
resolve(chosen);
|
|
8711
|
+
return;
|
|
8712
|
+
}
|
|
8713
|
+
paintFrame();
|
|
8714
|
+
};
|
|
8715
|
+
stdin.setRawMode(true);
|
|
8716
|
+
stdin.resume();
|
|
8717
|
+
stdin.setEncoding("utf8");
|
|
8718
|
+
this.out(HIDE_CURSOR);
|
|
8719
|
+
stdin.on("data", onData);
|
|
8720
|
+
stdin.on("end", onEnd);
|
|
8721
|
+
paintFrame();
|
|
8722
|
+
});
|
|
8723
|
+
}
|
|
8724
|
+
/** Line-mode fallback: pick one by number. */
|
|
8725
|
+
async selectByNumber(label, choices, defaultIndex) {
|
|
8726
|
+
this.write(`${cyan("?")} ${bold(label)}`);
|
|
8727
|
+
choices.forEach((c, i) => {
|
|
8728
|
+
const marker = i === defaultIndex ? green("❯") : " ";
|
|
8729
|
+
const hint = c.hint ? ` ${dim(`— ${c.hint}`)}` : "";
|
|
8730
|
+
this.write(` ${marker} ${dim(`${i + 1}.`)} ${i === defaultIndex ? bold(c.label) : c.label}${hint}`);
|
|
8731
|
+
});
|
|
8732
|
+
for (;;) {
|
|
8733
|
+
const answer = (await this.question(` ${dim(`1–${choices.length}`)} `)).trim();
|
|
8734
|
+
if (!answer)
|
|
8735
|
+
return choices[defaultIndex].value;
|
|
8736
|
+
const index = Number(answer) - 1;
|
|
8737
|
+
if (Number.isInteger(index) && index >= 0 && index < choices.length)
|
|
8738
|
+
return choices[index].value;
|
|
8739
|
+
this.write(` ${yellow("!")} Enter a number between 1 and ${choices.length}.`);
|
|
8740
|
+
}
|
|
8741
|
+
}
|
|
8742
|
+
/** Line-mode fallback: pick any by comma-separated numbers. */
|
|
8743
|
+
async multiselectByNumber(label, choices, preselected) {
|
|
8744
|
+
this.write(`${cyan("?")} ${bold(label)} ${dim('(comma-separated, blank = keep, "none" = clear)')}`);
|
|
8745
|
+
choices.forEach((c, i) => {
|
|
8746
|
+
const on = preselected.includes(i);
|
|
8747
|
+
const hint = c.hint ? ` ${dim(`— ${c.hint}`)}` : "";
|
|
8748
|
+
this.write(` ${on ? green("◉") : dim("◯")} ${dim(`${i + 1}.`)} ${c.label}${hint}`);
|
|
8749
|
+
});
|
|
8750
|
+
for (;;) {
|
|
8751
|
+
const answer = (await this.question(` ${dim(`1–${choices.length}`)} `)).trim().toLowerCase();
|
|
8752
|
+
if (!answer)
|
|
8753
|
+
return preselected.map(i => choices[i].value);
|
|
8754
|
+
if (answer === "none")
|
|
8755
|
+
return [];
|
|
8756
|
+
const parts = answer.split(",").map(s => Number(s.trim()) - 1);
|
|
8757
|
+
if (parts.every(i => Number.isInteger(i) && i >= 0 && i < choices.length)) {
|
|
8758
|
+
return parts.map(i => choices[i].value);
|
|
8759
|
+
}
|
|
8760
|
+
this.write(` ${yellow("!")} Enter numbers between 1 and ${choices.length}, separated by commas.`);
|
|
8761
|
+
}
|
|
8762
|
+
}
|
|
8763
|
+
}
|
|
8764
|
+
|
|
8765
|
+
/** What each harmony scheme is for, in one line — the names mean nothing to most people. */
|
|
8766
|
+
const SCHEME_HINTS = {
|
|
8767
|
+
complement: "one companion, 180° opposite — maximum separation",
|
|
8768
|
+
analogous: "two neighbours, ±30° — quiet and cohesive",
|
|
8769
|
+
"split-complement": "the complement's neighbours — contrast with less tension",
|
|
8770
|
+
triadic: "even thirds, ±120° — three colours of equal weight",
|
|
8771
|
+
tetradic: "two complementary pairs — the most range",
|
|
8772
|
+
pentadic: "five evenly spaced hues — needs a dominant colour picked by hand",
|
|
8773
|
+
};
|
|
8774
|
+
/**
|
|
8775
|
+
* Ratio choices, tightest to most dramatic, each showing the ladder it produces from 16px. The ratio
|
|
8776
|
+
* is the most consequential typographic choice in the theme and its name carries no information, so
|
|
8777
|
+
* the numbers do the explaining.
|
|
8778
|
+
*/
|
|
8779
|
+
const TYPE_SCALE_CHOICES = [
|
|
8780
|
+
["major-second", "1.125 · 16 · 18 · 20 · 23 · 26 · 29"],
|
|
8781
|
+
["minor-third", "1.2 · 16 · 19 · 23 · 28 · 33 · 40"],
|
|
8782
|
+
["major-third", "1.25 · 16 · 20 · 25 · 31 · 39 · 49"],
|
|
8783
|
+
["perfect-fourth", "1.333 · 16 · 21 · 28 · 38 · 51 · 67"],
|
|
8784
|
+
["perfect-fifth", "1.5 · 16 · 24 · 36 · 54 · 81 · 122"],
|
|
8785
|
+
["golden", "1.618 · 16 · 26 · 42 · 68 · 110 · 178"],
|
|
8786
|
+
];
|
|
8787
|
+
const isColor = (v) => {
|
|
8788
|
+
try {
|
|
8789
|
+
parseColor(v);
|
|
8790
|
+
return undefined;
|
|
8791
|
+
}
|
|
8792
|
+
catch {
|
|
8793
|
+
return `"${v}" isn't a colour refract can parse. Try a hex like #4c6ef5.`;
|
|
8794
|
+
}
|
|
8795
|
+
};
|
|
8796
|
+
/**
|
|
8797
|
+
* Run the interview. Anything present in `given` is taken as answered and never asked.
|
|
8798
|
+
*
|
|
8799
|
+
* The scheme question only appears when the brand count admits more than one rotation set — which is
|
|
8800
|
+
* only at three colours. Two is a complement, four a tetradic, five a pentadic: asking would be a
|
|
8801
|
+
* prompt with a single option.
|
|
8802
|
+
*/
|
|
8803
|
+
async function promptCreateAnswers(p, given = {}) {
|
|
8804
|
+
var _a, _b, _c, _d, _e, _f, _g, _h;
|
|
8805
|
+
const seed = (_a = given.seed) !== null && _a !== void 0 ? _a : (await p.text("Primary colour", "#4c6ef5", isColor));
|
|
8806
|
+
const lightness = Math.round(rgbToOklch(parseColor(seed).rgb).L * 10) / 10;
|
|
8807
|
+
p.write(` ${dim(`parsed · lightness ${lightness}% — lands at ≈${nearestLadderStep(lightness)} on the ladder`)}`);
|
|
8808
|
+
p.write();
|
|
8809
|
+
const manual = Boolean(given.manual);
|
|
8810
|
+
let brandCount = 2;
|
|
8811
|
+
let scheme;
|
|
8812
|
+
let extraColors = [];
|
|
8813
|
+
if (manual) {
|
|
8814
|
+
extraColors = ((_b = given.colors) !== null && _b !== void 0 ? _b : "").split(",").map(s => s.trim()).filter(Boolean);
|
|
8815
|
+
if (!extraColors.length && p.interactive) {
|
|
8816
|
+
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.");
|
|
8817
|
+
for (let i = 1; i < total; i++) {
|
|
8818
|
+
extraColors.push(await p.text(`Brand colour ${i + 1}`, "#e64980", isColor));
|
|
8819
|
+
}
|
|
8820
|
+
}
|
|
8821
|
+
brandCount = extraColors.length + 1;
|
|
8822
|
+
}
|
|
8823
|
+
else {
|
|
8824
|
+
brandCount = given.colors
|
|
8825
|
+
? Number(given.colors)
|
|
8826
|
+
: 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.");
|
|
8827
|
+
const options = schemesFor(brandCount);
|
|
8828
|
+
scheme = (_c = given.scheme) !== null && _c !== void 0 ? _c : defaultSchemeFor(brandCount);
|
|
8829
|
+
if (!given.scheme && options.length > 1) {
|
|
8830
|
+
scheme = await p.select("Harmony scheme", options.map(s => ({ value: s, label: s, hint: SCHEME_HINTS[s] })), Math.max(0, options.indexOf(scheme)));
|
|
8831
|
+
}
|
|
8832
|
+
if (scheme && brandCount > 1) {
|
|
8833
|
+
p.write(` ${dim(`→ ${scheme} · each member becomes its own palette with a full ladder`)}`);
|
|
8834
|
+
p.write();
|
|
8835
|
+
}
|
|
8836
|
+
}
|
|
8837
|
+
const anyExtraFlag = given.noSemantics || given.noNeutral || given.noShadows;
|
|
8838
|
+
const extras = anyExtraFlag || !p.interactive
|
|
8839
|
+
? { semantics: !given.noSemantics, neutral: !given.noNeutral, shadows: !given.noShadows }
|
|
8840
|
+
: await (async () => {
|
|
8841
|
+
const picked = await p.multiselect("Also add", [
|
|
8842
|
+
{ value: "semantics", label: "Semantic colours", hint: "success · info · warning · danger" },
|
|
8843
|
+
{ value: "neutral", label: "Neutral ramp", hint: "50 … 900" },
|
|
8844
|
+
{ value: "shadows", label: "Shadow tints", hint: "3 alpha levels + the effects ramp" },
|
|
8845
|
+
], [0, 1, 2]);
|
|
8846
|
+
return {
|
|
8847
|
+
semantics: picked.includes("semantics"),
|
|
8848
|
+
neutral: picked.includes("neutral"),
|
|
8849
|
+
shadows: picked.includes("shadows"),
|
|
8850
|
+
};
|
|
8851
|
+
})();
|
|
8852
|
+
const contrast = (_d = given.contrast) !== null && _d !== void 0 ? _d : await p.select("Contrast target", [
|
|
8853
|
+
{ value: "AA", label: "WCAG AA", hint: "4.5:1 body text" },
|
|
8854
|
+
{ value: "AAA", label: "WCAG AAA", hint: "7:1 — expect heavier nudges" },
|
|
8855
|
+
{ value: "none", label: "Skip", hint: "write the colours exactly as given" },
|
|
8856
|
+
]);
|
|
8857
|
+
const baseFontSize = given.baseSize !== undefined
|
|
8858
|
+
? Number(given.baseSize)
|
|
8859
|
+
: await p.number("Base font size (px)", 16, n => (n > 0 ? undefined : "Must be greater than zero."));
|
|
8860
|
+
const feel = (_e = given.feel) !== null && _e !== void 0 ? _e : await p.select("Overall feel", Object.keys(FEEL_PRESETS).map(k => ({
|
|
8861
|
+
value: k, label: FEEL_PRESETS[k].label, hint: FEEL_PRESETS[k].blurb,
|
|
8862
|
+
})));
|
|
8863
|
+
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)));
|
|
8864
|
+
const reset = (_g = given.reset) !== null && _g !== void 0 ? _g : await p.select("CSS reset", [
|
|
8865
|
+
{ value: "preflight", label: "preflight", hint: "full normalization + an h1–h6 size map" },
|
|
8866
|
+
{ value: "normalize", label: "normalize", hint: "the classic, no heading map" },
|
|
8867
|
+
{ value: "none", label: "none", hint: "you already ship one" },
|
|
8868
|
+
]);
|
|
8869
|
+
const format = (_h = given.format) !== null && _h !== void 0 ? _h : await p.select("Format", [
|
|
8870
|
+
{ value: "ts", label: "theme.raw.ts", hint: "typed, `satisfies RawTheme`" },
|
|
8871
|
+
{ value: "js", label: "theme.raw.js", hint: "plain ESM, no build step" },
|
|
8872
|
+
{ value: "json", label: "theme.raw.json", hint: "portable, no code at all" },
|
|
8873
|
+
]);
|
|
8874
|
+
return {
|
|
8875
|
+
seed, mode: manual ? "manual" : "auto", brandCount, scheme, extraColors,
|
|
8876
|
+
...extras, contrast, baseFontSize, ratio, feel, reset, format,
|
|
8877
|
+
};
|
|
8878
|
+
}
|
|
8879
|
+
/**
|
|
8880
|
+
* The post-generation summary: what the contrast pass did, and what landed. Returned as lines rather
|
|
8881
|
+
* than printed so each host can frame them — the standalone command and the project scaffolder put
|
|
8882
|
+
* different things around the same facts.
|
|
8883
|
+
*/
|
|
8884
|
+
function createReportLines(result, variableCount) {
|
|
8885
|
+
const lines = [];
|
|
8886
|
+
const { contrast } = result.report;
|
|
8887
|
+
if (contrast.length) {
|
|
8888
|
+
lines.push(` ${dim(`Contrast · ${contrast.length} pairings checked`)}`);
|
|
8889
|
+
for (const c of contrast) {
|
|
8890
|
+
const shift = c.nudge > 0
|
|
8891
|
+
? `${yellow(`−${c.nudge}`)} ${green(`${c.ratioAfter} ${c.levelAfter}`)}`
|
|
8892
|
+
: dim("unchanged");
|
|
8893
|
+
const flag = c.unresolved ? ` ${yellow("! still short of the bar")}` : "";
|
|
8894
|
+
lines.push(` ${c.name.padEnd(10)} ${String(c.ratioBefore).padStart(5)} ${shift}${flag}`);
|
|
8895
|
+
}
|
|
8896
|
+
lines.push("");
|
|
8897
|
+
}
|
|
8898
|
+
const nudged = contrast.filter(c => c.nudge > 0).length;
|
|
8899
|
+
lines.push(` ${dim(`${result.sections.length} subsystems · ${variableCount} variables · 0 recipes`)}`);
|
|
8900
|
+
if (nudged)
|
|
8901
|
+
lines.push(` ${dim(`${nudged} colour${nudged === 1 ? "" : "s"} darkened to clear the contrast bar`)}`);
|
|
8902
|
+
return lines;
|
|
8903
|
+
}
|
|
8904
|
+
|
|
7872
8905
|
/**
|
|
7873
8906
|
* `refract` CLI (Node-only, §7 Step 10c) — a thin subcommand dispatcher over the build layer.
|
|
7874
8907
|
*
|
|
@@ -7885,6 +8918,11 @@ async function runDiff(options) {
|
|
|
7885
8918
|
const HELP = `refract — build a refract theme to disk.
|
|
7886
8919
|
|
|
7887
8920
|
Usage:
|
|
8921
|
+
refract create [--seed <color>] [--colors <n|list>] [--scheme <name>] [--manual]
|
|
8922
|
+
[--feel <neutral|compact|editorial|technical>] [--ratio <name>]
|
|
8923
|
+
[--base-size <px>] [--contrast <AA|AAA|none>] [--reset <name>]
|
|
8924
|
+
[--format <ts|js|json>] [--out <file>] [--no-semantics]
|
|
8925
|
+
[--no-neutral] [--no-shadows] [--yes] [--force]
|
|
7888
8926
|
refract init [--js | --mjs] [--force]
|
|
7889
8927
|
refract import <tokens.json> [--out <file>] [--raw-only] [--force]
|
|
7890
8928
|
[--breakpoint-group <name>] [--breakpoints <n:px,…>]
|
|
@@ -7898,6 +8936,7 @@ Usage:
|
|
|
7898
8936
|
refract help
|
|
7899
8937
|
|
|
7900
8938
|
Commands:
|
|
8939
|
+
create Design a theme.raw.(ts|js|json) from one seed colour and a short interview.
|
|
7901
8940
|
init Scaffold a runnable theme.config.(ts|js|mjs) in the current directory.
|
|
7902
8941
|
import Seed a theme.raw.ts (+ theme.config.ts) from a DTCG tokens.json (one-shot).
|
|
7903
8942
|
build Load the config and emit every target's files to its outDir.
|
|
@@ -7938,14 +8977,98 @@ async function cmdInit(argv) {
|
|
|
7938
8977
|
const variant = values.mjs ? "mjs" : values.js ? "js" : "ts";
|
|
7939
8978
|
try {
|
|
7940
8979
|
const result = runInit({ variant, force: Boolean(values.force) });
|
|
8980
|
+
if (result.rawTheme) {
|
|
8981
|
+
process.stdout.write(`Found ${result.rawTheme.filename} — wired it up.\n`);
|
|
8982
|
+
}
|
|
7941
8983
|
process.stdout.write(`Created ${result.path}\n`);
|
|
7942
|
-
process.stdout.write(
|
|
8984
|
+
process.stdout.write(result.rawTheme
|
|
8985
|
+
? `Next: run \`refract build\`.\n`
|
|
8986
|
+
: `Next: edit the starter theme in the config (or run \`refract create\` to design one), then \`refract build\`.\n`);
|
|
7943
8987
|
return 0;
|
|
7944
8988
|
}
|
|
7945
8989
|
catch (err) {
|
|
7946
8990
|
return reportError("init", err);
|
|
7947
8991
|
}
|
|
7948
8992
|
}
|
|
8993
|
+
/**
|
|
8994
|
+
* `refract create` — the guided raw-theme scaffolder.
|
|
8995
|
+
*
|
|
8996
|
+
* Flags only here: the question flow lives in `createInterview.ts` so `create-refract-theme` runs
|
|
8997
|
+
* the identical script, and the generation lives in `scaffold.ts`. Every prompt has a flag and every
|
|
8998
|
+
* flag has a default, so this command is fully scriptable (`--yes`) and never blocks in CI.
|
|
8999
|
+
*/
|
|
9000
|
+
async function cmdCreate(argv) {
|
|
9001
|
+
var _a;
|
|
9002
|
+
const { values } = node_util.parseArgs({
|
|
9003
|
+
args: argv,
|
|
9004
|
+
options: {
|
|
9005
|
+
seed: { type: "string" },
|
|
9006
|
+
colors: { type: "string" },
|
|
9007
|
+
scheme: { type: "string" },
|
|
9008
|
+
manual: { type: "boolean", default: false },
|
|
9009
|
+
feel: { type: "string" },
|
|
9010
|
+
ratio: { type: "string" },
|
|
9011
|
+
"base-size": { type: "string" },
|
|
9012
|
+
contrast: { type: "string" },
|
|
9013
|
+
reset: { type: "string" },
|
|
9014
|
+
format: { type: "string" },
|
|
9015
|
+
out: { type: "string" },
|
|
9016
|
+
"no-semantics": { type: "boolean", default: false },
|
|
9017
|
+
"no-neutral": { type: "boolean", default: false },
|
|
9018
|
+
"no-shadows": { type: "boolean", default: false },
|
|
9019
|
+
yes: { type: "boolean", default: false },
|
|
9020
|
+
force: { type: "boolean", default: false },
|
|
9021
|
+
},
|
|
9022
|
+
allowPositionals: false,
|
|
9023
|
+
});
|
|
9024
|
+
const p = new Prompter(values.yes ? false : undefined);
|
|
9025
|
+
try {
|
|
9026
|
+
p.write();
|
|
9027
|
+
p.write(` ${bold("refract")} ${dim("· raw-theme scaffold")}`);
|
|
9028
|
+
p.write();
|
|
9029
|
+
const answers = await promptCreateAnswers(p, {
|
|
9030
|
+
seed: values.seed,
|
|
9031
|
+
colors: values.colors,
|
|
9032
|
+
scheme: values.scheme,
|
|
9033
|
+
manual: values.manual,
|
|
9034
|
+
feel: values.feel,
|
|
9035
|
+
ratio: values.ratio,
|
|
9036
|
+
baseSize: values["base-size"],
|
|
9037
|
+
contrast: values.contrast,
|
|
9038
|
+
reset: values.reset,
|
|
9039
|
+
format: values.format,
|
|
9040
|
+
noSemantics: values["no-semantics"],
|
|
9041
|
+
noNeutral: values["no-neutral"],
|
|
9042
|
+
noShadows: values["no-shadows"],
|
|
9043
|
+
});
|
|
9044
|
+
const result = runCreate({ ...answers, out: values.out, force: Boolean(values.force) });
|
|
9045
|
+
p.write();
|
|
9046
|
+
for (const line of createReportLines(result, countVariables(result.raw)))
|
|
9047
|
+
p.write(line);
|
|
9048
|
+
p.write();
|
|
9049
|
+
p.write(` ${bold((_a = result.path.split(/[\\/]/).pop()) !== null && _a !== void 0 ? _a : "")} written`);
|
|
9050
|
+
p.write();
|
|
9051
|
+
p.write(` ${dim("Next — wire up a build:")} ${bold("refract init")}`);
|
|
9052
|
+
p.write();
|
|
9053
|
+
return 0;
|
|
9054
|
+
}
|
|
9055
|
+
catch (err) {
|
|
9056
|
+
return reportError("create", err);
|
|
9057
|
+
}
|
|
9058
|
+
finally {
|
|
9059
|
+
p.close();
|
|
9060
|
+
}
|
|
9061
|
+
}
|
|
9062
|
+
/** Compile the generated theme with the null adapter purely to count what it emits, for the report. */
|
|
9063
|
+
function countVariables(raw) {
|
|
9064
|
+
try {
|
|
9065
|
+
const theme = createTheme(raw, { adapter: createNoopAdapter() });
|
|
9066
|
+
return Object.keys(theme.tokens).length;
|
|
9067
|
+
}
|
|
9068
|
+
catch {
|
|
9069
|
+
return 0;
|
|
9070
|
+
}
|
|
9071
|
+
}
|
|
7949
9072
|
async function cmdImport(argv) {
|
|
7950
9073
|
const { values, positionals } = node_util.parseArgs({
|
|
7951
9074
|
args: argv,
|
|
@@ -8255,6 +9378,8 @@ async function cmdSkills(argv) {
|
|
|
8255
9378
|
async function main(argv) {
|
|
8256
9379
|
const [command, ...rest] = argv;
|
|
8257
9380
|
switch (command) {
|
|
9381
|
+
case "create":
|
|
9382
|
+
return cmdCreate(rest);
|
|
8258
9383
|
case "init":
|
|
8259
9384
|
return cmdInit(rest);
|
|
8260
9385
|
case "import":
|