@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/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$1 = (n, dp) => {
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$1(ratio, 2),
3204
+ wcagRatio: round$2(ratio, 2),
3204
3205
  wcagLevel: level,
3205
- apcaLc: round$1(apcaLc(fgRgb, bgRgb), 1),
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
- primary: { base: "#4dabf7", text: "#fff", variants: { dark: "#1c7ed6", light: "#a5d8ff" } },
7175
- neutral: { base: "#868e96", text: "#fff", variants: { light: "#f1f3f5", dark: "#343a40" } },
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
- node_fs.writeFileSync(path, scaffoldConfig(packageName), "utf8");
7209
- return { path, variant, packageName };
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
- /** Load a candidate raw theme from a module (default export) or a `.json` file. */
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
- return JSON.parse(node_fs.readFileSync(path, "utf8"));
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
- return ((_b = (_a = mod.default) !== null && _a !== void 0 ? _a : mod.raw) !== null && _b !== void 0 ? _b : mod);
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
- const mod = (await import(`${node_url.pathToFileURL(path).href}?v=${++counter}`));
7767
- return ((_d = (_c = mod.default) !== null && _c !== void 0 ? _c : mod.raw) !== null && _d !== void 0 ? _d : mod);
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(`Next: edit the raw theme, then run \`refract build\`.\n`);
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
- process.stderr.write(`refract init: ${err.message}\n`);
7894
- return 1;
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
- process.stderr.write(`refract import: ${err.message}\n`);
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
- process.stderr.write(`refract build: ${err.message}\n`);
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
- process.stderr.write(`refract tokens: ${err.message}\n`);
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
- process.stderr.write(`refract diff: ${err.message}\n`);
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
- process.stderr.write(`${err.message}\n`);
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
- process.stderr.write(`refract skills list: ${err.message}\n`);
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
- process.stderr.write(`refract skills: ${err.message}\n`);
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":