@zenginui/registry 0.1.0
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 +101 -0
- package/dist/brand.d.ts +61 -0
- package/dist/brand.js +292 -0
- package/dist/build.d.ts +14 -0
- package/dist/build.js +344 -0
- package/dist/color.d.ts +24 -0
- package/dist/color.js +88 -0
- package/dist/create.d.ts +55 -0
- package/dist/create.js +437 -0
- package/dist/fonts.d.ts +42 -0
- package/dist/fonts.js +132 -0
- package/dist/html.d.ts +16 -0
- package/dist/html.js +63 -0
- package/dist/icons.d.ts +43 -0
- package/dist/icons.js +197 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +14 -0
- package/dist/install.d.ts +34 -0
- package/dist/install.js +106 -0
- package/dist/load.d.ts +11 -0
- package/dist/load.js +70 -0
- package/dist/resolve.d.ts +7 -0
- package/dist/resolve.js +31 -0
- package/dist/schema.d.ts +71 -0
- package/dist/schema.js +13 -0
- package/dist/theme.d.ts +26 -0
- package/dist/theme.js +35 -0
- package/dist/tokens.d.ts +14 -0
- package/dist/tokens.js +44 -0
- package/dist/upgrade.d.ts +60 -0
- package/dist/upgrade.js +171 -0
- package/package.json +53 -0
package/README.md
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# @zenginui/registry
|
|
2
|
+
|
|
3
|
+
Components, templates and definitions as installable items, and the generator behind `zengin create` and `zengin add`. This is the shadcn model with the piece shadcn lacks: what gets copied into a project stays understood by the engine, so it can be checked, tracked and upgraded.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
zengin create acme --template marketing # a project that owns its components, checked before it prints
|
|
7
|
+
cd acme && npm install && npm run dev
|
|
8
|
+
zengin add dialog tooltip # more items from the registry
|
|
9
|
+
zengin tokens # zengin/tokens*.json to src/styles/generated/tokens.css
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## What a created project looks like
|
|
13
|
+
|
|
14
|
+
| Path | What |
|
|
15
|
+
| --- | --- |
|
|
16
|
+
| `src/components/ui/<name>/` | The component's TSX and CSS, copied in. The TSX opens with `/* zengin-owned Button, forked from @zenginui/ui@0.1.0 */`, which the engine and the rollup read. |
|
|
17
|
+
| `src/components/ui/index.ts` | The barrel. The engine treats `@/components/ui` as the system. |
|
|
18
|
+
| `src/lib/cx.ts` | The one shared helper. |
|
|
19
|
+
| `zengin/` | `tokens.json`, `tokens.dark.json`, `components.json`. The manifest entries arrive with each component, `export.from` already pointing at the alias. |
|
|
20
|
+
| `src/styles/` | `base.css`, `index.css` with one `@import` per component, `generated/tokens.css` built by `zengin tokens`. |
|
|
21
|
+
| `src/theme/brand.css` | Token overrides. The one place literals are allowed. |
|
|
22
|
+
| `zengin.config.yaml` | `system.package: "@/components/ui"`, `definitions: ./zengin`, `ownership: src/components/ui/**`, every rule at error. |
|
|
23
|
+
| `.mcp.json`, `.claude/settings.json` | The MCP server and the edit hook. |
|
|
24
|
+
| `.storybook/`, `stories/` | Storybook with the theme toolbar and one story file per component, reading the manifest. `--no-storybook` leaves them out. |
|
|
25
|
+
|
|
26
|
+
The generator runs the engine on the result before it returns. A fresh project reports zero violations, or the registry is wrong.
|
|
27
|
+
|
|
28
|
+
## The registry
|
|
29
|
+
|
|
30
|
+
A directory of static JSON, so it can be served from anywhere: `index.json` lists every item without file contents; `items/<name>.json` carries the files. The marketing site serves the public one at `https://zengin-marketing-site.vercel.app/r`, rebuilt on every deploy. `ZENGIN_REGISTRY` or `--registry <dir|url>` points the CLI elsewhere, which is how a private registry of paid templates works later: same shape, a different URL, a token on the request.
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
zengin registry build --out public/r # from a Zengin repository checkout
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Items are built, never hand-written. Components come from `packages/ui/src/components`, their stories from `packages/ui/stories`, their manifest entries from `packages/ui/zengin/components.json`. Templates come from `examples/` (every template is a real app there, recorded in the item's `source` field, which the marketing site builds as the template's live preview) with `@zenginui/ui` imports rewritten to `@/components/ui`. The `default` theme is generated from the token files rather than read from a file, so applying it over another brand resets every token. The one test that matters: every template creates a project the engine finds clean.
|
|
37
|
+
|
|
38
|
+
| Item type | Examples | Installs |
|
|
39
|
+
| --- | --- | --- |
|
|
40
|
+
| `component` | `button`, `dialog`, `tabs` | TSX and CSS under `src/components/ui`, the story, the manifest entry, the barrel export, the stylesheet import |
|
|
41
|
+
| `template` | `blank`, `marketing`, `review`, `saas`, `chat`, `auth`, `docs`, `storefront` | App files under `src/` plus `index.html`; depends on the components it uses |
|
|
42
|
+
| `lib` | `lib-cx`, `lib-chart`, `lib-markdown` | `src/lib/<name>.ts`; every file in `packages/ui/src/internal` is one, named `lib-<file>` so it cannot collide with a component, and components depend on the ones they import |
|
|
43
|
+
| `definitions` | `foundation` | `zengin/tokens.json`, `zengin/tokens.dark.json`, `src/styles/base.css` |
|
|
44
|
+
| `theme` | `brutal`, `default`, `meadow`, `plex`, `spec-sheet`, `zengin` | `src/theme/brand.css`, replacing the current one, plus the theme's fonts link in `index.html` |
|
|
45
|
+
| `icons` | `icons-lucide`, `icons-tabler`, `icons-phosphor`, `icons-heroicons`, `icons-feather`, `icons-radix`, `icons-material`, `icons-bootstrap` | `src/lib/icons.tsx` rewritten so the icon vocabulary draws from that react-icons set, plus the `react-icons` dependency. The names stay; every `<Icon.Name />` follows |
|
|
46
|
+
| `fonts` | `fonts-inter`, `fonts-plex`, `fonts-archivo`, `fonts-geist`, `fonts-space`, `fonts-manrope`, `fonts-fraunces`, `fonts-playfair`, `fonts-dm`, `fonts-brutal` | No files: a pairing of three roles (headlines, text, code), each a Google Fonts family at its weights. `zengin fonts <name>` rewrites the three font tokens in `src/theme/brand.css` and the fonts link; nothing else changes |
|
|
47
|
+
|
|
48
|
+
## Themes and brands
|
|
49
|
+
|
|
50
|
+
A theme is a brand file in the registry (`packages/ui/themes/<name>/brand.css` with a `theme.json` naming its fonts). `zengin theme <name>` swaps it in; `zengin create --theme <name>` applies it at creation. A theme is the whole brand: applying one replaces the file, so the previous brand is gone, which is what a swap means.
|
|
51
|
+
|
|
52
|
+
`zengin brand` generates a brand file instead of picking one: `derivePalette(hex)` builds both schemes from one color in OKLCH and pushes every component pairing to WCAG AA; `renderBrandCss` writes it with the fonts and radii; `brandProject` adds the logo, favicon, wordmark and `index.html` patches. Both are exported for programmatic use.
|
|
53
|
+
|
|
54
|
+
## Fonts
|
|
55
|
+
|
|
56
|
+
A pairing is three roles, `display`, `sans` and `mono`, each a Google Fonts family at the weights the components use, with a serif flag that picks the fallback stack. They live in `packages/ui/fonts/<name>/fonts.json` and reach the registry as `fonts-<name>`; people say the bare name.
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
zengin fonts # the pairings, with their three families
|
|
60
|
+
zengin fonts fraunces # --font-display, --font-sans, --font-mono in brand.css, and the fonts link in index.html
|
|
61
|
+
zengin fonts geist --self-host # the woff2 files into public/fonts, @font-face in src/theme/fonts.css, no Google Fonts at runtime
|
|
62
|
+
zengin brand --name Acme --fonts plex # a pairing name in place of the three --font-* families
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
A theme carries its own fonts; a pairing applied after it wins for type only, the palette, radii and shadows stay. On the marketing site the catalog's font picker passes `?fonts=<name>` to every preview, which applies the pairing the same way `zengin fonts` does.
|
|
66
|
+
|
|
67
|
+
## Icons
|
|
68
|
+
|
|
69
|
+
Zengin UI draws by name. `Icon.Search`, `Icon.Close`, `Icon.ChevronDown` and sixty more are one component each, exported from `@zenginui/ui` and, in a project, from `src/lib/icons.tsx` (the `lib-icons` item, which every component that draws an icon depends on). The default drawings are Zengin UI's own: 16 units, a 1.75 stroke, `currentColor`, sized by font-size. Components use the same names for their chevrons, checks and close buttons, so a set change reaches inside them.
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
zengin icons # lucide, tabler, phosphor, heroicons, feather, radix, material, bootstrap
|
|
73
|
+
zengin icons tabler # src/lib/icons.tsx rewritten: the same Icon object, drawn by react-icons/tb; react-icons added to package.json
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
The manifest carries an `Icon` entry that shadows `react-icons/*`, `lucide-react`, `@tabler/icons-react`, `@phosphor-icons/react`, `@heroicons/react/*` and `@radix-ui/react-icons`: an app file importing a glyph from any of them is a `component-substitution` violation with the fix `import { Icon } from "@/lib/icons"`. `src/lib/**` is owned, so the rewritten file itself is exempt. On the marketing site the catalog's icon picker passes `?icons=<set>` to every preview; the preview loads the set on demand and swaps the drawings at runtime with `setIconSet`, which a project never needs.
|
|
77
|
+
|
|
78
|
+
A set maps every vocabulary name to a real export of its react-icons module; the registry's tests import each module and check every name. Radix has no glyph for a few names (folder, database, terminal) and reuses the closest one; the others map one to one.
|
|
79
|
+
|
|
80
|
+
## Upgrading owned components
|
|
81
|
+
|
|
82
|
+
`zengin upgrade` compares three things per owned file: the hash in its pragma (what was copied), the file now (what the project did), and the registry's file (what the system did). Upstream-only changes are taken with `--write`; local-only changes are left alone; both changed is a conflict with a diff in the report and `--force` to take upstream. The pinned version in `zengin.config.yaml` moves when nothing is held. Libs under `src/lib` follow the same comparison. Stories are the project's from the start and are never touched.
|
|
83
|
+
|
|
84
|
+
## Next.js
|
|
85
|
+
|
|
86
|
+
`zengin create acme --framework next` writes the App Router under `src/app` instead of `index.html`, `src/main.tsx` and a Vite config: `layout.tsx` carries the template's stylesheet imports, its title as metadata and its fonts link in `<head>`; `page.tsx` mounts the template's `App` client-side with `next/dynamic` and `ssr: false`, since the templates read `window` and `document` in hooks the way an SPA does. `zengin theme`, `zengin fonts` and `zengin brand` patch the layout's head exactly as they patch `index.html`. Everything else, the components, the definitions, the engine, the hook and the MCP server, is the same project.
|
|
87
|
+
|
|
88
|
+
## Programmatic use
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
import { buildRegistry, createProject, installItems, openRegistry, resolveItems } from "@zenginui/registry";
|
|
92
|
+
|
|
93
|
+
const source = openRegistry(); // the public registry, or ZENGIN_REGISTRY
|
|
94
|
+
const result = await createProject({ dir: "acme", template: "review", source });
|
|
95
|
+
const items = await resolveItems(source, ["dialog"]);
|
|
96
|
+
installItems({ projectDir: "acme", items, version: (await source.index()).version });
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Before the first npm release
|
|
100
|
+
|
|
101
|
+
Created projects depend on `@zenginui/cli`, `@zenginui/mcp` and `@zenginui/hook` from npm. Until those are published, `zengin create --local <path to this repository>` links them from the checkout with `link:`, which pnpm resolves. That is how the generator is tested.
|
package/dist/brand.d.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
export type BrandRadius = "sharp" | "soft" | "round";
|
|
2
|
+
export interface BrandOptions {
|
|
3
|
+
projectDir: string;
|
|
4
|
+
/** The product's name: the title, the wordmark, the favicon initial. */
|
|
5
|
+
name: string;
|
|
6
|
+
/** Path to a logo. An SVG also supplies the primary color when none is given. */
|
|
7
|
+
logo?: string;
|
|
8
|
+
/** Primary color as hex. Wins over the logo. */
|
|
9
|
+
primary?: string;
|
|
10
|
+
/** Google Fonts family names. */
|
|
11
|
+
fontDisplay?: string;
|
|
12
|
+
fontSans?: string;
|
|
13
|
+
fontMono?: string;
|
|
14
|
+
radius?: BrandRadius;
|
|
15
|
+
}
|
|
16
|
+
export interface Palette {
|
|
17
|
+
light: Record<string, string>;
|
|
18
|
+
dark: Record<string, string>;
|
|
19
|
+
}
|
|
20
|
+
export interface BrandResult {
|
|
21
|
+
name: string;
|
|
22
|
+
primary: string;
|
|
23
|
+
primarySource: "option" | "logo" | "system";
|
|
24
|
+
palette: Palette;
|
|
25
|
+
/** Project-relative paths written. */
|
|
26
|
+
files: string[];
|
|
27
|
+
/** Contrast pairs the palette guarantees, for the record. */
|
|
28
|
+
contrast: {
|
|
29
|
+
pair: string;
|
|
30
|
+
ratio: number;
|
|
31
|
+
}[];
|
|
32
|
+
warnings: string[];
|
|
33
|
+
/** The engine on the result. */
|
|
34
|
+
violations: number;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* The brand from one color: primary with its hover, active, soft, soft-foreground and on-primary; neutrals
|
|
38
|
+
* tinted toward the primary's hue; both schemes. Every pairing a component relies on is pushed until it
|
|
39
|
+
* meets WCAG AA, so a brand can be any hue without producing an unreadable button.
|
|
40
|
+
*/
|
|
41
|
+
export declare function derivePalette(primaryHex: string): Palette;
|
|
42
|
+
/** The pairings the components rely on, with the ratios the palette reached. */
|
|
43
|
+
export declare function paletteContrast(p: Palette): {
|
|
44
|
+
pair: string;
|
|
45
|
+
ratio: number;
|
|
46
|
+
}[];
|
|
47
|
+
export declare function renderBrandCss(opts: {
|
|
48
|
+
name: string;
|
|
49
|
+
palette: Palette;
|
|
50
|
+
fontDisplay?: string;
|
|
51
|
+
fontSans?: string;
|
|
52
|
+
fontMono?: string;
|
|
53
|
+
radius?: BrandRadius;
|
|
54
|
+
logo: boolean;
|
|
55
|
+
}): string;
|
|
56
|
+
/** The most saturated color an SVG paints with, or undefined for a monochrome or unreadable file. */
|
|
57
|
+
export declare function primaryFromSvg(svg: string): string | undefined;
|
|
58
|
+
/** The favicon when there is no logo: the name's initial on the primary, in the display face. */
|
|
59
|
+
export declare function faviconSvg(name: string, primary: string, onPrimary: string, radius: BrandRadius): string;
|
|
60
|
+
/** Applies a brand to a project: brand.css, brand.json, the logo and favicon, the wordmark, and index.html. */
|
|
61
|
+
export declare function brandProject(opts: BrandOptions): Promise<BrandResult>;
|
package/dist/brand.js
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { basename, dirname, extname, join, resolve } from "node:path";
|
|
3
|
+
import { createEngine, loadConfigFile, readProjectFiles, resolveConfig } from "@zenginui/engine";
|
|
4
|
+
import { contrast, hexToOklch, oklchToHex, pushForContrast } from "./color.js";
|
|
5
|
+
import { patchIndexHtml, fontsHref } from "./html.js";
|
|
6
|
+
import { LAYOUT } from "./schema.js";
|
|
7
|
+
/**
|
|
8
|
+
* The brand from one color: primary with its hover, active, soft, soft-foreground and on-primary; neutrals
|
|
9
|
+
* tinted toward the primary's hue; both schemes. Every pairing a component relies on is pushed until it
|
|
10
|
+
* meets WCAG AA, so a brand can be any hue without producing an unreadable button.
|
|
11
|
+
*/
|
|
12
|
+
export function derivePalette(primaryHex) {
|
|
13
|
+
const p = hexToOklch(primaryHex);
|
|
14
|
+
const hue = p.h;
|
|
15
|
+
const nc = p.c < 0.02 ? 0 : Math.min(0.018, p.c * 0.12); // neutrals lean toward the accent, faintly
|
|
16
|
+
const n = (l, c = nc) => oklchToHex({ l, c, h: hue });
|
|
17
|
+
const at = (l, c) => ({ l, c, h: hue });
|
|
18
|
+
// Light scheme.
|
|
19
|
+
const light = {};
|
|
20
|
+
const surface = "#FFFFFF";
|
|
21
|
+
light["--color-surface"] = surface;
|
|
22
|
+
light["--color-surface-raised"] = n(0.975, nc * 0.5);
|
|
23
|
+
light["--color-surface-sunken"] = n(0.95, nc * 0.7);
|
|
24
|
+
const text = n(0.22);
|
|
25
|
+
light["--color-surface-overlay"] = `${text}CC`;
|
|
26
|
+
light["--color-text"] = text;
|
|
27
|
+
light["--color-text-muted"] = pushForContrast(at(0.48, nc), surface, 4.5, "darker");
|
|
28
|
+
light["--color-text-subtle"] = pushForContrast(at(0.66, nc), surface, 3, "darker");
|
|
29
|
+
light["--color-border"] = n(0.9, nc * 0.6);
|
|
30
|
+
light["--color-border-strong"] = n(0.8, nc * 0.8);
|
|
31
|
+
const primary = pushForContrast(p, surface, 3, "darker");
|
|
32
|
+
const pl = hexToOklch(primary);
|
|
33
|
+
light["--color-primary"] = primary;
|
|
34
|
+
light["--color-primary-hover"] = oklchToHex({ ...pl, l: pl.l - 0.06 });
|
|
35
|
+
light["--color-primary-active"] = oklchToHex({ ...pl, l: pl.l - 0.12 });
|
|
36
|
+
const soft = oklchToHex(at(0.94, Math.min(0.06, p.c * 0.4)));
|
|
37
|
+
light["--color-primary-soft"] = soft;
|
|
38
|
+
light["--color-primary-soft-hover"] = oklchToHex(at(0.89, Math.min(0.08, p.c * 0.5)));
|
|
39
|
+
light["--color-primary-soft-foreground"] = pushForContrast(at(0.42, Math.min(p.c, 0.15)), soft, 4.5, "darker");
|
|
40
|
+
light["--color-on-primary"] = contrast("#FFFFFF", primary) >= 4.5 ? "#FFFFFF" : pushForContrast(at(0.22, nc), primary, 4.5, "darker");
|
|
41
|
+
light["--color-focus"] = primary;
|
|
42
|
+
light["--color-neutral"] = n(0.3);
|
|
43
|
+
light["--color-neutral-hover"] = n(0.38);
|
|
44
|
+
light["--color-neutral-active"] = n(0.22);
|
|
45
|
+
light["--color-neutral-soft"] = n(0.93, nc * 0.7);
|
|
46
|
+
light["--color-neutral-soft-hover"] = n(0.88, nc * 0.8);
|
|
47
|
+
light["--color-neutral-soft-foreground"] = text;
|
|
48
|
+
light["--color-on-neutral"] = "#FFFFFF";
|
|
49
|
+
// Dark scheme.
|
|
50
|
+
const dark = {};
|
|
51
|
+
const dSurface = n(0.18, nc);
|
|
52
|
+
dark["--color-surface"] = dSurface;
|
|
53
|
+
dark["--color-surface-raised"] = n(0.23, nc);
|
|
54
|
+
dark["--color-surface-sunken"] = n(0.13, nc);
|
|
55
|
+
const dText = n(0.95, nc * 0.3);
|
|
56
|
+
dark["--color-surface-overlay"] = `${n(0.13, nc)}CC`;
|
|
57
|
+
dark["--color-text"] = dText;
|
|
58
|
+
dark["--color-text-muted"] = pushForContrast(at(0.72, nc * 0.5), dSurface, 4.5, "lighter");
|
|
59
|
+
dark["--color-text-subtle"] = pushForContrast(at(0.55, nc * 0.5), dSurface, 3, "lighter");
|
|
60
|
+
dark["--color-border"] = n(0.29, nc);
|
|
61
|
+
dark["--color-border-strong"] = n(0.37, nc);
|
|
62
|
+
const dPrimary = pushForContrast(at(Math.max(p.l, 0.72), Math.min(p.c, 0.17)), dSurface, 3, "lighter");
|
|
63
|
+
const dpl = hexToOklch(dPrimary);
|
|
64
|
+
dark["--color-primary"] = dPrimary;
|
|
65
|
+
dark["--color-primary-hover"] = oklchToHex({ ...dpl, l: Math.min(0.97, dpl.l + 0.05) });
|
|
66
|
+
dark["--color-primary-active"] = oklchToHex({ ...dpl, l: Math.min(0.99, dpl.l + 0.1) });
|
|
67
|
+
const dSoft = oklchToHex(at(0.3, Math.min(0.08, p.c * 0.5)));
|
|
68
|
+
dark["--color-primary-soft"] = dSoft;
|
|
69
|
+
dark["--color-primary-soft-hover"] = oklchToHex(at(0.35, Math.min(0.09, p.c * 0.55)));
|
|
70
|
+
dark["--color-primary-soft-foreground"] = pushForContrast(at(0.85, Math.min(0.1, p.c * 0.6)), dSoft, 4.5, "lighter");
|
|
71
|
+
dark["--color-on-primary"] = contrast(dSurface, dPrimary) >= 4.5 ? n(0.13, nc) : "#FFFFFF";
|
|
72
|
+
dark["--color-focus"] = dPrimary;
|
|
73
|
+
dark["--color-neutral"] = n(0.9, nc * 0.5);
|
|
74
|
+
dark["--color-neutral-hover"] = n(0.84, nc * 0.5);
|
|
75
|
+
dark["--color-neutral-active"] = n(0.95, nc * 0.3);
|
|
76
|
+
dark["--color-neutral-soft"] = n(0.28, nc);
|
|
77
|
+
dark["--color-neutral-soft-hover"] = n(0.34, nc);
|
|
78
|
+
dark["--color-neutral-soft-foreground"] = dText;
|
|
79
|
+
dark["--color-on-neutral"] = n(0.13, nc);
|
|
80
|
+
return { light, dark };
|
|
81
|
+
}
|
|
82
|
+
/** The pairings the components rely on, with the ratios the palette reached. */
|
|
83
|
+
export function paletteContrast(p) {
|
|
84
|
+
const pairs = [
|
|
85
|
+
["text on surface", "--color-text", "--color-surface"],
|
|
86
|
+
["muted on surface", "--color-text-muted", "--color-surface"],
|
|
87
|
+
["primary on surface", "--color-primary", "--color-surface"],
|
|
88
|
+
["on-primary on primary", "--color-on-primary", "--color-primary"],
|
|
89
|
+
["soft-foreground on soft", "--color-primary-soft-foreground", "--color-primary-soft"],
|
|
90
|
+
];
|
|
91
|
+
const out = [];
|
|
92
|
+
for (const [scheme, vars] of [["light", p.light], ["dark", p.dark]]) {
|
|
93
|
+
for (const [label, a, b] of pairs)
|
|
94
|
+
out.push({ pair: `${scheme}: ${label}`, ratio: Math.round(contrast(vars[a], vars[b]) * 100) / 100 });
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
const RADII = {
|
|
99
|
+
sharp: { "--radius-sm": "0px", "--radius-md": "0px", "--radius-lg": "0px", "--radius-xl": "0px" },
|
|
100
|
+
soft: null,
|
|
101
|
+
round: { "--radius-sm": "8px", "--radius-md": "12px", "--radius-lg": "16px", "--radius-xl": "24px" },
|
|
102
|
+
};
|
|
103
|
+
const FALLBACK_SANS = 'ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif';
|
|
104
|
+
const FALLBACK_MONO = "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace";
|
|
105
|
+
export function renderBrandCss(opts) {
|
|
106
|
+
const head = [];
|
|
107
|
+
if (opts.fontSans)
|
|
108
|
+
head.push(` --font-sans: "${opts.fontSans}", ${FALLBACK_SANS};`);
|
|
109
|
+
if (opts.fontDisplay)
|
|
110
|
+
head.push(` --font-display: "${opts.fontDisplay}", ${opts.fontSans ? `"${opts.fontSans}", ` : ""}${FALLBACK_SANS};`);
|
|
111
|
+
else if (opts.fontSans)
|
|
112
|
+
head.push(` --font-display: "${opts.fontSans}", ${FALLBACK_SANS};`);
|
|
113
|
+
if (opts.fontMono)
|
|
114
|
+
head.push(` --font-mono: "${opts.fontMono}", ${FALLBACK_MONO};`);
|
|
115
|
+
const radii = RADII[opts.radius ?? "soft"];
|
|
116
|
+
if (radii) {
|
|
117
|
+
head.push("");
|
|
118
|
+
for (const [k, v] of Object.entries(radii))
|
|
119
|
+
head.push(` ${k}: ${v};`);
|
|
120
|
+
}
|
|
121
|
+
const block = (vars) => Object.entries(vars).map(([k, v]) => ` ${k}: ${v};`).join("\n");
|
|
122
|
+
return `/*
|
|
123
|
+
* The ${opts.name} brand, generated by \`zengin brand\` from one color. Edit freely: this is the one file in the
|
|
124
|
+
* project that may hold literals, and every component wears whatever it says. Re-run \`zengin brand\` to
|
|
125
|
+
* regenerate from zengin/brand.json, or hand-tune the values below.
|
|
126
|
+
*/
|
|
127
|
+
|
|
128
|
+
:root,
|
|
129
|
+
[data-theme="light"] {
|
|
130
|
+
${head.length ? head.join("\n") + "\n\n" : ""}${block(opts.palette.light)}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
[data-theme="dark"] {
|
|
134
|
+
${block(opts.palette.dark)}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
@media (prefers-color-scheme: dark) {
|
|
138
|
+
:root:not([data-theme="light"]) {
|
|
139
|
+
${block(opts.palette.dark).replace(/^/gm, " ")}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/* The wordmark, for a header or a footer: the logo and the name, in the display face. */
|
|
144
|
+
.brand-mark {
|
|
145
|
+
display: inline-flex;
|
|
146
|
+
align-items: center;
|
|
147
|
+
gap: var(--spacing-2);
|
|
148
|
+
color: var(--color-text);
|
|
149
|
+
font-family: var(--font-display);
|
|
150
|
+
font-size: var(--text-xl);
|
|
151
|
+
font-weight: var(--font-weight-semibold);
|
|
152
|
+
letter-spacing: -0.01em;
|
|
153
|
+
text-decoration: none;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
.brand-mark__logo {
|
|
157
|
+
width: 1.5em;
|
|
158
|
+
height: 1.5em;
|
|
159
|
+
object-fit: contain;
|
|
160
|
+
}
|
|
161
|
+
`;
|
|
162
|
+
}
|
|
163
|
+
/** The most saturated color an SVG paints with, or undefined for a monochrome or unreadable file. */
|
|
164
|
+
export function primaryFromSvg(svg) {
|
|
165
|
+
const found = new Set();
|
|
166
|
+
for (const m of svg.matchAll(/#([0-9a-f]{6}|[0-9a-f]{3})\b/gi))
|
|
167
|
+
found.add(`#${m[1].length === 3 ? m[1].split("").map((c) => c + c).join("") : m[1]}`.toUpperCase());
|
|
168
|
+
for (const m of svg.matchAll(/rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)/gi)) {
|
|
169
|
+
found.add(`#${[m[1], m[2], m[3]].map((v) => Number(v).toString(16).padStart(2, "0")).join("")}`.toUpperCase());
|
|
170
|
+
}
|
|
171
|
+
let best;
|
|
172
|
+
for (const hex of found) {
|
|
173
|
+
const { c, l } = hexToOklch(hex);
|
|
174
|
+
if (c < 0.04 || l < 0.15 || l > 0.95)
|
|
175
|
+
continue; // greys, near-black, near-white
|
|
176
|
+
if (!best || c > best.c)
|
|
177
|
+
best = { hex, c };
|
|
178
|
+
}
|
|
179
|
+
return best?.hex;
|
|
180
|
+
}
|
|
181
|
+
/** The favicon when there is no logo: the name's initial on the primary, in the display face. */
|
|
182
|
+
export function faviconSvg(name, primary, onPrimary, radius) {
|
|
183
|
+
const initial = (name.trim().charAt(0) || "Z").toUpperCase();
|
|
184
|
+
const rx = radius === "sharp" ? 0 : radius === "round" ? 16 : 8;
|
|
185
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="${rx}" fill="${primary}"/><text x="32" y="44" text-anchor="middle" font-family="system-ui, sans-serif" font-size="36" font-weight="700" fill="${onPrimary}">${initial}</text></svg>\n`;
|
|
186
|
+
}
|
|
187
|
+
/** Applies a brand to a project: brand.css, brand.json, the logo and favicon, the wordmark, and index.html. */
|
|
188
|
+
export async function brandProject(opts) {
|
|
189
|
+
const dir = resolve(opts.projectDir);
|
|
190
|
+
if (!existsSync(join(dir, "zengin.config.yaml")))
|
|
191
|
+
throw new Error(`${dir} has no zengin.config.yaml. Run zengin brand inside a project made by zengin create, or pass --dir.`);
|
|
192
|
+
if (!opts.name.trim())
|
|
193
|
+
throw new Error("zengin brand needs --name.");
|
|
194
|
+
const warnings = [];
|
|
195
|
+
const files = [];
|
|
196
|
+
const write = (rel, content) => {
|
|
197
|
+
mkdirSync(dirname(join(dir, rel)), { recursive: true });
|
|
198
|
+
writeFileSync(join(dir, rel), content);
|
|
199
|
+
files.push(rel);
|
|
200
|
+
};
|
|
201
|
+
// The logo: copied into public/, and read for a color when it is an SVG.
|
|
202
|
+
let logoPublic;
|
|
203
|
+
let logoPrimary;
|
|
204
|
+
if (opts.logo) {
|
|
205
|
+
const src = resolve(dir, opts.logo);
|
|
206
|
+
if (!existsSync(src))
|
|
207
|
+
throw new Error(`Logo not found: ${src}`);
|
|
208
|
+
const ext = extname(src).toLowerCase();
|
|
209
|
+
if (![".svg", ".png", ".jpg", ".jpeg", ".webp"].includes(ext))
|
|
210
|
+
throw new Error(`Logo must be an SVG, PNG, JPEG or WebP (got ${basename(src)}).`);
|
|
211
|
+
logoPublic = `logo${ext}`;
|
|
212
|
+
mkdirSync(join(dir, "public"), { recursive: true });
|
|
213
|
+
copyFileSync(src, join(dir, "public", logoPublic));
|
|
214
|
+
files.push(`public/${logoPublic}`);
|
|
215
|
+
if (ext === ".svg")
|
|
216
|
+
logoPrimary = primaryFromSvg(readFileSync(src, "utf8"));
|
|
217
|
+
else if (!opts.primary)
|
|
218
|
+
warnings.push(`A ${ext.slice(1).toUpperCase()} logo does not supply a color; pass --primary <hex> to set one. Using the system default.`);
|
|
219
|
+
}
|
|
220
|
+
let primary;
|
|
221
|
+
let primarySource;
|
|
222
|
+
if (opts.primary) {
|
|
223
|
+
primary = normalizeHex(opts.primary);
|
|
224
|
+
primarySource = "option";
|
|
225
|
+
}
|
|
226
|
+
else if (logoPrimary) {
|
|
227
|
+
primary = logoPrimary;
|
|
228
|
+
primarySource = "logo";
|
|
229
|
+
}
|
|
230
|
+
else {
|
|
231
|
+
primary = systemPrimary(dir);
|
|
232
|
+
primarySource = "system";
|
|
233
|
+
if (opts.logo && extname(opts.logo).toLowerCase() === ".svg")
|
|
234
|
+
warnings.push("The logo has no saturated color to take a primary from. Using the system default; pass --primary <hex> to set one.");
|
|
235
|
+
}
|
|
236
|
+
const palette = derivePalette(primary);
|
|
237
|
+
const radius = opts.radius ?? "soft";
|
|
238
|
+
write("src/theme/brand.css", renderBrandCss({ name: opts.name, palette, fontDisplay: opts.fontDisplay, fontSans: opts.fontSans, fontMono: opts.fontMono, radius, logo: Boolean(logoPublic) }));
|
|
239
|
+
write(`${LAYOUT.definitionsDir}/brand.json`, JSON.stringify({ name: opts.name, primary, primarySource, logo: logoPublic ? `public/${logoPublic}` : null, fontDisplay: opts.fontDisplay ?? null, fontSans: opts.fontSans ?? null, fontMono: opts.fontMono ?? null, radius }, null, 2) + "\n");
|
|
240
|
+
const icon = logoPublic && logoPublic.endsWith(".svg") ? `/${logoPublic}` : "/favicon.svg";
|
|
241
|
+
if (icon === "/favicon.svg")
|
|
242
|
+
write("public/favicon.svg", faviconSvg(opts.name, palette.light["--color-primary"], palette.light["--color-on-primary"], radius));
|
|
243
|
+
write("src/brand.ts", `/** Generated by \`zengin brand\`. Import it wherever the product names itself. */
|
|
244
|
+
export const brand = {
|
|
245
|
+
name: ${JSON.stringify(opts.name)},
|
|
246
|
+
logo: ${logoPublic ? JSON.stringify(`/${logoPublic}`) : "null"},
|
|
247
|
+
} as const;
|
|
248
|
+
`);
|
|
249
|
+
write("src/components/brand-mark.tsx", `import { brand } from "../brand";
|
|
250
|
+
|
|
251
|
+
/** The logo and the name, for a header or a footer. Styled by .brand-mark in src/theme/brand.css. */
|
|
252
|
+
export function BrandMark({ href = "/" }: { href?: string }) {
|
|
253
|
+
return (
|
|
254
|
+
<a className="brand-mark" href={href} aria-label={brand.name}>
|
|
255
|
+
{brand.logo && <img className="brand-mark__logo" src={brand.logo} alt="" />}
|
|
256
|
+
{brand.name}
|
|
257
|
+
</a>
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
`);
|
|
261
|
+
const families = [opts.fontDisplay, opts.fontSans, opts.fontMono].filter((f) => Boolean(f));
|
|
262
|
+
const html = patchIndexHtml(dir, { title: opts.name, themeColor: palette.light["--color-primary"], icon, fonts: families.length ? fontsHref(families) : null });
|
|
263
|
+
if (html)
|
|
264
|
+
files.push("index.html");
|
|
265
|
+
if (families.length)
|
|
266
|
+
warnings.push("Fonts load from Google Fonts at weights 400 to 700; open the page once to confirm each family has them.");
|
|
267
|
+
const { config, dir: projectDir } = loadConfigFile(join(dir, "zengin.config.yaml"));
|
|
268
|
+
const resolved = resolveConfig(config, projectDir);
|
|
269
|
+
const engine = await createEngine(resolved);
|
|
270
|
+
const violations = engine.check(readProjectFiles(projectDir, resolved.scope.include, resolved.scope.exclude)).length;
|
|
271
|
+
return { name: opts.name, primary, primarySource, palette, files, contrast: paletteContrast(palette), warnings, violations };
|
|
272
|
+
}
|
|
273
|
+
function normalizeHex(hex) {
|
|
274
|
+
const m = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(hex.trim());
|
|
275
|
+
if (!m)
|
|
276
|
+
throw new Error(`--primary must be a hex color like #1B3FE4 (got ${hex}).`);
|
|
277
|
+
const s = m[1].length === 3 ? m[1].split("").map((c) => c + c).join("") : m[1];
|
|
278
|
+
return `#${s.toUpperCase()}`;
|
|
279
|
+
}
|
|
280
|
+
function systemPrimary(dir) {
|
|
281
|
+
const p = join(dir, LAYOUT.definitionsDir, "tokens.json");
|
|
282
|
+
try {
|
|
283
|
+
const tokens = JSON.parse(readFileSync(p, "utf8"));
|
|
284
|
+
const v = tokens.color?.primary?.DEFAULT?.$value;
|
|
285
|
+
if (v && /^#[0-9a-f]{6}$/i.test(v))
|
|
286
|
+
return v.toUpperCase();
|
|
287
|
+
}
|
|
288
|
+
catch {
|
|
289
|
+
// fall through
|
|
290
|
+
}
|
|
291
|
+
return "#2563EB";
|
|
292
|
+
}
|
package/dist/build.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type Registry } from "./schema.js";
|
|
2
|
+
/**
|
|
3
|
+
* Builds the registry from the repository: every component in packages/ui, the shared lib and foundation
|
|
4
|
+
* files, and the example apps as templates. Imports are rewritten from the package layout to the project
|
|
5
|
+
* layout (`@/components/ui`, `@/lib/cx`), so what a project receives is what it would have written itself.
|
|
6
|
+
*/
|
|
7
|
+
export declare function buildRegistry(opts: {
|
|
8
|
+
root: string;
|
|
9
|
+
version?: string;
|
|
10
|
+
}): Registry;
|
|
11
|
+
/** Writes index.json and items/<name>.json so the registry can be served as static files. */
|
|
12
|
+
export declare function writeRegistry(registry: Registry, outDir: string): string[];
|
|
13
|
+
export declare function kebab(name: string): string;
|
|
14
|
+
export declare function pascal(name: string): string;
|