@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/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 = {
@@ -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
- node_fs.writeFileSync(path, scaffoldConfig(packageName), "utf8");
7238
- 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 };
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(`Next: edit the raw theme, then run \`refract build\`.\n`);
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":