@theme-registry/refract 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -47,6 +47,21 @@ npm install @theme-registry/refract
47
47
  `styled-components` and `typescript` are **optional** peers — only needed if you use the
48
48
  styled-components adapter or the `.ts` build config, respectively.
49
49
 
50
+ ## Scaffold a theme
51
+
52
+ Don't start from a blank file. One seed colour becomes a full theme — palettes with tonal ladders,
53
+ semantic colours, a type scale with derived leading, a spacing ramp — with every colour checked
54
+ against **WCAG contrast before the file is written**:
55
+
56
+ ```bash
57
+ npx refract create # in an existing project → theme.raw.ts
58
+ npm create refract-theme my-theme # from nothing → a publishable theme package
59
+ ```
60
+
61
+ The generator runs **once**; what it writes is an ordinary theme file you own and edit. It emits
62
+ **tokens only** — no recipes, so nothing composes into a class list yet. That's the next step, and
63
+ it's design work: see [Recipes](https://theme-registry.github.io/refract/r-recipes).
64
+
50
65
  ## Quick start
51
66
 
52
67
  `createTheme(raw, { adapter })` builds the theme. The `adapter` is **required** — core
@@ -126,7 +141,8 @@ Write your own with `defineAdapter(spec)` — you fill four primitives (`recipeN
126
141
  ## Build to disk (CLI)
127
142
 
128
143
  ```bash
129
- npx refract init # scaffold a theme.config.(ts|js|mjs)
144
+ npx refract create # design a theme.raw.(ts|js|json) from one seed colour
145
+ npx refract init # scaffold a theme.config.(ts|js|mjs) — imports theme.raw.* if present
130
146
  npx refract build # load the config → write every target's files to its outDir
131
147
  npx refract tokens # export theme.tokens as a DTCG tokens.json (adapter-free)
132
148
  ```
@@ -0,0 +1,47 @@
1
+ import { type ScaffoldAnswers, type ScaffoldReport } from "./scaffold";
2
+ import type { RawTheme } from "../core";
3
+ /** The file flavours `create` can emit. */
4
+ export type RawFormat = "ts" | "js" | "json";
5
+ export declare const DEFAULT_RAW_BASENAME = "theme.raw";
6
+ export interface CreateOptions extends ScaffoldAnswers {
7
+ /** Project dir to write into (default `process.cwd()`). */
8
+ readonly cwd?: string;
9
+ /** Which flavour to write (default `"ts"`). */
10
+ readonly format?: RawFormat;
11
+ /** Output filename, relative to `cwd` (default `theme.raw.<format>`). */
12
+ readonly out?: string;
13
+ /** Overwrite an existing file instead of refusing (default `false`). */
14
+ readonly force?: boolean;
15
+ /** Package name the `.ts`/`.js` type import points at (default: this package's own `name`). */
16
+ readonly packageName?: string;
17
+ }
18
+ export interface CreateResult {
19
+ /** Absolute path of the written file. */
20
+ readonly path: string;
21
+ readonly format: RawFormat;
22
+ /** The generated theme, so callers can compile it without re-reading the file. */
23
+ readonly raw: RawTheme;
24
+ /** What the generator derived — the CLI prints this as its summary. */
25
+ readonly report: ScaffoldReport;
26
+ /** Count of emitted top-level subsystem slices. */
27
+ readonly sections: readonly string[];
28
+ }
29
+ /**
30
+ * Serialize a generated theme to source text.
31
+ *
32
+ * `ts` and `js` share one body and differ only in how the type is attached (a real `satisfies` vs a
33
+ * JSDoc cast), so a JS user gets the same editor completion without adding a compile step. `json`
34
+ * carries no types and no shared `ladder` const — it is the portable flavour, and for a scaffolded
35
+ * theme it is exactly as faithful, because nothing here is a function or a recipe.
36
+ */
37
+ export declare function renderRawTheme(raw: RawTheme, options: {
38
+ format: RawFormat;
39
+ packageName: string;
40
+ seed: string;
41
+ detail: string;
42
+ }): string;
43
+ /**
44
+ * Generate and write the raw theme. Throws when the target exists and `force` is not set — the same
45
+ * guard `init` and `import` use, so no command in this CLI can silently destroy authored work.
46
+ */
47
+ export declare function runCreate(options: CreateOptions): CreateResult;
@@ -0,0 +1,56 @@
1
+ /**
2
+ * The `refract create` interview (Node-only) — the question flow, separated from both CLIs that run it.
3
+ *
4
+ * `refract create` asks it to write a theme into an existing project; `create-refract-theme` asks it
5
+ * while scaffolding a whole new package. Two entry points, one script of questions — because an
6
+ * interview duplicated across two packages is an interview that drifts.
7
+ *
8
+ * Every answer can be supplied up front (a flag, or a caller's default), and every prompt is
9
+ * non-interactive-safe, so the same function serves a human at a terminal and a scripted run.
10
+ */
11
+ import type { Prompter } from "./prompt";
12
+ import { type ScaffoldAnswers } from "./scaffold";
13
+ import type { CreateResult, RawFormat } from "./createCommand";
14
+ /** What each harmony scheme is for, in one line — the names mean nothing to most people. */
15
+ export declare const SCHEME_HINTS: Readonly<Record<string, string>>;
16
+ /**
17
+ * Ratio choices, tightest to most dramatic, each showing the ladder it produces from 16px. The ratio
18
+ * is the most consequential typographic choice in the theme and its name carries no information, so
19
+ * the numbers do the explaining.
20
+ */
21
+ export declare const TYPE_SCALE_CHOICES: ReadonlyArray<readonly [string, string]>;
22
+ /** Answers a caller already has — from CLI flags, or a host tool's own defaults. */
23
+ export interface InterviewGiven {
24
+ readonly seed?: string;
25
+ /** Brand count in auto mode, or a comma-separated colour list in manual mode. */
26
+ readonly colors?: string;
27
+ readonly scheme?: string;
28
+ readonly manual?: boolean;
29
+ readonly feel?: string;
30
+ readonly ratio?: string;
31
+ readonly baseSize?: string | number;
32
+ readonly contrast?: string;
33
+ readonly reset?: string;
34
+ readonly format?: string;
35
+ readonly noSemantics?: boolean;
36
+ readonly noNeutral?: boolean;
37
+ readonly noShadows?: boolean;
38
+ }
39
+ /** The fully-resolved answer set, ready for `runCreate`. */
40
+ export type InterviewAnswers = ScaffoldAnswers & {
41
+ readonly format: RawFormat;
42
+ };
43
+ /**
44
+ * Run the interview. Anything present in `given` is taken as answered and never asked.
45
+ *
46
+ * The scheme question only appears when the brand count admits more than one rotation set — which is
47
+ * only at three colours. Two is a complement, four a tetradic, five a pentadic: asking would be a
48
+ * prompt with a single option.
49
+ */
50
+ export declare function promptCreateAnswers(p: Prompter, given?: InterviewGiven): Promise<InterviewAnswers>;
51
+ /**
52
+ * The post-generation summary: what the contrast pass did, and what landed. Returned as lines rather
53
+ * than printed so each host can frame them — the standalone command and the project scaffolder put
54
+ * different things around the same facts.
55
+ */
56
+ export declare function createReportLines(result: CreateResult, variableCount: number): string[];
@@ -12,6 +12,10 @@ export * from "./emitTheme";
12
12
  export * from "./emit";
13
13
  export * from "./config";
14
14
  export * from "./init";
15
+ export * from "./scaffold";
16
+ export * from "./createCommand";
17
+ export * from "./createInterview";
18
+ export * from "./prompt";
15
19
  export * from "./importCommand";
16
20
  export * from "./buildCommand";
17
21
  export * from "./tokensCommand";
@@ -13,6 +13,11 @@ export interface InitOptions {
13
13
  readonly force?: boolean;
14
14
  /** Package name to import from in the scaffold (default: this package's own `name`). */
15
15
  readonly packageName?: string;
16
+ /**
17
+ * Override the raw-theme detection: a {@link DetectedRawTheme} to wire up regardless of what's on
18
+ * disk, or `null` to force the self-contained starter config. Omit to detect (the normal path).
19
+ */
20
+ readonly rawTheme?: DetectedRawTheme | null;
16
21
  }
17
22
  export interface InitResult {
18
23
  /** Absolute path of the written config. */
@@ -20,9 +25,46 @@ export interface InitResult {
20
25
  readonly variant: ConfigVariant;
21
26
  /** The package name the scaffold imports from. */
22
27
  readonly packageName: string;
28
+ /** The raw theme the config was wired to, or `undefined` when it carries its own starter palette. */
29
+ readonly rawTheme?: DetectedRawTheme;
23
30
  }
24
31
  /** Read this package's own `name` (so the scaffold imports the currently-installed package name). */
25
32
  export declare function readOwnPackageName(): string;
33
+ /** A raw theme found next to the config, and how a config should reach it. */
34
+ export interface DetectedRawTheme {
35
+ /** Absolute path of the file found. */
36
+ readonly path: string;
37
+ /** Its basename, e.g. `theme.raw.json`. */
38
+ readonly filename: string;
39
+ /** The extension, including the dot. */
40
+ readonly ext: string;
41
+ }
42
+ /**
43
+ * Look for an authored raw theme in `fromDir`.
44
+ *
45
+ * This is the seam between `create` and `init`: `create` designs the theme, `init` wires the build.
46
+ * When a theme is already there, `init` must not invent a second one — a project with two sources of
47
+ * truth for its tokens is worse than a project with none.
48
+ */
49
+ export declare const findRawTheme: (fromDir?: string) => DetectedRawTheme | undefined;
50
+ /**
51
+ * The import line (or lines) a config uses to reach a detected raw theme.
52
+ *
53
+ * `.ts` is graph-compiled alongside the config, so an extensionless specifier resolves and the
54
+ * transformer rewrites it. `.mjs`/`.js` are imported by Node directly, so they keep their extension.
55
+ * `.json` is read rather than imported: an import attribute (`with { type: "json" }`) would work on
56
+ * current Node but pins the scaffolded config to a version floor for no benefit, and `readFileSync`
57
+ * behaves identically in every flavour.
58
+ */
59
+ export declare function rawThemeImport(detected: DetectedRawTheme): {
60
+ head: string;
61
+ expression: string;
62
+ };
63
+ /**
64
+ * The config body when a raw theme already exists — it imports that theme instead of carrying one.
65
+ * Deliberately short: everything about the design lives in the theme file, and this is only wiring.
66
+ */
67
+ export declare function scaffoldConfigForRaw(packageName: string, detected: DetectedRawTheme): string;
26
68
  /** The scaffolded config body — one shared ESM template across ts/js/mjs. */
27
69
  export declare function scaffoldConfig(packageName: string): string;
28
70
  /**
@@ -0,0 +1,76 @@
1
+ export declare const bold: (s: string) => string;
2
+ export declare const dim: (s: string) => string;
3
+ export declare const cyan: (s: string) => string;
4
+ export declare const green: (s: string) => string;
5
+ export declare const yellow: (s: string) => string;
6
+ /** One choice in a `select` / `multiselect`. */
7
+ export interface Choice<T> {
8
+ readonly value: T;
9
+ readonly label: string;
10
+ /** Trailing grey note — what this option means. */
11
+ readonly hint?: string;
12
+ }
13
+ /** The keys a list prompt understands, after decoding an stdin chunk. */
14
+ export type ListKey = "up" | "down" | "space" | "submit" | "all" | "cancel" | "none";
15
+ /** Decode a raw stdin chunk into a {@link ListKey}. Arrows arrive as escape sequences; j/k mirror vim. */
16
+ export declare function decodeKey(chunk: string): ListKey;
17
+ /**
18
+ * Split one stdin chunk into the keys it contains.
19
+ *
20
+ * A held-down arrow key, or fast typing, delivers several sequences in a single `data` event — so
21
+ * decoding the chunk as one key would swallow all but the first and make the list feel like it drops
22
+ * input. Escape sequences (`ESC [ <letter>`) are taken as a unit; everything else is one key each.
23
+ */
24
+ export declare function decodeKeys(chunk: string): ListKey[];
25
+ /** Where a list prompt currently stands. */
26
+ export interface ListState {
27
+ readonly cursor: number;
28
+ readonly selected: ReadonlySet<number>;
29
+ readonly done: boolean;
30
+ readonly cancelled: boolean;
31
+ }
32
+ /**
33
+ * Advance a list prompt by one key. Pure — no I/O — so navigation is testable without a terminal.
34
+ *
35
+ * The cursor **wraps** at both ends: with six options, up from the first should land on the last
36
+ * rather than stick. `space` and `a` toggle only in multi mode; in single mode Enter is the commit,
37
+ * so space would be ambiguous.
38
+ */
39
+ export declare function applyKey(state: ListState, key: ListKey, count: number, multi: boolean): ListState;
40
+ /**
41
+ * A prompt session. Holds at most one readline interface, so stdin is opened and closed once;
42
+ * `interactive` is false when there's no TTY, and every ask short-circuits to its default.
43
+ */
44
+ export declare class Prompter {
45
+ private rl;
46
+ readonly interactive: boolean;
47
+ constructor(interactive?: boolean);
48
+ private get io();
49
+ close(): void;
50
+ private question;
51
+ /** Raw write, no newline — for cursor control during a live render. */
52
+ private out;
53
+ /** Print a line. Kept on the prompter so command code never touches `process.stdout`. */
54
+ write(line?: string): void;
55
+ /** Free-text, with a default shown in the prompt. Blank input takes the default. */
56
+ text(label: string, fallback: string, validate?: (v: string) => string | undefined): Promise<string>;
57
+ /** A number, validated as finite. */
58
+ number(label: string, fallback: number, validate?: (v: number) => string | undefined): Promise<number>;
59
+ /** Pick one — arrows to move, Enter to choose. */
60
+ select<T>(label: string, choices: readonly Choice<T>[], defaultIndex?: number): Promise<T>;
61
+ /** Pick any — arrows to move, space to toggle, `a` for all/none, Enter to confirm. */
62
+ multiselect<T>(label: string, choices: readonly Choice<T>[], preselected: readonly number[]): Promise<T[]>;
63
+ /**
64
+ * The raw-mode list loop. Renders in place: each keypress rewinds over the block just drawn and
65
+ * repaints it, so the list stays put instead of scrolling the terminal away.
66
+ *
67
+ * Any readline interface is closed first — it would otherwise swallow the keystrokes we need.
68
+ * `cleanup` always restores the terminal (raw mode off, cursor shown), including on cancel, so a
69
+ * Ctrl-C can't leave the shell in a state where typing is invisible.
70
+ */
71
+ private runList;
72
+ /** Line-mode fallback: pick one by number. */
73
+ private selectByNumber;
74
+ /** Line-mode fallback: pick any by comma-separated numbers. */
75
+ private multiselectByNumber;
76
+ }
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Guided raw-theme generator (Node-only, pure) — the engine behind `refract create`.
3
+ *
4
+ * Turns a seed colour plus a handful of answers into a complete `RawTheme`. Deliberately split from
5
+ * the prompting (`cli.ts`) and from the file writing (`createCommand.ts`) so the whole generator is
6
+ * testable without a TTY and reusable programmatically.
7
+ *
8
+ * The governing rule for what lands in the output:
9
+ *
10
+ * **Bake a literal** where the value came from an opinion the engine does not hold — the harmony
11
+ * rotation, the contrast nudge, the leading/tracking curves. Those are this module's taste, and a
12
+ * scaffolded theme is a style guide the user then owns, so it must hold still.
13
+ *
14
+ * **Write the declaration** where the engine already synthesizes — `fontSize.ratio`,
15
+ * `layout.spacing.step`. Baking those would throw the intent away: re-tuning a scale should stay a
16
+ * one-word edit, not a regeneration of eight numbers.
17
+ *
18
+ * Nothing here changes the palette model. It calls the colour helpers the package already ships
19
+ * (`rotateHue`/`darken`, and `audit` for the contrast gate) and writes down what they return, once.
20
+ * Callers who want a companion that *re-derives* on `override()` should use `colors.harmony` instead —
21
+ * that is the other tool, for the other job.
22
+ */
23
+ import type { RawTheme } from "../core";
24
+ import type { WcagLevel } from "../subsystems/colors/audit";
25
+ /**
26
+ * Hue rotations per scheme. Aligned with the colours subsystem's own `HARMONY_SCHEMES` where they
27
+ * overlap, so a scaffolded literal and an authored `harmony:` key agree. `pentadic` is local — the
28
+ * subsystem has no five-way scheme, and the scaffolder needs one to reach five brand colours.
29
+ */
30
+ declare const SCHEME_ROTATIONS: Readonly<Record<string, readonly number[]>>;
31
+ export type Feel = "neutral" | "compact" | "editorial" | "technical";
32
+ interface FeelPreset {
33
+ readonly label: string;
34
+ readonly blurb: string;
35
+ /** `fontFamily` base + the `mono` variant (system stacks only — a CLI can't install fonts). */
36
+ readonly fontFamily: {
37
+ readonly base: string;
38
+ readonly mono: string;
39
+ };
40
+ /** Multiplier map for the linear spacing curve (`step: 4`). */
41
+ readonly spacing: Readonly<Record<string, number>>;
42
+ /** Radius base + variants. */
43
+ readonly radius: {
44
+ readonly base: number;
45
+ readonly variants: Readonly<Record<string, number | string>>;
46
+ };
47
+ /** Suggested type ratio when the user takes the default. */
48
+ readonly ratio: string;
49
+ }
50
+ export declare const FEEL_PRESETS: Readonly<Record<Feel, FeelPreset>>;
51
+ export type BrandScheme = keyof typeof SCHEME_ROTATIONS;
52
+ export type ResetPreset = "preflight" | "normalize" | "none";
53
+ export type ContrastTarget = "AA" | "AAA" | "none";
54
+ export interface ScaffoldAnswers {
55
+ /** The seed colour — any form `parseColor` accepts (hex, `rgb()`, `oklch()`). */
56
+ readonly seed: string;
57
+ /** `auto` derives colours 2…N by hue rotation; `manual` takes them verbatim from `extraColors`. */
58
+ readonly mode?: "auto" | "manual";
59
+ /** Total brand colours including the primary, 1–5. Default 2. */
60
+ readonly brandCount?: number;
61
+ /** Which rotation set to use. Ignored unless `mode: "auto"`; defaulted from `brandCount`. */
62
+ readonly scheme?: BrandScheme;
63
+ /** `mode: "manual"` — the colours after the primary, in order. */
64
+ readonly extraColors?: readonly string[];
65
+ /** Add success / info / warning / danger. Default true. */
66
+ readonly semantics?: boolean;
67
+ /** Add the neutral ramp. Default true. */
68
+ readonly neutral?: boolean;
69
+ /** Add shadow ink + alpha tints, and the effects ramp that references them. Default true. */
70
+ readonly shadows?: boolean;
71
+ /** Contrast bar for the text-on-base pass. Default `"AA"`; `"none"` skips the pass entirely. */
72
+ readonly contrast?: ContrastTarget;
73
+ /** Base font size in px. Default 16. */
74
+ readonly baseFontSize?: number;
75
+ /** Named type ratio. Defaults to the feel preset's suggestion. */
76
+ readonly ratio?: string;
77
+ /** Type family + density + corners, moved together. Default `"neutral"`. */
78
+ readonly feel?: Feel;
79
+ /** Which normalization layer `globals.preset` gets. Default `"preflight"`. */
80
+ readonly reset?: ResetPreset;
81
+ }
82
+ /** What the contrast pass did to one palette — surfaced so the CLI can report it honestly. */
83
+ export interface ContrastAdjustment {
84
+ readonly name: string;
85
+ /** The colour before the pass. */
86
+ readonly seed: string;
87
+ /** The colour written to the file. */
88
+ readonly final: string;
89
+ /** OKLCH lightness points removed (0 when untouched). */
90
+ readonly nudge: number;
91
+ /** Ratio before, and after. */
92
+ readonly ratioBefore: number;
93
+ readonly ratioAfter: number;
94
+ readonly levelAfter: WcagLevel;
95
+ /** True when the pass ran out of room without reaching the bar. */
96
+ readonly unresolved: boolean;
97
+ }
98
+ export interface ScaffoldReport {
99
+ /** The seed's OKLCH lightness, 0–100. */
100
+ readonly seedLightness: number;
101
+ /** The ladder label the seed sits nearest — `base` stays canonical, this is orientation only. */
102
+ readonly nearestStep: number;
103
+ /** Brand palettes in order, with the hue rotation that produced each (0 for the primary/manual). */
104
+ readonly brand: ReadonlyArray<{
105
+ readonly name: string;
106
+ readonly hex: string;
107
+ readonly rotation: number;
108
+ }>;
109
+ /** Every palette the contrast pass looked at. Empty when `contrast: "none"`. */
110
+ readonly contrast: readonly ContrastAdjustment[];
111
+ /** Derived per-step leading and tracking, for the CLI's summary line. */
112
+ readonly type: ReadonlyArray<{
113
+ readonly step: string;
114
+ readonly px: number;
115
+ readonly leading: number;
116
+ readonly tracking: string;
117
+ }>;
118
+ /** The resolved spacing ramp in px, for the CLI's summary line. */
119
+ readonly spacing: ReadonlyArray<{
120
+ readonly step: string;
121
+ readonly px: number;
122
+ }>;
123
+ }
124
+ export interface ScaffoldResult {
125
+ readonly raw: RawTheme;
126
+ readonly report: ScaffoldReport;
127
+ }
128
+ /** Leading for a size, from the curve above. */
129
+ export declare const deriveLeading: (sizePx: number) => number;
130
+ /** Tracking (em) for a size, from the curve above. */
131
+ export declare const deriveTracking: (sizePx: number) => string;
132
+ /**
133
+ * The ladder label a colour's lightness sits nearest. `base` stays the brand colour — this exists so
134
+ * the CLI can tell you which stops are your hover and active shades without moving the hex you typed.
135
+ */
136
+ export declare const nearestLadderStep: (lightness: number) => number;
137
+ /** Which scheme a brand count implies when the caller doesn't name one. */
138
+ export declare const defaultSchemeFor: (brandCount: number) => BrandScheme | undefined;
139
+ /** Schemes that produce exactly `brandCount − 1` members — the valid choices at that count. */
140
+ export declare const schemesFor: (brandCount: number) => readonly BrandScheme[];
141
+ /**
142
+ * Build a complete `RawTheme` from the interview answers. Pure — no filesystem, no prompting, no
143
+ * randomness — so the same answers always produce the same theme.
144
+ */
145
+ export declare function scaffoldTheme(answers: ScaffoldAnswers): ScaffoldResult;
146
+ export {};