@theme-registry/refract 0.1.3 → 0.1.5

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
@@ -8004,7 +8004,7 @@ const RATIO_VALUES = {
8004
8004
  * overlap, so a scaffolded literal and an authored `harmony:` key agree. `pentadic` is local — the
8005
8005
  * subsystem has no five-way scheme, and the scaffolder needs one to reach five brand colours.
8006
8006
  */
8007
- const SCHEME_ROTATIONS = {
8007
+ const SCHEME_ROTATIONS$1 = {
8008
8008
  complement: [180],
8009
8009
  analogous: [-30, 30],
8010
8010
  "split-complement": [150, 210],
@@ -8106,7 +8106,7 @@ const defaultSchemeFor = (brandCount) => {
8106
8106
  return "pentadic";
8107
8107
  };
8108
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);
8109
+ const schemesFor = (brandCount) => Object.keys(SCHEME_ROTATIONS$1).filter((s) => SCHEME_ROTATIONS$1[s].length === brandCount - 1);
8110
8110
  /**
8111
8111
  * The contrast gate. Builds the palette set, audits every text-on-base pairing, and walks the
8112
8112
  * lightness of each failing colour down one OKLCH point at a time until it clears the bar.
@@ -8202,7 +8202,7 @@ function scaffoldTheme(answers) {
8202
8202
  }
8203
8203
  else if (brandCount > 1) {
8204
8204
  const scheme = (_g = answers.scheme) !== null && _g !== void 0 ? _g : defaultSchemeFor(brandCount);
8205
- const rotations = SCHEME_ROTATIONS[scheme];
8205
+ const rotations = SCHEME_ROTATIONS$1[scheme];
8206
8206
  if (!rotations)
8207
8207
  throw new Error(`Unknown harmony scheme "${scheme}".`);
8208
8208
  rotations.slice(0, brandCount - 1).forEach((deg, i) => {
@@ -8430,16 +8430,22 @@ function runCreate(options) {
8430
8430
  }
8431
8431
 
8432
8432
  /**
8433
- * Minimal interactive prompts over `node:readline` (Node-only).
8433
+ * Interactive prompts over `node:readline` and raw stdin (Node-only).
8434
8434
  *
8435
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.
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.
8440
8446
  *
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.
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.
8443
8449
  */
8444
8450
  /** ANSI helpers — no-ops when the stream isn't a TTY, so piped output stays clean. */
8445
8451
  const useColor = () => Boolean(process.stdout.isTTY) && !process.env.NO_COLOR;
@@ -8449,10 +8455,131 @@ const dim = (s) => paint("2", s);
8449
8455
  const cyan = (s) => paint("36", s);
8450
8456
  const green = (s) => paint("32", s);
8451
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
+ /**
8464
+ * Does this terminal do 24-bit colour? `COLORTERM` is the only real signal, and plenty of capable
8465
+ * terminals (Apple Terminal among them) never set it — so a missing value means "approximate", not
8466
+ * "give up". Gating swatches on truecolor alone would silently delete them for those users.
8467
+ */
8468
+ const truecolor = () => { var _a; return /truecolor|24bit/i.test((_a = process.env.COLORTERM) !== null && _a !== void 0 ? _a : ""); };
8469
+ /** Nearest xterm-256 colour-cube index for an RGB triple — the fallback when 24-bit isn't offered. */
8470
+ const to256 = (r, g, b) => {
8471
+ const axis = (v) => (v < 48 ? 0 : v < 114 ? 1 : Math.min(5, Math.round((v - 35) / 40)));
8472
+ return 16 + 36 * axis(r) + 6 * axis(g) + axis(b);
8473
+ };
8474
+ /**
8475
+ * `#rrggbb` → a coloured block: exact in 24-bit terminals, approximated in 256-colour ones, and
8476
+ * empty when there's no colour at all (a pipe, `NO_COLOR`) so redirected output stays clean.
8477
+ *
8478
+ * Approximating is right here — the swatch is a preview, and the exact value is written to the theme
8479
+ * file regardless. An approximately-right hue beats a blank space.
8480
+ */
8481
+ function swatch(hex, width = 2) {
8482
+ if (!useColor())
8483
+ return "";
8484
+ const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim());
8485
+ if (!m)
8486
+ return "";
8487
+ const n = parseInt(m[1], 16);
8488
+ const [r, g, b] = [(n >> 16) & 255, (n >> 8) & 255, n & 255];
8489
+ const bg = truecolor() ? `48;2;${r};${g};${b}` : `48;5;${to256(r, g, b)}`;
8490
+ return `\u001b[${bg}m${" ".repeat(width)}\u001b[0m`;
8491
+ }
8492
+ /** Decode a raw stdin chunk into a {@link ListKey}. Arrows arrive as escape sequences; j/k mirror vim. */
8493
+ function decodeKey(chunk) {
8494
+ switch (chunk) {
8495
+ case "\u001b[A":
8496
+ case "k":
8497
+ return "up";
8498
+ case "\u001b[B":
8499
+ case "j":
8500
+ return "down";
8501
+ case " ":
8502
+ return "space";
8503
+ case "\r":
8504
+ case "\n":
8505
+ return "submit";
8506
+ case "a":
8507
+ return "all";
8508
+ case "\u0003": // Ctrl-C
8509
+ case "\u001b": // bare Escape
8510
+ return "cancel";
8511
+ default:
8512
+ return "none";
8513
+ }
8514
+ }
8452
8515
  /**
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.
8516
+ * Split one stdin chunk into the keys it contains.
8517
+ *
8518
+ * A held-down arrow key, or fast typing, delivers several sequences in a single `data` event — so
8519
+ * decoding the chunk as one key would swallow all but the first and make the list feel like it drops
8520
+ * input. Escape sequences (`ESC [ <letter>`) are taken as a unit; everything else is one key each.
8521
+ */
8522
+ function decodeKeys(chunk) {
8523
+ const keys = [];
8524
+ for (let i = 0; i < chunk.length;) {
8525
+ if (chunk[i] === "\u001b" && chunk[i + 1] === "[" && i + 2 < chunk.length) {
8526
+ keys.push(decodeKey(chunk.slice(i, i + 3)));
8527
+ i += 3;
8528
+ }
8529
+ else {
8530
+ keys.push(decodeKey(chunk[i]));
8531
+ i += 1;
8532
+ }
8533
+ }
8534
+ return keys;
8535
+ }
8536
+ /**
8537
+ * Advance a list prompt by one key. Pure — no I/O — so navigation is testable without a terminal.
8538
+ *
8539
+ * The cursor **wraps** at both ends: with six options, up from the first should land on the last
8540
+ * rather than stick. `space` and `a` toggle only in multi mode; in single mode Enter is the commit,
8541
+ * so space would be ambiguous.
8542
+ */
8543
+ function applyKey(state, key, count, multi) {
8544
+ if (state.done || state.cancelled || count === 0)
8545
+ return state;
8546
+ switch (key) {
8547
+ case "up":
8548
+ return { ...state, cursor: (state.cursor - 1 + count) % count };
8549
+ case "down":
8550
+ return { ...state, cursor: (state.cursor + 1) % count };
8551
+ case "space": {
8552
+ if (!multi)
8553
+ return state;
8554
+ const next = new Set(state.selected);
8555
+ if (next.has(state.cursor))
8556
+ next.delete(state.cursor);
8557
+ else
8558
+ next.add(state.cursor);
8559
+ return { ...state, selected: next };
8560
+ }
8561
+ case "all": {
8562
+ if (!multi)
8563
+ return state;
8564
+ const everything = state.selected.size === count;
8565
+ return {
8566
+ ...state,
8567
+ selected: everything ? new Set() : new Set(Array.from({ length: count }, (_, i) => i)),
8568
+ };
8569
+ }
8570
+ case "submit":
8571
+ return { ...state, done: true };
8572
+ case "cancel":
8573
+ return { ...state, cancelled: true };
8574
+ default:
8575
+ return state;
8576
+ }
8577
+ }
8578
+ /** Can we drive the terminal directly? Some TTYs (and most CI) don't grant raw mode. */
8579
+ const rawCapable = () => Boolean(process.stdin.isTTY) && typeof process.stdin.setRawMode === "function";
8580
+ /**
8581
+ * A prompt session. Holds at most one readline interface, so stdin is opened and closed once;
8582
+ * `interactive` is false when there's no TTY, and every ask short-circuits to its default.
8456
8583
  */
8457
8584
  class Prompter {
8458
8585
  constructor(interactive = Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY)) {
@@ -8471,7 +8598,11 @@ class Prompter {
8471
8598
  question(text) {
8472
8599
  return new Promise(resolve => this.io.question(text, answer => resolve(answer)));
8473
8600
  }
8474
- /** Print a line to stdout. Kept on the prompter so command code never touches `process.stdout`. */
8601
+ /** Raw write, no newline for cursor control during a live render. */
8602
+ out(s) {
8603
+ process.stdout.write(s);
8604
+ }
8605
+ /** Print a line. Kept on the prompter so command code never touches `process.stdout`. */
8475
8606
  write(line = "") {
8476
8607
  process.stdout.write(`${line}\n`);
8477
8608
  }
@@ -8498,11 +8629,140 @@ class Prompter {
8498
8629
  });
8499
8630
  return Number(answer);
8500
8631
  }
8501
- /** Pick one. Answers are 1-based indices; blank takes the default (the first choice unless given). */
8632
+ /** Pick one arrows to move, Enter to choose. */
8502
8633
  async select(label, choices, defaultIndex = 0) {
8503
- var _a, _b;
8634
+ var _a, _b, _c;
8504
8635
  if (!this.interactive || choices.length === 1)
8505
8636
  return (_b = (_a = choices[defaultIndex]) === null || _a === void 0 ? void 0 : _a.value) !== null && _b !== void 0 ? _b : choices[0].value;
8637
+ if (rawCapable()) {
8638
+ const picked = await this.runList(label, choices, {
8639
+ multi: false,
8640
+ cursor: defaultIndex,
8641
+ selected: new Set(),
8642
+ });
8643
+ return choices[(_c = picked[0]) !== null && _c !== void 0 ? _c : defaultIndex].value;
8644
+ }
8645
+ return this.selectByNumber(label, choices, defaultIndex);
8646
+ }
8647
+ /** Pick any — arrows to move, space to toggle, `a` for all/none, Enter to confirm. */
8648
+ async multiselect(label, choices, preselected) {
8649
+ var _a;
8650
+ if (!this.interactive)
8651
+ return preselected.map(i => choices[i].value);
8652
+ if (rawCapable()) {
8653
+ const picked = await this.runList(label, choices, {
8654
+ multi: true,
8655
+ cursor: (_a = preselected[0]) !== null && _a !== void 0 ? _a : 0,
8656
+ selected: new Set(preselected),
8657
+ });
8658
+ return picked.map(i => choices[i].value);
8659
+ }
8660
+ return this.multiselectByNumber(label, choices, preselected);
8661
+ }
8662
+ /**
8663
+ * The raw-mode list loop. Renders in place: each keypress rewinds over the block just drawn and
8664
+ * repaints it, so the list stays put instead of scrolling the terminal away.
8665
+ *
8666
+ * Any readline interface is closed first — it would otherwise swallow the keystrokes we need.
8667
+ * `cleanup` always restores the terminal (raw mode off, cursor shown), including on cancel, so a
8668
+ * Ctrl-C can't leave the shell in a state where typing is invisible.
8669
+ */
8670
+ runList(label, choices, init) {
8671
+ this.close(); // readline and raw mode can't both own stdin
8672
+ const stdin = process.stdin;
8673
+ const { multi } = init;
8674
+ let state = {
8675
+ cursor: Math.max(0, Math.min(init.cursor, choices.length - 1)),
8676
+ selected: init.selected,
8677
+ done: false,
8678
+ cancelled: false,
8679
+ };
8680
+ let painted = 0;
8681
+ const help = multi ? "↑↓ move · space toggle · a all · enter confirm" : "↑↓ move · enter select";
8682
+ // Pad labels so the hints line up into a column — a ragged right edge is most of why a long
8683
+ // option list reads as noise.
8684
+ const widest = choices.reduce((w, c) => Math.max(w, c.label.length), 0);
8685
+ const frame = () => {
8686
+ const rows = choices.map((c, i) => {
8687
+ var _a;
8688
+ const here = i === state.cursor;
8689
+ // Focus and selection are SEPARATE signals and both need to be visible: the caret says
8690
+ // where you are, the box says what's chosen. Showing only one (as this did) leaves a
8691
+ // multi-select where you can't tell what the keys will act on.
8692
+ const caret = here ? green("❯") : " ";
8693
+ const box = multi ? (state.selected.has(i) ? green("[✓]") : dim("[ ]")) : "";
8694
+ const name = here ? bold(c.label) : c.label;
8695
+ const pad = " ".repeat(Math.max(0, widest - c.label.length));
8696
+ const blocks = ((_a = c.swatches) !== null && _a !== void 0 ? _a : []).map(s => swatch(s)).filter(Boolean).join(" ");
8697
+ const art = blocks ? ` ${blocks}` : "";
8698
+ const hint = c.hint ? ` ${dim(c.hint)}` : "";
8699
+ return ` ${caret} ${box ? `${box} ` : ""}${name}${pad}${art}${hint}`;
8700
+ });
8701
+ return [`${cyan("?")} ${bold(label)} ${dim(help)}`, ...rows].join("\n");
8702
+ };
8703
+ const paintFrame = () => {
8704
+ if (painted)
8705
+ this.out(cursorUp(painted) + CLEAR_DOWN);
8706
+ const text = frame();
8707
+ this.out(`${text}\n`);
8708
+ painted = text.split("\n").length;
8709
+ };
8710
+ return new Promise((resolve, reject) => {
8711
+ const cleanup = () => {
8712
+ stdin.removeListener("data", onData);
8713
+ stdin.removeListener("end", onEnd);
8714
+ if (stdin.isTTY)
8715
+ stdin.setRawMode(false);
8716
+ stdin.pause();
8717
+ this.out(SHOW_CURSOR);
8718
+ };
8719
+ /**
8720
+ * stdin closed while we were waiting — a piped run, a closed terminal, a killed parent. There
8721
+ * is no further input coming, so commit what's on screen instead of blocking forever.
8722
+ */
8723
+ const onEnd = () => {
8724
+ const chosen = multi ? [...state.selected].sort((a, b) => a - b) : [state.cursor];
8725
+ this.out(cursorUp(painted) + CLEAR_DOWN);
8726
+ cleanup();
8727
+ resolve(chosen);
8728
+ };
8729
+ const onData = (chunk) => {
8730
+ // One event can carry several keys (a held-down arrow); apply them all, then paint once.
8731
+ for (const key of decodeKeys(chunk)) {
8732
+ state = applyKey(state, key, choices.length, multi);
8733
+ if (state.done || state.cancelled)
8734
+ break;
8735
+ }
8736
+ if (state.cancelled) {
8737
+ this.out(cursorUp(painted) + CLEAR_DOWN);
8738
+ cleanup();
8739
+ reject(new Error("Cancelled."));
8740
+ return;
8741
+ }
8742
+ if (state.done) {
8743
+ const chosen = multi ? [...state.selected].sort((a, b) => a - b) : [state.cursor];
8744
+ // Replace the live block with a one-line record of the answer, so a finished interview
8745
+ // reads back as a transcript rather than a wall of spent menus.
8746
+ this.out(cursorUp(painted) + CLEAR_DOWN);
8747
+ const summary = chosen.length ? chosen.map(i => choices[i].label).join(", ") : "none";
8748
+ this.write(`${cyan("?")} ${bold(label)} ${dim("›")} ${green(summary)}`);
8749
+ cleanup();
8750
+ resolve(chosen);
8751
+ return;
8752
+ }
8753
+ paintFrame();
8754
+ };
8755
+ stdin.setRawMode(true);
8756
+ stdin.resume();
8757
+ stdin.setEncoding("utf8");
8758
+ this.out(HIDE_CURSOR);
8759
+ stdin.on("data", onData);
8760
+ stdin.on("end", onEnd);
8761
+ paintFrame();
8762
+ });
8763
+ }
8764
+ /** Line-mode fallback: pick one by number. */
8765
+ async selectByNumber(label, choices, defaultIndex) {
8506
8766
  this.write(`${cyan("?")} ${bold(label)}`);
8507
8767
  choices.forEach((c, i) => {
8508
8768
  const marker = i === defaultIndex ? green("❯") : " ";
@@ -8519,14 +8779,9 @@ class Prompter {
8519
8779
  this.write(` ${yellow("!")} Enter a number between 1 and ${choices.length}.`);
8520
8780
  }
8521
8781
  }
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)")}`);
8782
+ /** Line-mode fallback: pick any by comma-separated numbers. */
8783
+ async multiselectByNumber(label, choices, preselected) {
8784
+ this.write(`${cyan("?")} ${bold(label)} ${dim('(comma-separated, blank = keep, "none" = clear)')}`);
8530
8785
  choices.forEach((c, i) => {
8531
8786
  const on = preselected.includes(i);
8532
8787
  const hint = c.hint ? ` ${dim(`— ${c.hint}`)}` : "";
@@ -8547,6 +8802,19 @@ class Prompter {
8547
8802
  }
8548
8803
  }
8549
8804
 
8805
+ /**
8806
+ * The hues a scheme derives from a seed — the same rotations the generator will bake, so the
8807
+ * preview beside each option is the palette you'd actually get, not an illustration.
8808
+ */
8809
+ const SCHEME_ROTATIONS = {
8810
+ complement: [180],
8811
+ analogous: [-30, 30],
8812
+ "split-complement": [150, 210],
8813
+ triadic: [120, 240],
8814
+ tetradic: [90, 180, 270],
8815
+ pentadic: [72, 144, 216, 288],
8816
+ };
8817
+ const schemeHues = (seedHex, scheme) => { var _a; return ((_a = SCHEME_ROTATIONS[scheme]) !== null && _a !== void 0 ? _a : []).map(deg => convertRgbToHex(parseColor(rotateHue(seedHex, deg)).rgb)); };
8550
8818
  /** What each harmony scheme is for, in one line — the names mean nothing to most people. */
8551
8819
  const SCHEME_HINTS = {
8552
8820
  complement: "one companion, 180° opposite — maximum separation",
@@ -8589,7 +8857,9 @@ async function promptCreateAnswers(p, given = {}) {
8589
8857
  var _a, _b, _c, _d, _e, _f, _g, _h;
8590
8858
  const seed = (_a = given.seed) !== null && _a !== void 0 ? _a : (await p.text("Primary colour", "#4c6ef5", isColor));
8591
8859
  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`)}`);
8860
+ const seedHex = convertRgbToHex(parseColor(seed).rgb);
8861
+ const chip = swatch(seedHex, 3);
8862
+ p.write(` ${chip ? `${chip} ` : ""}${dim(`${seedHex} · lightness ${lightness}% — lands at ≈${nearestLadderStep(lightness)} on the ladder`)}`);
8593
8863
  p.write();
8594
8864
  const manual = Boolean(given.manual);
8595
8865
  let brandCount = 2;
@@ -8612,7 +8882,14 @@ async function promptCreateAnswers(p, given = {}) {
8612
8882
  const options = schemesFor(brandCount);
8613
8883
  scheme = (_c = given.scheme) !== null && _c !== void 0 ? _c : defaultSchemeFor(brandCount);
8614
8884
  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)));
8885
+ // Show the hues each scheme actually produces. "split-complement" is a word; two blocks of
8886
+ // real colour beside the seed is the thing you're choosing between.
8887
+ scheme = await p.select("Harmony scheme", options.map(s => ({
8888
+ value: s,
8889
+ label: s,
8890
+ hint: SCHEME_HINTS[s],
8891
+ swatches: [seedHex, ...schemeHues(seedHex, s)],
8892
+ })), Math.max(0, options.indexOf(scheme)));
8616
8893
  }
8617
8894
  if (scheme && brandCount > 1) {
8618
8895
  p.write(` ${dim(`→ ${scheme} · each member becomes its own palette with a full ladder`)}`);