create-forte-ui 1.0.0-alpha.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 ADDED
@@ -0,0 +1,46 @@
1
+ # create-forte-ui
2
+
3
+ Scaffold a brand-new app wired up with [forte-ui](https://forte-ui.com) — Vite
4
+ or Next.js, with or without Tailwind, themed from your answers.
5
+
6
+ ```bash
7
+ pnpm create forte-ui my-app
8
+ npm create forte-ui@latest my-app
9
+ ```
10
+
11
+ Four questions get you to a running app (name, framework, Tailwind, accent
12
+ color); everything else — secondary color, neutral tint, radius, density,
13
+ motion, fonts — hides behind one "customize further?" gate. Every prompt has a
14
+ flag twin, so the [Theme Studio](https://forte-ui.com/theme/) can hand you a
15
+ complete command line and `--yes` runs without a single question:
16
+
17
+ ```bash
18
+ pnpm create forte-ui my-app --seed "#e11d48" --radius pill --font-sans "Inter" --yes
19
+ ```
20
+
21
+ Run `create-forte-ui --help` for the full flag list.
22
+
23
+ ## How it works
24
+
25
+ The framework scaffold is not ours: `create-next-app` / `create-vite` run
26
+ non-interactively and stay current upstream. This CLI applies only the
27
+ forte-ui overlay on top — the same steps as the
28
+ [getting-started guides](https://forte-ui.com/getting-started/nextjs/), which
29
+ are the spec: a scaffolded app should diff against the walkthrough and show
30
+ only your answers.
31
+
32
+ Answers you skip write **nothing** — no restated defaults, no attributes for
33
+ default presets — so the app keeps following the library when its defaults
34
+ are tuned.
35
+
36
+ ## Maintaining
37
+
38
+ - The starter files live in `src/templates.ts`; when a guide step changes,
39
+ change the matching builder in the same commit.
40
+ - The font catalogue (`src/fonts.ts`) and colour maths (`src/color.ts`) are
41
+ the modules of record — the docs' Theme Studio re-exports them from this
42
+ package.
43
+ - `pnpm --filter create-forte-ui smoke` scaffolds and builds all four
44
+ framework × Tailwind paths against the workspace library (network, takes
45
+ minutes). Run it before releasing this package or after editing a guide's
46
+ setup steps.
package/dist/args.d.ts ADDED
@@ -0,0 +1,19 @@
1
+ import { type ThemeAnswers } from "./theme.js";
2
+ import { type PackageManager } from "./scaffold.js";
3
+ import type { Framework } from "./overlay.js";
4
+ export type CliOptions = {
5
+ name?: string;
6
+ framework?: Framework;
7
+ tailwind?: boolean;
8
+ pm?: PackageManager;
9
+ yes: boolean;
10
+ install: boolean;
11
+ help: boolean;
12
+ version: boolean;
13
+ /** Only the keys given as flags — presence is what suppresses the prompt. */
14
+ answers: Partial<ThemeAnswers>;
15
+ };
16
+ export declare class UsageError extends Error {
17
+ }
18
+ export declare function parseCliArgs(argv: string[]): CliOptions;
19
+ export declare const HELP = "create-forte-ui \u2014 scaffold a new app wired up with forte-ui\n\nUsage\n pnpm create forte-ui [name] [flags]\n npm create forte-ui@latest [name] -- [flags]\n\nEvery flag has a prompt twin; a passed flag suppresses its prompt. With no\nflags you get the questionnaire, with --yes you get a Next.js + Tailwind app\non the library's default theme.\n\nProject\n [name] project directory (prompted if omitted)\n -f, --framework next | vite\n --tailwind wire the Tailwind v4 bridge (default yes)\n --no-tailwind plain CSS setup\n --pm npm | pnpm | yarn | bun (default: whoever invoked us)\n --no-install write files only; skip installing dependencies\n -y, --yes accept the defaults for everything not passed\n\nTheme \u2014 every skipped value keeps the library default and writes NOTHING,\nso the app keeps following the library when defaults are tuned.\n --seed, --accent accent seed, hex (\"#6d43d4\")\n --secondary secondary seed, hex\n --tint neutral tint, 0 (pure grey) to 1 (default)\n --radius none | soft | pill\n --density compact | spacious\n --motion system (default) | reduce | full\n \"full\" overrides the OS reduced-motion preference\n for everyone \u2014 prefer leaving it unset.\n --font-sans a catalogue name (\"Inter\", \"DM Sans\", ...)\n --font-mono a catalogue name (\"JetBrains Mono\", ...)\n\nDesign the theme visually instead: https://forte-ui.com/theme\n";
package/dist/args.js ADDED
@@ -0,0 +1,146 @@
1
+ /* Flag parsing. Every prompt has a flag twin — that is what lets the Theme
2
+ * Studio export a ready-made command line, and what makes the CI smoke runs
3
+ * possible at all — and the flag names are the studio's config keys, kebab-
4
+ * cased, so the two stay alignable by inspection.
5
+ *
6
+ * Values are validated HERE, before anything runs: the library's own failure
7
+ * mode for a bad custom property is silence at computed-value time, and the
8
+ * one thing a scaffolder must never do is bake that silence into a fresh
9
+ * project.
10
+ */
11
+ import { parseArgs } from "node:util";
12
+ import { SANS_FONTS, MONO_FONTS } from "./fonts.js";
13
+ import { hexToOklch } from "./color.js";
14
+ import { RADIUS, DENSITY, MOTION } from "./theme.js";
15
+ import { PACKAGE_MANAGERS } from "./scaffold.js";
16
+ export class UsageError extends Error {
17
+ }
18
+ function normalizeHex(flag, value) {
19
+ const hex = value.startsWith("#") ? value : `#${value}`;
20
+ if (!hexToOklch(hex)) {
21
+ throw new UsageError(`--${flag} expects a hex colour like "#6d43d4", got "${value}"`);
22
+ }
23
+ return hex.toLowerCase();
24
+ }
25
+ function oneOf(flag, value, options) {
26
+ const v = value.toLowerCase();
27
+ if (!options.includes(v)) {
28
+ throw new UsageError(`--${flag} must be one of ${options.join(", ")} — got "${value}"`);
29
+ }
30
+ return v;
31
+ }
32
+ function fontByName(flag, value, list) {
33
+ const match = list.find((f) => f.name.toLowerCase() === value.toLowerCase());
34
+ if (!match) {
35
+ throw new UsageError(`--${flag}: "${value}" is not in the catalogue. Choices: ${list.map((f) => f.name).join(", ")}`);
36
+ }
37
+ return match.name;
38
+ }
39
+ export function parseCliArgs(argv) {
40
+ const { values, positionals } = parseArgs({
41
+ args: argv,
42
+ allowPositionals: true,
43
+ options: {
44
+ framework: { type: "string", short: "f" },
45
+ tailwind: { type: "boolean" },
46
+ "no-tailwind": { type: "boolean" },
47
+ seed: { type: "string" },
48
+ accent: { type: "string" },
49
+ secondary: { type: "string" },
50
+ tint: { type: "string" },
51
+ radius: { type: "string" },
52
+ density: { type: "string" },
53
+ motion: { type: "string" },
54
+ "font-sans": { type: "string" },
55
+ "font-mono": { type: "string" },
56
+ pm: { type: "string" },
57
+ yes: { type: "boolean", short: "y" },
58
+ "no-install": { type: "boolean" },
59
+ help: { type: "boolean", short: "h" },
60
+ version: { type: "boolean", short: "v" },
61
+ },
62
+ });
63
+ if (positionals.length > 1) {
64
+ throw new UsageError(`expected one project name, got: ${positionals.join(", ")}`);
65
+ }
66
+ if (values.tailwind && values["no-tailwind"]) {
67
+ throw new UsageError("--tailwind and --no-tailwind are mutually exclusive");
68
+ }
69
+ if (values.seed !== undefined && values.accent !== undefined) {
70
+ throw new UsageError("--seed and --accent are the same flag — pass one");
71
+ }
72
+ const answers = {};
73
+ const seed = values.seed ?? values.accent;
74
+ if (seed !== undefined)
75
+ answers.seed = normalizeHex(values.seed !== undefined ? "seed" : "accent", seed);
76
+ if (values.secondary !== undefined)
77
+ answers.secondary = normalizeHex("secondary", values.secondary);
78
+ if (values.tint !== undefined) {
79
+ const tint = Number(values.tint);
80
+ if (!Number.isFinite(tint) || tint < 0 || tint > 1) {
81
+ throw new UsageError(`--tint expects a number between 0 and 1, got "${values.tint}"`);
82
+ }
83
+ answers.tint = tint;
84
+ }
85
+ if (values.radius !== undefined)
86
+ answers.radius = oneOf("radius", values.radius, RADIUS);
87
+ if (values.density !== undefined)
88
+ answers.density = oneOf("density", values.density, DENSITY);
89
+ if (values.motion !== undefined) {
90
+ /* "system" is the honest name for the default from the outside: no
91
+ * attribute, so the OS reduced-motion preference stays in charge. */
92
+ answers.motion =
93
+ values.motion.toLowerCase() === "system" ? "default" : oneOf("motion", values.motion, MOTION);
94
+ }
95
+ if (values["font-sans"] !== undefined) {
96
+ answers.fontSans = fontByName("font-sans", values["font-sans"], SANS_FONTS);
97
+ }
98
+ if (values["font-mono"] !== undefined) {
99
+ answers.fontMono = fontByName("font-mono", values["font-mono"], MONO_FONTS);
100
+ }
101
+ return {
102
+ name: positionals[0],
103
+ framework: values.framework === undefined ? undefined : oneOf("framework", values.framework, ["next", "vite"]),
104
+ tailwind: values.tailwind ? true : values["no-tailwind"] ? false : undefined,
105
+ pm: values.pm === undefined ? undefined : oneOf("pm", values.pm, PACKAGE_MANAGERS),
106
+ yes: values.yes ?? false,
107
+ install: !(values["no-install"] ?? false),
108
+ help: values.help ?? false,
109
+ version: values.version ?? false,
110
+ answers,
111
+ };
112
+ }
113
+ export const HELP = `create-forte-ui — scaffold a new app wired up with forte-ui
114
+
115
+ Usage
116
+ pnpm create forte-ui [name] [flags]
117
+ npm create forte-ui@latest [name] -- [flags]
118
+
119
+ Every flag has a prompt twin; a passed flag suppresses its prompt. With no
120
+ flags you get the questionnaire, with --yes you get a Next.js + Tailwind app
121
+ on the library's default theme.
122
+
123
+ Project
124
+ [name] project directory (prompted if omitted)
125
+ -f, --framework next | vite
126
+ --tailwind wire the Tailwind v4 bridge (default yes)
127
+ --no-tailwind plain CSS setup
128
+ --pm npm | pnpm | yarn | bun (default: whoever invoked us)
129
+ --no-install write files only; skip installing dependencies
130
+ -y, --yes accept the defaults for everything not passed
131
+
132
+ Theme — every skipped value keeps the library default and writes NOTHING,
133
+ so the app keeps following the library when defaults are tuned.
134
+ --seed, --accent accent seed, hex ("#6d43d4")
135
+ --secondary secondary seed, hex
136
+ --tint neutral tint, 0 (pure grey) to 1 (default)
137
+ --radius none | soft | pill
138
+ --density compact | spacious
139
+ --motion system (default) | reduce | full
140
+ "full" overrides the OS reduced-motion preference
141
+ for everyone — prefer leaving it unset.
142
+ --font-sans a catalogue name ("Inter", "DM Sans", ...)
143
+ --font-mono a catalogue name ("JetBrains Mono", ...)
144
+
145
+ Design the theme visually instead: https://forte-ui.com/theme
146
+ `;
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Colour maths for the Theme Studio and the create-forte-ui CLI. This is the
3
+ * module of record — the docs' `lib/color.ts` re-exports it from here.
4
+ *
5
+ * Mirrors the model the library's CSS uses, so what the studio reports is what
6
+ * the browser will actually paint:
7
+ * - sRGB is naive-CLIPPED, not gamut-mapped, because that is what browsers do
8
+ * today. Reporting a gamut-mapped colour here would understate how far an
9
+ * out-of-range seed drifts.
10
+ * - Contrast is WCAG 2.x relative luminance, the ratio the success criteria
11
+ * are actually written against.
12
+ */
13
+ export type Oklch = {
14
+ l: number;
15
+ c: number;
16
+ h: number;
17
+ };
18
+ type Rgb = [number, number, number];
19
+ /** OKLCH -> linear sRGB, plus whether sRGB could represent it at all. */
20
+ export declare function oklchToLinear(o: Oklch): {
21
+ rgb: Rgb;
22
+ outOfGamut: boolean;
23
+ };
24
+ export declare function oklchToHex(o: Oklch): string;
25
+ export declare function hexToOklch(hex: string): Oklch | null;
26
+ export declare function contrast(a: Rgb, b: Rgb): number;
27
+ /**
28
+ * The exact readable text colour for a solid fill, chosen by measuring both
29
+ * candidates rather than by the CSS fallback's fitted lightness threshold.
30
+ * Emitting this as a literal is what makes the studio's output correct in
31
+ * every browser, including those without contrast-color().
32
+ */
33
+ export declare function bestOnColor(seed: Oklch): {
34
+ color: "white" | "black";
35
+ ratio: number;
36
+ };
37
+ /** The envelope the library's contrast guarantees were verified across. */
38
+ export declare const ENVELOPE: {
39
+ lMin: number;
40
+ lMax: number;
41
+ cMin: number;
42
+ cMax: number;
43
+ };
44
+ export type SeedWarning = {
45
+ level: "warn" | "info";
46
+ message: string;
47
+ };
48
+ export declare function validateSeed(seed: Oklch): SeedWarning[];
49
+ export {};
package/dist/color.js ADDED
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Colour maths for the Theme Studio and the create-forte-ui CLI. This is the
3
+ * module of record — the docs' `lib/color.ts` re-exports it from here.
4
+ *
5
+ * Mirrors the model the library's CSS uses, so what the studio reports is what
6
+ * the browser will actually paint:
7
+ * - sRGB is naive-CLIPPED, not gamut-mapped, because that is what browsers do
8
+ * today. Reporting a gamut-mapped colour here would understate how far an
9
+ * out-of-range seed drifts.
10
+ * - Contrast is WCAG 2.x relative luminance, the ratio the success criteria
11
+ * are actually written against.
12
+ */
13
+ /** OKLCH -> linear sRGB, plus whether sRGB could represent it at all. */
14
+ export function oklchToLinear(o) {
15
+ const hr = (o.h * Math.PI) / 180;
16
+ const a = o.c * Math.cos(hr);
17
+ const b = o.c * Math.sin(hr);
18
+ const l3 = (o.l + 0.3963377774 * a + 0.2158037573 * b) ** 3;
19
+ const m3 = (o.l - 0.1055613458 * a - 0.0638541728 * b) ** 3;
20
+ const s3 = (o.l - 0.0894841775 * a - 1.2914855480 * b) ** 3;
21
+ const raw = [
22
+ +4.0767416621 * l3 - 3.3077115913 * m3 + 0.2309699292 * s3,
23
+ -1.2684380046 * l3 + 2.6097574011 * m3 - 0.3413193965 * s3,
24
+ -0.0041960863 * l3 - 0.7034186147 * m3 + 1.7076147010 * s3,
25
+ ];
26
+ return {
27
+ rgb: raw.map((v) => Math.min(1, Math.max(0, v))),
28
+ outOfGamut: raw.some((v) => v < -1e-4 || v > 1 + 1e-4),
29
+ };
30
+ }
31
+ const encode = (v) => Math.round((v <= 0.0031308 ? 12.92 * v : 1.055 * Math.pow(v, 1 / 2.4) - 0.055) * 255);
32
+ export function oklchToHex(o) {
33
+ const { rgb } = oklchToLinear(o);
34
+ return "#" + rgb.map((v) => encode(v).toString(16).padStart(2, "0")).join("");
35
+ }
36
+ function srgbToLinear(v) {
37
+ return v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
38
+ }
39
+ export function hexToOklch(hex) {
40
+ const m = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(hex.trim());
41
+ if (!m)
42
+ return null;
43
+ let s = m[1];
44
+ if (s.length === 3)
45
+ s = s.split("").map((ch) => ch + ch).join("");
46
+ const [r, g, b] = [0, 2, 4].map((i) => srgbToLinear(parseInt(s.slice(i, i + 2), 16) / 255));
47
+ const l_ = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
48
+ const m_ = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
49
+ const s_ = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
50
+ const L = 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_;
51
+ const A = 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_;
52
+ const B = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_;
53
+ const h = (Math.atan2(B, A) * 180) / Math.PI;
54
+ return { l: L, c: Math.hypot(A, B), h: h < 0 ? h + 360 : h };
55
+ }
56
+ const luminance = ([r, g, b]) => 0.2126 * r + 0.7152 * g + 0.0722 * b;
57
+ export function contrast(a, b) {
58
+ const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
59
+ return (hi + 0.05) / (lo + 0.05);
60
+ }
61
+ const WHITE = [1, 1, 1];
62
+ const BLACK = [0, 0, 0];
63
+ /**
64
+ * The exact readable text colour for a solid fill, chosen by measuring both
65
+ * candidates rather than by the CSS fallback's fitted lightness threshold.
66
+ * Emitting this as a literal is what makes the studio's output correct in
67
+ * every browser, including those without contrast-color().
68
+ */
69
+ export function bestOnColor(seed) {
70
+ const { rgb } = oklchToLinear(seed);
71
+ const w = contrast(WHITE, rgb);
72
+ const b = contrast(BLACK, rgb);
73
+ return w >= b ? { color: "white", ratio: w } : { color: "black", ratio: b };
74
+ }
75
+ /** The envelope the library's contrast guarantees were verified across. */
76
+ export const ENVELOPE = { lMin: 0.45, lMax: 0.9, cMin: 0.02, cMax: 0.3 };
77
+ export function validateSeed(seed) {
78
+ const out = [];
79
+ const { outOfGamut } = oklchToLinear(seed);
80
+ if (outOfGamut) {
81
+ out.push({
82
+ level: "warn",
83
+ message: "Outside the sRGB gamut. Browsers clip rather than gamut-map, which shifts the painted lightness — the 9/10 hover step can visually collapse, and the colour will differ between sRGB and P3 displays.",
84
+ });
85
+ }
86
+ if (seed.l < ENVELOPE.lMin) {
87
+ out.push({ level: "warn", message: `Very dark (L ${seed.l.toFixed(2)}). Steps 9 and 12 converge, so solid fills and high-contrast text stop being distinguishable.` });
88
+ }
89
+ if (seed.l > ENVELOPE.lMax) {
90
+ out.push({ level: "warn", message: `Very light (L ${seed.l.toFixed(2)}). The subtle background steps have nowhere left to go and flatten against the page.` });
91
+ }
92
+ if (seed.c < ENVELOPE.cMin) {
93
+ out.push({ level: "info", message: "Nearly achromatic — the accent ramp will be hard to tell apart from the neutrals." });
94
+ }
95
+ return out;
96
+ }
@@ -0,0 +1,23 @@
1
+ export type FontOption = {
2
+ /** Display name, and the value stored in the studio config. */
3
+ name: string;
4
+ /** `font-family` value, fallback stack included. `null` means "System" —
5
+ * no override at all, so the token keeps its shipped default. */
6
+ stack: string | null;
7
+ /** Full stylesheet for actually using the font. `null` for System. */
8
+ css: string | null;
9
+ /** Subsetted stylesheet carrying only the glyphs of the family's own name —
10
+ * a few KB, loaded when the picker opens so each menu item can render in
11
+ * its own face without pulling ten full families. */
12
+ preview: string | null;
13
+ };
14
+ export declare const SANS_FALLBACK = "ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, sans-serif";
15
+ export declare const MONO_FALLBACK = "ui-monospace, SFMono-Regular, \"SF Mono\", Menlo, Consolas, \"Liberation Mono\", monospace";
16
+ export declare const SANS_FONTS: readonly FontOption[];
17
+ export declare const MONO_FONTS: readonly FontOption[];
18
+ export declare function findFont(list: readonly FontOption[], name: string): FontOption;
19
+ /** Append a stylesheet `<link>` once. Loaded fonts are deliberately never
20
+ * removed on switch-away — the files are cached, and keeping the link means
21
+ * flipping back to a font shows it instantly instead of re-flashing the
22
+ * fallback. */
23
+ export declare function ensureFontLink(href: string): void;
package/dist/fonts.js ADDED
@@ -0,0 +1,83 @@
1
+ /* The font catalogue — the ten most-used Google Fonts of each kind, plus the
2
+ * library's own default stack as "System". This is the module of record: the
3
+ * docs' Theme Studio re-exports it from here, so the CLI's font prompt and
4
+ * the studio's pickers cannot drift apart. Everything a consumer of
5
+ * an entry needs (the CSS stack, the full stylesheet URL, the tiny preview
6
+ * URL) is precomputed here, so the component never builds a URL of its own
7
+ * and the pre-paint replay in `layout.tsx` can trust what was stored.
8
+ *
9
+ * The axes in `axes` are per-family on purpose: css2 rejects a range a static
10
+ * family cannot serve (`Lato:wght@400..700` is a 400), so variable families
11
+ * ask for the 400..700 range and static ones list the weights they actually
12
+ * have. The tokens only ever use 400/500/600/700; a family missing a step
13
+ * (Lato, Space Mono…) lets the browser synthesise it, which is fine for a
14
+ * preview and stated in the copied CSS by the import carrying the real list. */
15
+ /* The shipped defaults from `tokens.css`, verbatim. They double as the
16
+ * fallback tail behind every Google family, so a font that has not arrived
17
+ * yet degrades to exactly the look the studio started with. */
18
+ export const SANS_FALLBACK = 'ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif';
19
+ export const MONO_FALLBACK = 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace';
20
+ const GOOGLE = "https://fonts.googleapis.com/css2";
21
+ function font(name, axes, fallback) {
22
+ const family = name.replaceAll(" ", "+");
23
+ return {
24
+ name,
25
+ stack: `"${name}", ${fallback}`,
26
+ css: `${GOOGLE}?family=${family}:${axes}&display=swap`,
27
+ preview: `${GOOGLE}?family=${family}&text=${encodeURIComponent(name)}&display=swap`,
28
+ };
29
+ }
30
+ const system = { name: "System", stack: null, css: null, preview: null };
31
+ export const SANS_FONTS = [
32
+ system,
33
+ font("Inter", "wght@400..700", SANS_FALLBACK),
34
+ font("Roboto", "wght@400;500;700", SANS_FALLBACK),
35
+ font("Open Sans", "wght@400..700", SANS_FALLBACK),
36
+ font("Lato", "wght@400;700", SANS_FALLBACK),
37
+ font("Montserrat", "wght@400..700", SANS_FALLBACK),
38
+ font("Poppins", "wght@400;500;600;700", SANS_FALLBACK),
39
+ font("Nunito", "wght@400..700", SANS_FALLBACK),
40
+ font("Source Sans 3", "wght@400..700", SANS_FALLBACK),
41
+ font("Work Sans", "wght@400..700", SANS_FALLBACK),
42
+ font("DM Sans", "wght@400..700", SANS_FALLBACK),
43
+ ];
44
+ export const MONO_FONTS = [
45
+ system,
46
+ font("JetBrains Mono", "wght@400..700", MONO_FALLBACK),
47
+ font("Fira Code", "wght@400..700", MONO_FALLBACK),
48
+ font("Source Code Pro", "wght@400..700", MONO_FALLBACK),
49
+ font("IBM Plex Mono", "wght@400;500;600;700", MONO_FALLBACK),
50
+ font("Roboto Mono", "wght@400..700", MONO_FALLBACK),
51
+ font("Geist Mono", "wght@400..700", MONO_FALLBACK),
52
+ font("Space Mono", "wght@400;700", MONO_FALLBACK),
53
+ font("Ubuntu Mono", "wght@400;700", MONO_FALLBACK),
54
+ font("Inconsolata", "wght@400..700", MONO_FALLBACK),
55
+ font("Courier Prime", "wght@400;700", MONO_FALLBACK),
56
+ ];
57
+ export function findFont(list, name) {
58
+ // Falls back to System rather than throwing: the name may come from
59
+ // user-editable storage, and readStored() has already validated it — this
60
+ // is belt-and-braces for the one caller that builds CSS from it.
61
+ return list.find((f) => f.name === name) ?? list[0];
62
+ }
63
+ /** Append a stylesheet `<link>` once. Loaded fonts are deliberately never
64
+ * removed on switch-away — the files are cached, and keeping the link means
65
+ * flipping back to a font shows it instantly instead of re-flashing the
66
+ * fallback. */
67
+ export function ensureFontLink(href) {
68
+ if (document.head.querySelector(`link[href="${href}"]`))
69
+ return;
70
+ // One preconnect ahead of the first font request; gstatic is where the
71
+ // actual woff2 files live and the stylesheet origin connects itself.
72
+ if (!document.head.querySelector('link[rel="preconnect"][href="https://fonts.gstatic.com"]')) {
73
+ const pre = document.createElement("link");
74
+ pre.rel = "preconnect";
75
+ pre.href = "https://fonts.gstatic.com";
76
+ pre.crossOrigin = "anonymous";
77
+ document.head.appendChild(pre);
78
+ }
79
+ const link = document.createElement("link");
80
+ link.rel = "stylesheet";
81
+ link.href = href;
82
+ document.head.appendChild(link);
83
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,135 @@
1
+ #!/usr/bin/env node
2
+ /* create-forte-ui: scaffold a brand-new app wired up with forte-ui.
3
+ *
4
+ * The division of labour is the whole design: `create-next-app` / `create-vite`
5
+ * own the framework scaffold (run non-interactively, kept current upstream),
6
+ * and this CLI owns only the forte-ui overlay — the getting-started guides'
7
+ * steps, mechanized. The guides are the spec; when a step changes there,
8
+ * change `templates.ts` with it.
9
+ */
10
+ import fs from "node:fs";
11
+ import path from "node:path";
12
+ import { fileURLToPath } from "node:url";
13
+ import * as p from "@clack/prompts";
14
+ import pc from "picocolors";
15
+ import { parseCliArgs, UsageError, HELP } from "./args.js";
16
+ import { collectPlan } from "./prompts.js";
17
+ import { hexToOklch, validateSeed } from "./color.js";
18
+ import { applyOverlay, dependenciesFor, recordDependencies } from "./overlay.js";
19
+ import { detectPackageManager, runScaffolder, addDependencies, devCommand, installCommand, } from "./scaffold.js";
20
+ function ownVersion() {
21
+ const pkgPath = path.join(fileURLToPath(import.meta.url), "../../package.json");
22
+ return JSON.parse(fs.readFileSync(pkgPath, "utf8")).version;
23
+ }
24
+ /** The library's contrast guarantees were measured across a seed envelope;
25
+ * a seed outside it still works, but the studio would have warned — so the
26
+ * CLI does too, after the answers are settled and before anything runs. */
27
+ function warnAboutSeeds(plan) {
28
+ for (const [label, hex] of [
29
+ ["accent", plan.answers.seed],
30
+ ["secondary", plan.answers.secondary],
31
+ ]) {
32
+ if (!hex)
33
+ continue;
34
+ const oklch = hexToOklch(hex);
35
+ if (!oklch)
36
+ continue;
37
+ for (const warning of validateSeed(oklch)) {
38
+ p.log[warning.level === "warn" ? "warn" : "info"](`${label} ${hex}: ${warning.message}`);
39
+ }
40
+ }
41
+ }
42
+ function scaffold(plan, pm) {
43
+ if (plan.framework === "vite") {
44
+ return runScaffolder(pm, "create-vite@latest", [plan.name, "--template", "react-ts"], process.cwd());
45
+ }
46
+ return runScaffolder(pm, "create-next-app@latest", [
47
+ plan.name,
48
+ "--typescript",
49
+ "--app",
50
+ plan.tailwind ? "--tailwind" : "--no-tailwind",
51
+ "--no-src-dir",
52
+ "--import-alias",
53
+ "@/*",
54
+ /* One install at the end (ours, with the library included) instead of
55
+ * two; --yes answers whatever prompts remain in future versions. */
56
+ "--skip-install",
57
+ "--yes",
58
+ ], process.cwd());
59
+ }
60
+ async function main() {
61
+ let opts;
62
+ try {
63
+ opts = parseCliArgs(process.argv.slice(2));
64
+ }
65
+ catch (error) {
66
+ if (error instanceof UsageError || error instanceof TypeError) {
67
+ console.error(pc.red(`create-forte-ui: ${error.message}`));
68
+ console.error(`Run with ${pc.bold("--help")} for usage.`);
69
+ process.exit(1);
70
+ }
71
+ throw error;
72
+ }
73
+ if (opts.help) {
74
+ console.log(HELP);
75
+ return;
76
+ }
77
+ if (opts.version) {
78
+ console.log(ownVersion());
79
+ return;
80
+ }
81
+ p.intro(pc.bold("create-forte-ui"));
82
+ let plan;
83
+ try {
84
+ plan = await collectPlan(opts);
85
+ }
86
+ catch (error) {
87
+ if (error instanceof UsageError) {
88
+ p.cancel(error.message);
89
+ process.exit(1);
90
+ }
91
+ throw error;
92
+ }
93
+ warnAboutSeeds(plan);
94
+ const pm = opts.pm ?? detectPackageManager();
95
+ const upstream = plan.framework === "vite" ? "create-vite" : "create-next-app";
96
+ p.log.step(`Scaffolding with ${upstream}…`);
97
+ if (!scaffold(plan, pm)) {
98
+ p.cancel(`${upstream} failed — see its output above. Nothing else was written.`);
99
+ process.exit(1);
100
+ }
101
+ let written;
102
+ try {
103
+ written = applyOverlay(plan);
104
+ }
105
+ catch (error) {
106
+ p.cancel(`${error.message}\n` +
107
+ `The ${upstream} scaffold in ./${plan.name} is intact — ` +
108
+ `finish by hand with the guide: https://forte-ui.com/getting-started/${plan.framework === "vite" ? "vite" : "nextjs"}/`);
109
+ process.exit(1);
110
+ }
111
+ p.log.success(`Wired up forte-ui: ${written.join(", ")}`);
112
+ const deps = dependenciesFor(plan);
113
+ if (opts.install) {
114
+ p.log.step(`Installing ${deps.join(", ")} with ${pm}…`);
115
+ if (!addDependencies(pm, deps, plan.dir)) {
116
+ p.cancel(`${pm} failed to install — run ${pc.bold(`${installCommand(pm)}`)} in ./${plan.name} yourself.`);
117
+ process.exit(1);
118
+ }
119
+ }
120
+ else {
121
+ recordDependencies(plan.dir, deps);
122
+ p.log.info(`Skipped install; added ${deps.join(", ")} to package.json.`);
123
+ }
124
+ const steps = [
125
+ `cd ${plan.name}`,
126
+ ...(opts.install ? [] : [installCommand(pm)]),
127
+ devCommand(pm),
128
+ ];
129
+ p.note(steps.join("\n"), "Next");
130
+ p.outro(`Docs: ${pc.underline("https://forte-ui.com")} · design the theme visually: ${pc.underline("https://forte-ui.com/theme/")}`);
131
+ }
132
+ main().catch((error) => {
133
+ console.error(pc.red(`create-forte-ui: ${error instanceof Error ? error.message : String(error)}`));
134
+ process.exit(1);
135
+ });
@@ -0,0 +1,16 @@
1
+ import { type ThemeAnswers } from "./theme.js";
2
+ export type Framework = "next" | "vite";
3
+ export type ProjectPlan = {
4
+ name: string;
5
+ dir: string;
6
+ framework: Framework;
7
+ tailwind: boolean;
8
+ answers: ThemeAnswers;
9
+ };
10
+ /** Returns the files it wrote (project-relative), for the summary. */
11
+ export declare function applyOverlay(plan: ProjectPlan): string[];
12
+ /** The `--no-install` fallback: record the dependencies so the user's own
13
+ * install resolves them. "latest" is a dist-tag, which every manager
14
+ * accepts and replaces with a real range on first install. */
15
+ export declare function recordDependencies(dir: string, deps: string[]): void;
16
+ export declare function dependenciesFor(plan: ProjectPlan): string[];