@stridge/noctis-design-tokens 1.0.0-beta.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 +226 -0
- package/dist/apply-scopes.d.ts +12 -0
- package/dist/apply-scopes.js +30 -0
- package/dist/css.d.ts +10 -0
- package/dist/css.js +36 -0
- package/dist/graph/components.d.ts +6 -0
- package/dist/graph/components.js +4488 -0
- package/dist/graph/index.d.ts +11 -0
- package/dist/graph/index.js +26 -0
- package/dist/graph/inputs.d.ts +1 -0
- package/dist/graph/inputs.js +23 -0
- package/dist/graph/model.d.ts +205 -0
- package/dist/graph/model.js +282 -0
- package/dist/graph/registry.d.ts +67 -0
- package/dist/graph/registry.js +118 -0
- package/dist/graph/roles.d.ts +5 -0
- package/dist/graph/roles.js +151 -0
- package/dist/graph/scales.d.ts +1 -0
- package/dist/graph/scales.js +1296 -0
- package/dist/graph/serialize.d.ts +16 -0
- package/dist/graph/serialize.js +25 -0
- package/dist/graph/statics.d.ts +1 -0
- package/dist/graph/statics.js +419 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.js +25 -0
- package/dist/palettes.d.ts +70 -0
- package/dist/palettes.js +114 -0
- package/dist/react/provider.d.ts +23 -0
- package/dist/react/provider.js +28 -0
- package/dist/react.d.ts +3 -0
- package/dist/react.js +3 -0
- package/dist/scales.d.ts +186 -0
- package/dist/scales.js +186 -0
- package/dist/semantic.d.ts +36 -0
- package/dist/semantic.js +49 -0
- package/dist/swatches.d.ts +24 -0
- package/dist/swatches.js +57 -0
- package/dist/tokens.css +2607 -0
- package/dist/tokens.dtcg.json +3475 -0
- package/dist/tokens.json +14658 -0
- package/dist/tokens.tailwind.css +479 -0
- package/package.json +67 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { ROLE_VAR_PREFIX } from "./registry.js";
|
|
2
|
+
import { DesignToken } from "./model.js";
|
|
3
|
+
import { SEMANTIC_IDS } from "./roles.js";
|
|
4
|
+
import { COMPONENT_DECLARATIONS } from "./components.js";
|
|
5
|
+
//#region src/graph/index.d.ts
|
|
6
|
+
/** Every authored token — semantics, statics, foundations, and seeds — in tier order. */
|
|
7
|
+
declare const DESIGN_TOKENS: readonly DesignToken[];
|
|
8
|
+
/** A semantic-role id from the engine-backed semantic set. */
|
|
9
|
+
type SemanticRoleId = (typeof SEMANTIC_IDS)[number];
|
|
10
|
+
//#endregion
|
|
11
|
+
export { DESIGN_TOKENS, SemanticRoleId };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { COMPONENT_DECLARATIONS } from "./components.js";
|
|
2
|
+
import { SEED_TOKENS } from "./inputs.js";
|
|
3
|
+
import "./registry.js";
|
|
4
|
+
import { validateGraph } from "./model.js";
|
|
5
|
+
import { SEMANTIC_TOKENS } from "./roles.js";
|
|
6
|
+
import { FOUNDATION_TOKENS } from "./scales.js";
|
|
7
|
+
import { STATIC_SEMANTIC_TOKENS } from "./statics.js";
|
|
8
|
+
//#region src/graph/index.ts
|
|
9
|
+
/**
|
|
10
|
+
* The assembled token graph: the single source the views, generators, and exports derive from.
|
|
11
|
+
*
|
|
12
|
+
* Concatenates the authored semantic, static-semantic, foundation, and seed tokens, holds the
|
|
13
|
+
* component tier, and runs {@link validateGraph} at module load — a malformed graph is a
|
|
14
|
+
* load-time failure, which is the point. {@link ROLE_VAR_PREFIX} is the namespace every
|
|
15
|
+
* canonical token serializes under; the slot-internal `--_` namespace is fixed.
|
|
16
|
+
*/
|
|
17
|
+
/** Every authored token — semantics, statics, foundations, and seeds — in tier order. */
|
|
18
|
+
const DESIGN_TOKENS = [
|
|
19
|
+
...SEMANTIC_TOKENS,
|
|
20
|
+
...STATIC_SEMANTIC_TOKENS,
|
|
21
|
+
...FOUNDATION_TOKENS,
|
|
22
|
+
...SEED_TOKENS
|
|
23
|
+
];
|
|
24
|
+
validateGraph(DESIGN_TOKENS, COMPONENT_DECLARATIONS);
|
|
25
|
+
//#endregion
|
|
26
|
+
export { DESIGN_TOKENS };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { };
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
//#region src/graph/inputs.ts
|
|
2
|
+
function seed(uuid, id, raw, description, usage) {
|
|
3
|
+
return {
|
|
4
|
+
uuid,
|
|
5
|
+
tier: "seed",
|
|
6
|
+
name: {
|
|
7
|
+
kind: "flat",
|
|
8
|
+
id
|
|
9
|
+
},
|
|
10
|
+
value: { raw },
|
|
11
|
+
description,
|
|
12
|
+
usage,
|
|
13
|
+
introduced: "0.0.0"
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
/** Seed input knobs — the foundations derive from these. */
|
|
17
|
+
const SEED_TOKENS = [
|
|
18
|
+
seed("d8dec6e4-07ff-403d-ad16-b072a39d36f7", "font-scale", "1", "Type-scale multiplier applied to every named text size.", "The --noctis-seed-font-scale knob the System Controls type-size slider drives."),
|
|
19
|
+
seed("1cb82eee-e029-4538-9817-b0e533570797", "radius", "9999px", "Corner-radius knob the whole rounded scale derives from.", "The --noctis-seed-radius knob; 9999px is the pill Noctis default — controls go fully round while surfaces stay capped."),
|
|
20
|
+
seed("bcb4c342-6046-417d-953c-54aaf3fdf50e", "density", "1", "Density multiplier applied to every spacing step, control height, and region padding.", "The --noctis-seed-density knob the System Controls density picker drives; 1 is the default scale.")
|
|
21
|
+
];
|
|
22
|
+
//#endregion
|
|
23
|
+
export { SEED_TOKENS };
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { ComponentCategory, STATE_REGISTRY, TOKEN_TIERS, TokenCategory } from "./registry.js";
|
|
2
|
+
import { ThemeVar } from "@stridge/noctis-theme-engine";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
|
|
5
|
+
//#region src/graph/model.d.ts
|
|
6
|
+
/**
|
|
7
|
+
* How a token reaches Tailwind's `@theme` layer (no `bridge` = canonical-only, reached via
|
|
8
|
+
* `calc()`/`var()` or a component mint):
|
|
9
|
+
* - `color` — `--color-{id}: var(canonical)` in `@theme inline` (the color-utility bridge);
|
|
10
|
+
* - `utility` — reached through a dedicated `@utility` rule the generator authors;
|
|
11
|
+
* - `theme` — a bare Tailwind name in plain `@theme`, carrying the **literal** value
|
|
12
|
+
* duplicated from the canonical (breakpoints — `var()` is rejected in query preludes);
|
|
13
|
+
* - `theme-inline` — a bare Tailwind name in `@theme inline` as `var(canonical)`.
|
|
14
|
+
*/
|
|
15
|
+
type TokenBridge = {
|
|
16
|
+
readonly kind: "color";
|
|
17
|
+
} | {
|
|
18
|
+
readonly kind: "utility";
|
|
19
|
+
} | {
|
|
20
|
+
readonly kind: "theme";
|
|
21
|
+
readonly name: string;
|
|
22
|
+
} | {
|
|
23
|
+
readonly kind: "theme-inline";
|
|
24
|
+
readonly name: string;
|
|
25
|
+
};
|
|
26
|
+
/** How a token is grouped in the reference view (display metadata, not the structural category). */
|
|
27
|
+
type TokenGroup = "surface" | "text" | "border" | "accent" | "control" | "status" | "utility" | "chart" | "avatar";
|
|
28
|
+
/**
|
|
29
|
+
* A structured token name. The flat form carries an id verbatim (seed, foundation, and semantic
|
|
30
|
+
* tokens — the category segment comes from `tier`/`category` at serialization, never the id);
|
|
31
|
+
* the component form is `component[-anatomy]-property[-state]`.
|
|
32
|
+
*/
|
|
33
|
+
type TokenName = {
|
|
34
|
+
readonly kind: "flat";
|
|
35
|
+
readonly id: string;
|
|
36
|
+
} | {
|
|
37
|
+
readonly kind: "component";
|
|
38
|
+
readonly component: string;
|
|
39
|
+
readonly anatomy?: string;
|
|
40
|
+
readonly property: string;
|
|
41
|
+
readonly state?: (typeof STATE_REGISTRY)[number];
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* A token's value in the alias graph: a sibling role id, another token's uuid, an engine
|
|
45
|
+
* primitive, or a raw literal. A raw value may carry a light-mode twin (`rawLight`) for the
|
|
46
|
+
* accent-independent palettes that switch on the resolved mode.
|
|
47
|
+
*/
|
|
48
|
+
type TokenValue = {
|
|
49
|
+
readonly role: string;
|
|
50
|
+
} | {
|
|
51
|
+
readonly token: string;
|
|
52
|
+
} | {
|
|
53
|
+
readonly primitive: ThemeVar;
|
|
54
|
+
} | {
|
|
55
|
+
readonly raw: string;
|
|
56
|
+
readonly rawLight?: string;
|
|
57
|
+
};
|
|
58
|
+
/** A token's deprecation record — kept so a retired token stays in the graph as a redirect. */
|
|
59
|
+
interface TokenDeprecation {
|
|
60
|
+
readonly version: string;
|
|
61
|
+
readonly comment: string;
|
|
62
|
+
readonly replacedBy: readonly string[];
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* A design token: stable identity, a structured name, an alias-graph value, and the view
|
|
66
|
+
* metadata the reference surfaces render. Semantic/foundation tokens carry `label`/`group`/
|
|
67
|
+
* `category`/`utility`/`bridge`; seed and component tokens leave them unset (the validator
|
|
68
|
+
* enforces the `category` rule).
|
|
69
|
+
*/
|
|
70
|
+
interface DesignToken {
|
|
71
|
+
readonly uuid: string;
|
|
72
|
+
readonly tier: (typeof TOKEN_TIERS)[number];
|
|
73
|
+
readonly name: TokenName;
|
|
74
|
+
readonly value: TokenValue;
|
|
75
|
+
readonly description: string;
|
|
76
|
+
readonly usage: string;
|
|
77
|
+
readonly introduced: string;
|
|
78
|
+
readonly deprecated?: TokenDeprecation;
|
|
79
|
+
readonly label?: string;
|
|
80
|
+
/** Reference-view grouping (display metadata) — formerly `category`; renamed to free the structural sense. */
|
|
81
|
+
readonly group?: TokenGroup;
|
|
82
|
+
/**
|
|
83
|
+
* The structural category — the `{category}` segment of the canonical
|
|
84
|
+
* `--noctis-{category}-{name}`. Required for `foundation` and `semantic` tokens, forbidden
|
|
85
|
+
* for `seed` and `component` tokens; semantic tokens are always `"color"` (all enforced by
|
|
86
|
+
* {@link validateGraph}).
|
|
87
|
+
*/
|
|
88
|
+
readonly category?: TokenCategory;
|
|
89
|
+
/**
|
|
90
|
+
* The Tailwind utility (or utility prefix) that paints this token — **display metadata**
|
|
91
|
+
* for the docs/reference surfaces only. Emission is controlled exclusively by `bridge`.
|
|
92
|
+
*/
|
|
93
|
+
readonly utility?: string;
|
|
94
|
+
readonly bridge?: TokenBridge;
|
|
95
|
+
/**
|
|
96
|
+
* Per-variant fallback re-points for a component mint: option axis → option value → the fallback
|
|
97
|
+
* the internal var takes under `[data-{axis}={value}]`. The public var stays at the chain head,
|
|
98
|
+
* so one consumer override still wins across every variant.
|
|
99
|
+
*/
|
|
100
|
+
readonly variants?: Readonly<Record<string, Readonly<Record<string, TokenValue>>>>;
|
|
101
|
+
}
|
|
102
|
+
/** A primitive's token contract: its anatomy, option axes, consumed bridge utilities, and minted tokens. */
|
|
103
|
+
interface ComponentDeclaration {
|
|
104
|
+
readonly component: string;
|
|
105
|
+
/**
|
|
106
|
+
* The showcase group this component belongs to, from {@link CATEGORY_REGISTRY}. Catalog
|
|
107
|
+
* components carry one; mechanisms (the polymorphic base, radius scope, visually-hidden) leave it
|
|
108
|
+
* unset and never appear in the gallery.
|
|
109
|
+
*/
|
|
110
|
+
readonly category?: ComponentCategory;
|
|
111
|
+
readonly anatomy: readonly string[];
|
|
112
|
+
readonly options: Readonly<Record<string, readonly string[]>>;
|
|
113
|
+
readonly states: readonly string[];
|
|
114
|
+
/**
|
|
115
|
+
* The exact bridge utilities the recipes name directly (`"bg-surface"`, `"border-border"`,
|
|
116
|
+
* `"shadow-modal"`, …) — `minted-identity`'s per-owner allow-list. Identity values that flow
|
|
117
|
+
* through a minted internal var are NOT listed here; a role aliased by a mint is the mint's value,
|
|
118
|
+
* not a consumed utility. Validated against {@link consumableUtilities}; pinned to the recipes by
|
|
119
|
+
* the parity test.
|
|
120
|
+
*/
|
|
121
|
+
readonly consumes: readonly string[];
|
|
122
|
+
readonly mints: readonly DesignToken[];
|
|
123
|
+
/**
|
|
124
|
+
* Maps a `TokenName.anatomy` key to the `data-slot`s its `--_` internals attach to, for the case
|
|
125
|
+
* where one anatomy's tokens are shared across several slots (Menu's `item` tokens span four item
|
|
126
|
+
* slots). When unset for an anatomy, its internals attach to the single `{component}-{anatomy}`
|
|
127
|
+
* slot (or the root `{component}` slot when the anatomy is omitted).
|
|
128
|
+
*/
|
|
129
|
+
readonly slotGroups?: Readonly<Record<string, readonly string[]>>;
|
|
130
|
+
/**
|
|
131
|
+
* Maps an option axis to the `data-slot` that stamps its `data-{axis}` attribute, so a variant
|
|
132
|
+
* re-point keys off the right selector (Menu's `variant` stamps on `menu-item`).
|
|
133
|
+
*/
|
|
134
|
+
readonly optionSlots?: Readonly<Record<string, string>>;
|
|
135
|
+
/**
|
|
136
|
+
* Set when the component ships a recipe that is applied as a bare class list — exported as a
|
|
137
|
+
* `*Variants` helper or composed into another element through Base UI's `render`, where the host's
|
|
138
|
+
* own `data-slot` replaces this component's. The root-slot internals are then *also* declared on a
|
|
139
|
+
* `.noctis-{component}` class the recipe carries, so the chain resolves on the recipe element even
|
|
140
|
+
* when its `data-slot` is not `{component}` (Button composed into a `Menu.Trigger`/`Sheet.Close`).
|
|
141
|
+
*/
|
|
142
|
+
readonly recipePortable?: boolean;
|
|
143
|
+
/**
|
|
144
|
+
* Optional per-component prefix prepended to every emitted `data-slot` value (NOT the anatomy
|
|
145
|
+
* names, NOT the public `--noctis-*` var names). Set to `"noctis"` on `tabs` so its generated
|
|
146
|
+
* `@layer noctis.components` block targets `[data-slot="noctis-tabs*"]` to match the prefixed
|
|
147
|
+
* slots the migrated `tabs.tsx` stamps. Pre-1.0 alpha: no dual back-compat arm.
|
|
148
|
+
*
|
|
149
|
+
* Doubles as the **migration signal** (D4 final): a declaration carrying a `slotPrefix` has moved
|
|
150
|
+
* to the single-`data-slot` anchor model and drops the `.noctis-{component}` class arm entirely
|
|
151
|
+
* (the generator suppresses the portable hook for it), since D12's `.props()` spreads `data-slot`
|
|
152
|
+
* onto a foreign element so the escape hatch works through the attribute. Un-migrated components
|
|
153
|
+
* keep the dual `:where([data-slot="x"], .noctis-x)` arm unchanged.
|
|
154
|
+
*/
|
|
155
|
+
readonly slotPrefix?: string;
|
|
156
|
+
/**
|
|
157
|
+
* A stable marker attribute the component's **root-slot** internals (and the per-`[data-{axis}]`
|
|
158
|
+
* re-points keyed off the root) are declared on instead of `[data-slot="{prefix}-{component}"]`.
|
|
159
|
+
* Set to `"button"` on `button` so its `--_button-*` chain is emitted as `[data-button]{…}` and
|
|
160
|
+
* `[data-button][data-size="sm"]{…}` — the generalization of surface's hand-authored `data-surface`.
|
|
161
|
+
*
|
|
162
|
+
* Button is a class-escape-hatch primitive: `Button.props()` styles a *foreign* element (the rail's
|
|
163
|
+
* `Collapsible.Trigger`, a docs `<a>`) and must NOT clobber that element's own `data-slot`. Keying
|
|
164
|
+
* the internals off a dedicated marker lets the escape hatch carry `data-button` (plus the variant/
|
|
165
|
+
* size axes) without overwriting the host's slot, while the component itself still stamps the catalog
|
|
166
|
+
* `data-slot="{prefix}-{component}"` for SLOTS.md / testing. Only the root-slot internals move to the
|
|
167
|
+
* marker; descendant-anatomy internals (and re-points whose token lives on a descendant) stay on
|
|
168
|
+
* their `[data-slot]`. Requires `slotPrefix` (the marker model is part of the migrated single-anchor
|
|
169
|
+
* world).
|
|
170
|
+
*/
|
|
171
|
+
readonly marker?: string;
|
|
172
|
+
}
|
|
173
|
+
/** Structural schema for a single token (graph-level invariants are checked by {@link validateGraph}). */
|
|
174
|
+
declare const designTokenSchema: z.ZodType<DesignToken>;
|
|
175
|
+
/** Structural schema for a component declaration. */
|
|
176
|
+
declare const componentDeclarationSchema: z.ZodType<ComponentDeclaration>;
|
|
177
|
+
/**
|
|
178
|
+
* The canonical CSS custom-property name for a token. Component tokens keep the ungoverned
|
|
179
|
+
* component grammar (`--noctis-{body}`); seeds serialize as `{prefix}-seed-{id}`; foundation
|
|
180
|
+
* and semantic tokens carry their structural category (`{prefix}-{category}-{id}`). The prefix
|
|
181
|
+
* is a parameter so the same graph can serialize under an alternate namespace; the default is
|
|
182
|
+
* {@link ROLE_VAR_PREFIX}.
|
|
183
|
+
*/
|
|
184
|
+
declare function cssVarName(token: DesignToken, rolePrefix: string): string;
|
|
185
|
+
/**
|
|
186
|
+
* The bare Tailwind `@theme` key a token's bridge declares, or `undefined` when the token has
|
|
187
|
+
* no `@theme` key (no bridge, or a `utility`-kind bridge reached via a dedicated `@utility`
|
|
188
|
+
* rule). `color`-kind bridges derive `--color-{id}`; `theme`/`theme-inline` bridges carry an
|
|
189
|
+
* explicit name. The single source of truth for bridge naming — generators must not re-derive it.
|
|
190
|
+
*/
|
|
191
|
+
declare function bridgeVarName(token: DesignToken): string | undefined;
|
|
192
|
+
/** A graph validation failure, with the offending token/declaration named in the message. */
|
|
193
|
+
declare class GraphValidationError extends Error {
|
|
194
|
+
constructor(message: string);
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Enforce the cross-token invariants the serializers and generators depend on: structural schema
|
|
198
|
+
* conformance, the tier↔category contract, unique identity (uuid + serialized var name), the
|
|
199
|
+
* reserved engine namespace, bridge-name uniqueness, resolvable acyclic `{ token }`/`{ role }`
|
|
200
|
+
* aliases, existing `replacedBy` redirects, grammar-clean component property names, and
|
|
201
|
+
* well-formed component declarations. Throws {@link GraphValidationError} on the first violation.
|
|
202
|
+
*/
|
|
203
|
+
declare function validateGraph(tokens: readonly DesignToken[], components: readonly ComponentDeclaration[]): void;
|
|
204
|
+
//#endregion
|
|
205
|
+
export { ComponentDeclaration, DesignToken, GraphValidationError, TokenBridge, TokenDeprecation, TokenGroup, TokenName, TokenValue, bridgeVarName, componentDeclarationSchema, cssVarName, designTokenSchema, validateGraph };
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import { ABBREVIATED_PROPERTIES, CATEGORY_REGISTRY, COMPOSITE_PROPERTIES, ROLE_VAR_PREFIX, STATE_REGISTRY, TOKEN_CATEGORIES, TOKEN_TIERS } from "./registry.js";
|
|
2
|
+
import { serializeTokenName } from "./serialize.js";
|
|
3
|
+
import { THEME_VARS } from "@stridge/noctis-theme-engine";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
//#region src/graph/model.ts
|
|
6
|
+
/**
|
|
7
|
+
* The token graph data model: zod-validated TypeScript as the single source of truth.
|
|
8
|
+
*
|
|
9
|
+
* A {@link DesignToken} carries stable UUID identity, a structured {@link TokenName}, and an
|
|
10
|
+
* alias-graph {@link TokenValue} — an engine primitive, another token, a sibling role, or a raw
|
|
11
|
+
* literal. {@link ComponentDeclaration} records a primitive's anatomy, option axes, consumed
|
|
12
|
+
* roles, and the component tokens it mints. {@link validateGraph} enforces the cross-token
|
|
13
|
+
* invariants the serializers and generators rely on (unique identity, resolvable acyclic
|
|
14
|
+
* aliases, grammar-clean property names). JSON and DTCG are generated *from* this graph; nothing
|
|
15
|
+
* is authored in them.
|
|
16
|
+
*/
|
|
17
|
+
const KEBAB = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
|
|
18
|
+
/**
|
|
19
|
+
* Flat semantic/foundation ids — leading digits (`2xl`), the Tailwind `--modifier` double dash,
|
|
20
|
+
* and a fractional segment (`1.5`, the spacing-ramp half steps) permitted. The id carries the
|
|
21
|
+
* bare `.`; CSS emission backslash-escapes it (`--noctis-space-1\.5`) at the generator.
|
|
22
|
+
*/
|
|
23
|
+
const FLAT_ID = /^[a-z0-9]+(?:\.[0-9]+)?(?:-{1,2}[a-z0-9]+(?:\.[0-9]+)?)*$/;
|
|
24
|
+
/**
|
|
25
|
+
* A bridge name: a bare Tailwind `@theme` key, double-dash modifier suffix
|
|
26
|
+
* (`--text-regular--line-height`) and fractional segment (`--spacing-1.5`) permitted — the
|
|
27
|
+
* fractional dot is stored bare and escaped at CSS emission, like {@link FLAT_ID}.
|
|
28
|
+
*/
|
|
29
|
+
const BRIDGE_NAME = /^--[a-z][a-z0-9]*(?:-{1,2}[a-z0-9]+(?:\.[0-9]+)?)*$/;
|
|
30
|
+
const tokenNameSchema = z.discriminatedUnion("kind", [z.object({
|
|
31
|
+
kind: z.literal("flat"),
|
|
32
|
+
id: z.string().regex(FLAT_ID)
|
|
33
|
+
}), z.object({
|
|
34
|
+
kind: z.literal("component"),
|
|
35
|
+
component: z.string().regex(/^[a-z][a-z0-9-]*$/),
|
|
36
|
+
anatomy: z.string().regex(KEBAB).optional(),
|
|
37
|
+
property: z.string().min(1),
|
|
38
|
+
state: z.enum(STATE_REGISTRY).optional()
|
|
39
|
+
})]);
|
|
40
|
+
const tokenValueSchema = z.union([
|
|
41
|
+
z.object({ role: z.string() }).strict(),
|
|
42
|
+
z.object({ token: z.string() }).strict(),
|
|
43
|
+
z.object({ primitive: z.enum(THEME_VARS) }).strict(),
|
|
44
|
+
z.object({
|
|
45
|
+
raw: z.string(),
|
|
46
|
+
rawLight: z.string().optional()
|
|
47
|
+
}).strict()
|
|
48
|
+
]);
|
|
49
|
+
const groupSchema = z.enum([
|
|
50
|
+
"surface",
|
|
51
|
+
"text",
|
|
52
|
+
"border",
|
|
53
|
+
"accent",
|
|
54
|
+
"control",
|
|
55
|
+
"status",
|
|
56
|
+
"utility",
|
|
57
|
+
"chart",
|
|
58
|
+
"avatar"
|
|
59
|
+
]);
|
|
60
|
+
const tokenBridgeSchema = z.discriminatedUnion("kind", [
|
|
61
|
+
z.object({ kind: z.literal("color") }).strict(),
|
|
62
|
+
z.object({ kind: z.literal("utility") }).strict(),
|
|
63
|
+
z.object({
|
|
64
|
+
kind: z.literal("theme"),
|
|
65
|
+
name: z.string().regex(BRIDGE_NAME)
|
|
66
|
+
}).strict(),
|
|
67
|
+
z.object({
|
|
68
|
+
kind: z.literal("theme-inline"),
|
|
69
|
+
name: z.string().regex(BRIDGE_NAME)
|
|
70
|
+
}).strict()
|
|
71
|
+
]);
|
|
72
|
+
/** Structural schema for a single token (graph-level invariants are checked by {@link validateGraph}). */
|
|
73
|
+
const designTokenSchema = z.object({
|
|
74
|
+
uuid: z.uuid(),
|
|
75
|
+
tier: z.enum(TOKEN_TIERS),
|
|
76
|
+
name: tokenNameSchema,
|
|
77
|
+
value: tokenValueSchema,
|
|
78
|
+
description: z.string(),
|
|
79
|
+
usage: z.string(),
|
|
80
|
+
introduced: z.string(),
|
|
81
|
+
deprecated: z.object({
|
|
82
|
+
version: z.string(),
|
|
83
|
+
comment: z.string(),
|
|
84
|
+
replacedBy: z.array(z.string())
|
|
85
|
+
}).optional(),
|
|
86
|
+
label: z.string().optional(),
|
|
87
|
+
group: groupSchema.optional(),
|
|
88
|
+
category: z.enum(TOKEN_CATEGORIES).optional(),
|
|
89
|
+
utility: z.string().optional(),
|
|
90
|
+
bridge: tokenBridgeSchema.optional(),
|
|
91
|
+
variants: z.record(z.string(), z.record(z.string(), tokenValueSchema)).optional()
|
|
92
|
+
});
|
|
93
|
+
/** Structural schema for a component declaration. */
|
|
94
|
+
const componentDeclarationSchema = z.object({
|
|
95
|
+
component: z.string().regex(/^[a-z][a-z0-9-]*$/),
|
|
96
|
+
category: z.enum(CATEGORY_REGISTRY).optional(),
|
|
97
|
+
anatomy: z.array(z.string().regex(KEBAB)),
|
|
98
|
+
options: z.record(z.string(), z.array(z.string())),
|
|
99
|
+
states: z.array(z.enum(STATE_REGISTRY)),
|
|
100
|
+
consumes: z.array(z.string()),
|
|
101
|
+
mints: z.array(designTokenSchema),
|
|
102
|
+
slotGroups: z.record(z.string(), z.array(z.string())).optional(),
|
|
103
|
+
optionSlots: z.record(z.string(), z.string()).optional(),
|
|
104
|
+
recipePortable: z.boolean().optional(),
|
|
105
|
+
slotPrefix: z.string().regex(/^[a-z][a-z0-9-]*$/).optional(),
|
|
106
|
+
marker: z.string().regex(/^[a-z][a-z0-9-]*$/).optional()
|
|
107
|
+
});
|
|
108
|
+
/**
|
|
109
|
+
* The canonical CSS custom-property name for a token. Component tokens keep the ungoverned
|
|
110
|
+
* component grammar (`--noctis-{body}`); seeds serialize as `{prefix}-seed-{id}`; foundation
|
|
111
|
+
* and semantic tokens carry their structural category (`{prefix}-{category}-{id}`). The prefix
|
|
112
|
+
* is a parameter so the same graph can serialize under an alternate namespace; the default is
|
|
113
|
+
* {@link ROLE_VAR_PREFIX}.
|
|
114
|
+
*/
|
|
115
|
+
function cssVarName(token, rolePrefix) {
|
|
116
|
+
const body = serializeTokenName(token.name);
|
|
117
|
+
if (token.tier === "component") return `--noctis-${body}`;
|
|
118
|
+
if (token.tier === "seed") return `${rolePrefix}-seed-${body}`;
|
|
119
|
+
if (!token.category) throw new Error(`${token.tier} token "${body}" has no structural category — cannot derive its canonical name`);
|
|
120
|
+
return `${rolePrefix}-${token.category}-${body}`;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* The bare Tailwind `@theme` key a token's bridge declares, or `undefined` when the token has
|
|
124
|
+
* no `@theme` key (no bridge, or a `utility`-kind bridge reached via a dedicated `@utility`
|
|
125
|
+
* rule). `color`-kind bridges derive `--color-{id}`; `theme`/`theme-inline` bridges carry an
|
|
126
|
+
* explicit name. The single source of truth for bridge naming — generators must not re-derive it.
|
|
127
|
+
*/
|
|
128
|
+
function bridgeVarName(token) {
|
|
129
|
+
if (!token.bridge) return void 0;
|
|
130
|
+
switch (token.bridge.kind) {
|
|
131
|
+
case "color": return `--color-${serializeTokenName(token.name)}`;
|
|
132
|
+
case "utility": return;
|
|
133
|
+
case "theme":
|
|
134
|
+
case "theme-inline": return token.bridge.name;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* The gated color-utility prefixes Tailwind generates for every `--color-{id}` (the family-table
|
|
139
|
+
* paint families a recipe may consume). `outline-*` is deliberately absent — it is a *free* family
|
|
140
|
+
* (the focus-ring composite's domain), so an `outline-{role}` is never a declared `consumes` entry.
|
|
141
|
+
*/
|
|
142
|
+
const COLOR_UTILITY_PREFIXES = [
|
|
143
|
+
"bg",
|
|
144
|
+
"from",
|
|
145
|
+
"via",
|
|
146
|
+
"to",
|
|
147
|
+
"text",
|
|
148
|
+
"placeholder",
|
|
149
|
+
"caret",
|
|
150
|
+
"accent",
|
|
151
|
+
"decoration",
|
|
152
|
+
"fill",
|
|
153
|
+
"stroke",
|
|
154
|
+
"border",
|
|
155
|
+
"divide",
|
|
156
|
+
"ring",
|
|
157
|
+
"ring-offset",
|
|
158
|
+
"inset-ring"
|
|
159
|
+
];
|
|
160
|
+
/**
|
|
161
|
+
* Every bridge utility a component recipe may name in its `consumes` declaration: a color role's
|
|
162
|
+
* full prefix expansion (`bg-`/`text-`/`border-`/… of each `color`-kind semantic token), the
|
|
163
|
+
* single named utility of a `utility`-kind semantic token, and the named semantic shadows. Scale
|
|
164
|
+
* steps (`rounded-*`, `text-{size}`, spacing) are excluded — identity values flow through mints,
|
|
165
|
+
* not `consumes`. The single source of truth shared by {@link validateGraph} and the parity test.
|
|
166
|
+
*/
|
|
167
|
+
function consumableUtilities(tokens) {
|
|
168
|
+
const utilities = /* @__PURE__ */ new Set();
|
|
169
|
+
for (const token of tokens) if (token.tier === "semantic") {
|
|
170
|
+
if (token.bridge?.kind === "color") {
|
|
171
|
+
const id = serializeTokenName(token.name);
|
|
172
|
+
for (const prefix of COLOR_UTILITY_PREFIXES) utilities.add(`${prefix}-${id}`);
|
|
173
|
+
} else if (token.utility) utilities.add(token.utility);
|
|
174
|
+
} else if (token.tier === "foundation" && token.bridge?.kind === "theme-inline" && token.utility?.startsWith("shadow-")) utilities.add(token.utility);
|
|
175
|
+
return utilities;
|
|
176
|
+
}
|
|
177
|
+
function isCompositeProperty(property) {
|
|
178
|
+
return COMPOSITE_PROPERTIES.includes(property);
|
|
179
|
+
}
|
|
180
|
+
/** A graph validation failure, with the offending token/declaration named in the message. */
|
|
181
|
+
var GraphValidationError = class extends Error {
|
|
182
|
+
constructor(message) {
|
|
183
|
+
super(message);
|
|
184
|
+
this.name = "GraphValidationError";
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
/**
|
|
188
|
+
* Enforce the cross-token invariants the serializers and generators depend on: structural schema
|
|
189
|
+
* conformance, the tier↔category contract, unique identity (uuid + serialized var name), the
|
|
190
|
+
* reserved engine namespace, bridge-name uniqueness, resolvable acyclic `{ token }`/`{ role }`
|
|
191
|
+
* aliases, existing `replacedBy` redirects, grammar-clean component property names, and
|
|
192
|
+
* well-formed component declarations. Throws {@link GraphValidationError} on the first violation.
|
|
193
|
+
*/
|
|
194
|
+
function validateGraph(tokens, components) {
|
|
195
|
+
for (const token of tokens) {
|
|
196
|
+
const parsed = designTokenSchema.safeParse(token);
|
|
197
|
+
if (!parsed.success) throw new GraphValidationError(`invalid token ${describe(token)}: ${parsed.error.issues[0]?.message}`);
|
|
198
|
+
}
|
|
199
|
+
for (const decl of components) {
|
|
200
|
+
const parsed = componentDeclarationSchema.safeParse(decl);
|
|
201
|
+
if (!parsed.success) throw new GraphValidationError(`invalid declaration "${decl.component}": ${parsed.error.issues[0]?.message}`);
|
|
202
|
+
}
|
|
203
|
+
const allTokens = [...tokens, ...components.flatMap((c) => c.mints)];
|
|
204
|
+
const byUuid = /* @__PURE__ */ new Map();
|
|
205
|
+
for (const token of allTokens) {
|
|
206
|
+
if (byUuid.has(token.uuid)) throw new GraphValidationError(`duplicate uuid ${token.uuid} (${describe(token)})`);
|
|
207
|
+
byUuid.set(token.uuid, token);
|
|
208
|
+
}
|
|
209
|
+
for (const token of allTokens) {
|
|
210
|
+
const categorized = token.tier === "foundation" || token.tier === "semantic";
|
|
211
|
+
if (categorized && !token.category) throw new GraphValidationError(`${token.tier} token ${describe(token)} is missing a structural category`);
|
|
212
|
+
if (!categorized && token.category) throw new GraphValidationError(`${token.tier} token ${describe(token)} must not carry a structural category`);
|
|
213
|
+
if (token.tier === "semantic" && token.category !== "color") throw new GraphValidationError(`semantic token ${describe(token)} must have category "color" (got "${token.category}")`);
|
|
214
|
+
}
|
|
215
|
+
const seenVars = /* @__PURE__ */ new Set();
|
|
216
|
+
for (const token of allTokens) {
|
|
217
|
+
const name = cssVarName(token, ROLE_VAR_PREFIX);
|
|
218
|
+
if (seenVars.has(name)) throw new GraphValidationError(`duplicate css variable ${name}`);
|
|
219
|
+
if (name.startsWith(`--noctis-engine-`)) throw new GraphValidationError(`token ${describe(token)} serializes into the reserved engine namespace (${name})`);
|
|
220
|
+
seenVars.add(name);
|
|
221
|
+
}
|
|
222
|
+
const seenBridges = /* @__PURE__ */ new Set();
|
|
223
|
+
for (const token of allTokens) {
|
|
224
|
+
const bridge = bridgeVarName(token);
|
|
225
|
+
if (!bridge) continue;
|
|
226
|
+
if (seenBridges.has(bridge)) throw new GraphValidationError(`duplicate bridge name ${bridge} (${describe(token)})`);
|
|
227
|
+
if (seenVars.has(bridge)) throw new GraphValidationError(`bridge name ${bridge} (${describe(token)}) collides with a canonical css variable`);
|
|
228
|
+
seenBridges.add(bridge);
|
|
229
|
+
}
|
|
230
|
+
const roleById = new Map(allTokens.filter((t) => t.tier === "semantic").map((t) => [serializeTokenName(t.name), t]));
|
|
231
|
+
for (const token of allTokens) {
|
|
232
|
+
if ("token" in token.value && !byUuid.has(token.value.token)) throw new GraphValidationError(`token ${describe(token)} aliases a missing uuid ${token.value.token}`);
|
|
233
|
+
if ("role" in token.value && !roleById.has(token.value.role)) throw new GraphValidationError(`token ${describe(token)} aliases a missing role "${token.value.role}"`);
|
|
234
|
+
if (token.deprecated) {
|
|
235
|
+
for (const uuid of token.deprecated.replacedBy) if (!byUuid.has(uuid)) throw new GraphValidationError(`token ${describe(token)} is replaced by a missing uuid ${uuid}`);
|
|
236
|
+
}
|
|
237
|
+
if (token.name.kind === "component") {
|
|
238
|
+
const { property } = token.name;
|
|
239
|
+
if (!isCompositeProperty(property) && !KEBAB.test(property)) throw new GraphValidationError(`token ${describe(token)} property "${property}" is not kebab-case`);
|
|
240
|
+
if (ABBREVIATED_PROPERTIES.includes(property)) throw new GraphValidationError(`token ${describe(token)} property "${property}" is an abbreviation — spell it out`);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
const consumable = consumableUtilities(tokens);
|
|
244
|
+
for (const decl of components) {
|
|
245
|
+
for (const mint of decl.mints) {
|
|
246
|
+
if (mint.tier !== "component") throw new GraphValidationError(`"${decl.component}" mints a non-component token ${describe(mint)}`);
|
|
247
|
+
if (mint.name.kind !== "component" || mint.name.component !== decl.component) throw new GraphValidationError(`"${decl.component}" mints ${describe(mint)} under a foreign component`);
|
|
248
|
+
}
|
|
249
|
+
for (const utility of decl.consumes) if (!consumable.has(utility)) throw new GraphValidationError(`"${decl.component}" consumes an unknown bridge utility "${utility}"`);
|
|
250
|
+
const anatomy = new Set(decl.anatomy);
|
|
251
|
+
for (const [key, slots] of Object.entries(decl.slotGroups ?? {})) for (const slot of slots) if (!anatomy.has(slot)) throw new GraphValidationError(`"${decl.component}" slotGroup "${key}" references a missing slot "${slot}"`);
|
|
252
|
+
for (const [axis, slot] of Object.entries(decl.optionSlots ?? {})) {
|
|
253
|
+
if (!(axis in decl.options)) throw new GraphValidationError(`"${decl.component}" optionSlots axis "${axis}" is not an option`);
|
|
254
|
+
if (!anatomy.has(slot)) throw new GraphValidationError(`"${decl.component}" optionSlots axis "${axis}" stamps a missing slot "${slot}"`);
|
|
255
|
+
}
|
|
256
|
+
if (decl.marker !== void 0 && decl.slotPrefix === void 0) throw new GraphValidationError(`"${decl.component}" sets a marker "${decl.marker}" without a slotPrefix — the marker model is part of the migrated single-anchor world`);
|
|
257
|
+
for (const mint of decl.mints) for (const [axis, byValue] of Object.entries(mint.variants ?? {})) {
|
|
258
|
+
const values = decl.options[axis];
|
|
259
|
+
if (!values) throw new GraphValidationError(`${describe(mint)} re-points unknown axis "${axis}"`);
|
|
260
|
+
for (const value of Object.keys(byValue)) if (!values.includes(value)) throw new GraphValidationError(`${describe(mint)} re-points unknown "${axis}" value "${value}"`);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
detectAliasCycles(allTokens, byUuid, roleById);
|
|
264
|
+
}
|
|
265
|
+
function detectAliasCycles(tokens, byUuid, roleById) {
|
|
266
|
+
const state = /* @__PURE__ */ new Map();
|
|
267
|
+
const visit = (token) => {
|
|
268
|
+
const mark = state.get(token.uuid);
|
|
269
|
+
if (mark === "done") return;
|
|
270
|
+
if (mark === "visiting") throw new GraphValidationError(`alias cycle through ${describe(token)}`);
|
|
271
|
+
state.set(token.uuid, "visiting");
|
|
272
|
+
const next = "token" in token.value ? byUuid.get(token.value.token) : "role" in token.value ? roleById.get(token.value.role) : void 0;
|
|
273
|
+
if (next) visit(next);
|
|
274
|
+
state.set(token.uuid, "done");
|
|
275
|
+
};
|
|
276
|
+
for (const token of tokens) visit(token);
|
|
277
|
+
}
|
|
278
|
+
function describe(token) {
|
|
279
|
+
return `"${serializeTokenName(token.name)}"`;
|
|
280
|
+
}
|
|
281
|
+
//#endregion
|
|
282
|
+
export { GraphValidationError, bridgeVarName, componentDeclarationSchema, cssVarName, designTokenSchema, validateGraph };
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
//#region src/graph/registry.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Closed vocabularies the token grammar is built from.
|
|
4
|
+
*
|
|
5
|
+
* These are the controlled sets a structured token name draws on: the interaction states a
|
|
6
|
+
* token may key off, the composite pseudo-properties that stand in where no single real CSS
|
|
7
|
+
* property names an anatomy-level seam, and the abbreviation denylist that keeps property
|
|
8
|
+
* names spelled out. Extending any of them is a deliberate schema change here — never an
|
|
9
|
+
* ad-hoc string at a call site.
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* Interaction states a component token may carry, as the final name segment. The default
|
|
13
|
+
* (rest) state is the absence of a segment, so there is no `default` member. Order is the
|
|
14
|
+
* serialization tie-break only; names never combine two states.
|
|
15
|
+
*/
|
|
16
|
+
declare const STATE_REGISTRY: readonly ["hover", "active", "focus", "disabled", "selected", "highlighted", "open", "checked"];
|
|
17
|
+
/** A sanctioned interaction state. */
|
|
18
|
+
type TokenState = (typeof STATE_REGISTRY)[number];
|
|
19
|
+
/**
|
|
20
|
+
* Composite pseudo-properties: names admitted as a token `property` where no single real CSS
|
|
21
|
+
* property names the seam a consumer would override. Each expands to real CSS properties at the
|
|
22
|
+
* recipe — `size` is width + height (Tailwind's `size-*`). The seam vocabulary grows only by
|
|
23
|
+
* adding a member here.
|
|
24
|
+
*/
|
|
25
|
+
declare const COMPOSITE_PROPERTIES: readonly ["size"];
|
|
26
|
+
/** A sanctioned composite pseudo-property. */
|
|
27
|
+
type CompositeProperty = (typeof COMPOSITE_PROPERTIES)[number];
|
|
28
|
+
/**
|
|
29
|
+
* The token tiers. `seed` is the knob stratum (the System Controls inputs the foundations
|
|
30
|
+
* derive from); `foundation` is the authored non-color scale stratum (type, radius, motion,
|
|
31
|
+
* z, …); `semantic` is the intent-named color stratum over the engine; `component` is the
|
|
32
|
+
* minted per-component seam stratum. The engine stratum is not a graph tier — it is the
|
|
33
|
+
* engine's own `--noctis-engine-*` namespace.
|
|
34
|
+
*/
|
|
35
|
+
declare const TOKEN_TIERS: readonly ["seed", "foundation", "semantic", "component"];
|
|
36
|
+
/** A token tier. */
|
|
37
|
+
type TokenTier = (typeof TOKEN_TIERS)[number];
|
|
38
|
+
/**
|
|
39
|
+
* The closed structural-category vocabulary — the `{category}` segment of a canonical
|
|
40
|
+
* `--noctis-{category}-{name}` CSS variable. Required on `foundation` and
|
|
41
|
+
* `semantic` tokens, forbidden on `seed` and `component` tokens (the validator enforces
|
|
42
|
+
* both). Distinct from {@link TokenGroup} (the reference-view grouping) and from
|
|
43
|
+
* {@link ComponentCategory} (the showcase groups) — do not conflate the three. Growing the
|
|
44
|
+
* vocabulary is a deliberate schema change here, never an ad-hoc string in a data file.
|
|
45
|
+
*/
|
|
46
|
+
declare const TOKEN_CATEGORIES: readonly ["color", "space", "size", "text", "leading", "radius", "shadow", "duration", "ease", "z", "font", "tracking", "breakpoint", "container", "border", "opacity", "blur", "animate"];
|
|
47
|
+
/** A sanctioned structural category — the category segment of a canonical CSS variable name. */
|
|
48
|
+
type TokenCategory = (typeof TOKEN_CATEGORIES)[number];
|
|
49
|
+
/**
|
|
50
|
+
* The namespace every canonical token serializes under. A parameter on the serializers so the
|
|
51
|
+
* same graph can emit under an alternate namespace; this constant is the single source of truth
|
|
52
|
+
* for the default. `--noctis-engine-*` is the engine's reserved private namespace — the
|
|
53
|
+
* validator rejects any graph token that serializes into it.
|
|
54
|
+
*/
|
|
55
|
+
declare const ROLE_VAR_PREFIX: "--noctis";
|
|
56
|
+
/**
|
|
57
|
+
* The closed component-category vocabulary the showcase groups primitives by. A
|
|
58
|
+
* {@link ComponentDeclaration} draws its optional `category` from this set, and the gallery renders
|
|
59
|
+
* one localized group per member in this order. Mechanisms (the polymorphic base, the radius scope,
|
|
60
|
+
* the visually-hidden helper) carry no category — they are never showcased. Growing the vocabulary
|
|
61
|
+
* is a deliberate schema change here, never an ad-hoc string in the docs app.
|
|
62
|
+
*/
|
|
63
|
+
declare const CATEGORY_REGISTRY: readonly ["actions", "fields", "navigation", "overlays", "surfaces", "data", "typography", "system"];
|
|
64
|
+
/** A sanctioned component category. */
|
|
65
|
+
type ComponentCategory = (typeof CATEGORY_REGISTRY)[number];
|
|
66
|
+
//#endregion
|
|
67
|
+
export { CATEGORY_REGISTRY, COMPOSITE_PROPERTIES, ComponentCategory, CompositeProperty, ROLE_VAR_PREFIX, STATE_REGISTRY, TOKEN_CATEGORIES, TOKEN_TIERS, TokenCategory, TokenState, TokenTier };
|