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 +46 -0
- package/dist/args.d.ts +19 -0
- package/dist/args.js +146 -0
- package/dist/color.d.ts +49 -0
- package/dist/color.js +96 -0
- package/dist/fonts.d.ts +23 -0
- package/dist/fonts.js +83 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +135 -0
- package/dist/overlay.d.ts +16 -0
- package/dist/overlay.js +107 -0
- package/dist/prompts.d.ts +4 -0
- package/dist/prompts.js +166 -0
- package/dist/scaffold.d.ts +17 -0
- package/dist/scaffold.js +55 -0
- package/dist/templates.d.ts +8 -0
- package/dist/templates.js +172 -0
- package/dist/theme.d.ts +46 -0
- package/dist/theme.js +124 -0
- package/package.json +45 -0
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
|
+
`;
|
package/dist/color.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/fonts.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/index.d.ts
ADDED
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[];
|