@ggui-ai/project-config 0.15.0 → 0.17.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/dist/node.d.ts +1 -1
- package/dist/node.d.ts.map +1 -1
- package/dist/node.js +3 -3
- package/dist/schema.d.ts +2 -2
- package/dist/theme-loader.d.ts +25 -23
- package/dist/theme-loader.d.ts.map +1 -1
- package/dist/theme-loader.js +56 -49
- package/dist/theme.d.ts +451 -100
- package/dist/theme.d.ts.map +1 -1
- package/dist/theme.js +323 -135
- package/package.json +3 -3
package/dist/theme.js
CHANGED
|
@@ -1,82 +1,45 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `ggui.json#theme` → plain DTCG JSON document.
|
|
2
|
+
* `ggui.json#theme` → plain DTCG JSON document, v2 (ggui#987).
|
|
3
3
|
*
|
|
4
4
|
* The theme file is a plain JSON document matching the Design Tokens
|
|
5
|
-
* Community Group (DTCG) spec
|
|
6
|
-
*
|
|
7
|
-
* `
|
|
5
|
+
* Community Group (DTCG) spec, in the same vocabulary as
|
|
6
|
+
* `@ggui-ai/design`'s canonical {@link DtcgTheme}: the curated registry
|
|
7
|
+
* themes and an authored `theme.json` are the same shape.
|
|
8
8
|
*
|
|
9
|
-
* **
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* `
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* the
|
|
9
|
+
* **What is authored, what is derived.** An author states the six
|
|
10
|
+
* layering roles (`ground`/`onGround`, `container`/`onContainer`,
|
|
11
|
+
* `sunken`/`onSunken`), one `500` anchor per family (`primary`,
|
|
12
|
+
* `success`, `warning`, `error`, `info`), the font families and
|
|
13
|
+
* weights, spacing, radii, shadows. Everything else — the ten-stop
|
|
14
|
+
* ramps, every `on*` ink and container, the neutral ladder, outlines,
|
|
15
|
+
* `elevated`, the type scale from the one `font.ramp` — is DERIVED by
|
|
16
|
+
* the one producer (`deriveThemeVariables`), so the retired ladders
|
|
17
|
+
* (`font.size`, `font.lineHeight`, `motion.duration`, `motion.easing`)
|
|
18
|
+
* and `$metadata.fontUrl` are refused, not ignored. Fonts are declared
|
|
19
|
+
* as `typography.faces` (https `src` only); the card installs them.
|
|
17
20
|
*
|
|
18
|
-
* **External-tool
|
|
19
|
-
*
|
|
20
|
-
* the
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* (`$name`, `$description`, `$metadata`) are all OPTIONAL so tools
|
|
24
|
-
* that only emit colors + dimensions still parse cleanly.
|
|
25
|
-
*
|
|
26
|
-
* **BREAKING for old `theme.json` files.** Authors who hand-wrote a
|
|
27
|
-
* `theme.json` against the pre-rc schema must rename:
|
|
28
|
-
*
|
|
29
|
-
* - `typography.fontFamily` → `font.family`
|
|
30
|
-
* - `typography.fontSize` → `font.size`
|
|
31
|
-
* - `typography.fontWeight` → `font.weight`
|
|
32
|
-
* - `typography.lineHeight` → `font.lineHeight`
|
|
33
|
-
* - top-level `radius` → `shape.radius`
|
|
34
|
-
* - top-level `shadow` → `shape.shadow`
|
|
35
|
-
* - top-level `duration` → `motion.duration`
|
|
36
|
-
* - top-level `transition` → `motion.transition`
|
|
37
|
-
*
|
|
38
|
-
* Plus: under `font.family`, `sans` is the only required sub-token
|
|
39
|
-
* (was previously freeform); other family slots (`mono`, …) are
|
|
40
|
-
* optional. The font-family record itself stays open so additional
|
|
41
|
-
* named families parse without a schema change.
|
|
21
|
+
* **External-tool leniency.** `motion`, `accessibility`, `zIndex` and
|
|
22
|
+
* the DTCG metadata fields stay OPTIONAL; {@link normalizeThemeDocument}
|
|
23
|
+
* fills them from the shipped default and flattens the DTCG object
|
|
24
|
+
* forms (structured shadows, transitions, array font stacks) to the CSS
|
|
25
|
+
* strings the producer reads.
|
|
42
26
|
*
|
|
43
27
|
* **Ownership boundary:**
|
|
44
28
|
*
|
|
45
29
|
* - *Where* the theme lives → `ggui.json#theme` (pointer string).
|
|
46
30
|
* - *What* the theme file looks like → this module's schema.
|
|
47
|
-
* - *How*
|
|
48
|
-
* `
|
|
49
|
-
* emit). The duck-typed walker accepts any DTCG-shaped tree, so
|
|
50
|
-
* the new nested groups (`font.size`, `shape.radius`, …) walk
|
|
51
|
-
* into `--ggui-font-size-md`/`--ggui-shape-radius-md` CSS vars
|
|
52
|
-
* without code changes.
|
|
31
|
+
* - *How* the document becomes CSS variables → `@ggui-ai/design`'s
|
|
32
|
+
* `deriveThemeVariables` (the loader calls it for both modes).
|
|
53
33
|
* - *Built-in default* when `theme` is absent → `@ggui-ai/design`'s
|
|
54
|
-
* shipped `lightTheme`.
|
|
55
|
-
*
|
|
56
|
-
* **Extending rules:**
|
|
34
|
+
* shipped `lightTheme` / `darkTheme`.
|
|
57
35
|
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
* values, no build-pipeline fields.
|
|
62
|
-
* 3. Root and per-group objects are strict — unknown keys fail
|
|
63
|
-
* parse. Same discipline as `ggui.json`, `ggui.ui.json`, and
|
|
64
|
-
* `ggui.primitives.json`.
|
|
65
|
-
*
|
|
66
|
-
* **Strictness tradeoff:**
|
|
67
|
-
*
|
|
68
|
-
* Individual DTCG token leaves are validated with a discriminated
|
|
69
|
-
* union over `$type` (color / dimension / fontFamily / fontWeight /
|
|
70
|
-
* shadow / duration / cubicBezier / transition / number). Each
|
|
71
|
-
* variant validates its `$value` shape. `$description` is accepted
|
|
72
|
-
* everywhere as a free-form docstring. Token leaves under newer
|
|
73
|
-
* groups (`accessibility`, `zIndex`) accept a slightly
|
|
74
|
-
* wider token vocabulary because external tools sometimes encode
|
|
75
|
-
* scalar/array values with permissive `$type` strings (`string`,
|
|
76
|
-
* `array`, …); the schema accepts those without enumerating every
|
|
77
|
-
* possible spelling.
|
|
36
|
+
* **Extending rules:** additive only within `schema: '1'`; framework-
|
|
37
|
+
* and host-neutral; root and per-group objects are strict — unknown
|
|
38
|
+
* keys fail parse, the same discipline as `ggui.json`.
|
|
78
39
|
*/
|
|
79
40
|
import { z } from 'zod';
|
|
41
|
+
import { fontFaceDeclarationSchema } from '@ggui-ai/protocol';
|
|
42
|
+
import { lightTheme } from '@ggui-ai/design/themes';
|
|
80
43
|
// ─── Token leaves ────────────────────────────────────────────────────
|
|
81
44
|
/** A CSS color string. `$value` is not structurally validated (hex,
|
|
82
45
|
* rgba, color-mix, named, etc. are all legal DTCG colors). */
|
|
@@ -112,14 +75,6 @@ const DurationToken = z.strictObject({
|
|
|
112
75
|
$value: z.string().min(1),
|
|
113
76
|
$description: z.string().optional(),
|
|
114
77
|
});
|
|
115
|
-
/** DTCG cubic-bezier easing token. `$value` is a CSS timing function
|
|
116
|
-
* string (`"cubic-bezier(0.4, 0, 0.2, 1)"`, keyword aliases like
|
|
117
|
-
* `"ease-out"`, etc.). */
|
|
118
|
-
const CubicBezierToken = z.strictObject({
|
|
119
|
-
$type: z.literal('cubicBezier'),
|
|
120
|
-
$value: z.string().min(1),
|
|
121
|
-
$description: z.string().optional(),
|
|
122
|
-
});
|
|
123
78
|
/** Structured shadow value — DTCG spec composite shape. */
|
|
124
79
|
const ShadowValue = z.strictObject({
|
|
125
80
|
offsetX: z.string().min(1),
|
|
@@ -154,30 +109,11 @@ const NumberToken = z.strictObject({
|
|
|
154
109
|
$description: z.string().optional(),
|
|
155
110
|
});
|
|
156
111
|
/** Line-height token — DTCG allows unit-less number or dimension. */
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
$value: z.number(),
|
|
161
|
-
$description: z.string().optional(),
|
|
162
|
-
}),
|
|
163
|
-
z.strictObject({
|
|
164
|
-
$type: z.literal('dimension'),
|
|
165
|
-
$value: z.string().min(1),
|
|
166
|
-
$description: z.string().optional(),
|
|
167
|
-
}),
|
|
168
|
-
]);
|
|
169
|
-
/**
|
|
170
|
-
* Permissive token leaf for the newer optional groups
|
|
171
|
-
* (`motion.keyframes`, `zIndex`). DTCG hasn't standardised every
|
|
172
|
-
* `$type` value the curated registry themes carry — for example
|
|
173
|
-
* easing uses `$type: 'cubicBezier'`. Rather than
|
|
174
|
-
* enumerate every spelling, this leaf accepts any `$type` string and
|
|
175
|
-
* any JSON-serializable `$value`. Strict per-leaf shape stays in
|
|
176
|
-
* force for the original token types via the leaves above.
|
|
177
|
-
*/
|
|
178
|
-
const PermissiveToken = z.strictObject({
|
|
112
|
+
/** A token whose `$value` is a CSS string under any `$type` spelling
|
|
113
|
+
* (keyframes text, border style, …). */
|
|
114
|
+
const StringToken = z.strictObject({
|
|
179
115
|
$type: z.string().min(1),
|
|
180
|
-
$value: z.
|
|
116
|
+
$value: z.string().min(1),
|
|
181
117
|
$description: z.string().optional(),
|
|
182
118
|
});
|
|
183
119
|
// ─── Groups ──────────────────────────────────────────────────────────
|
|
@@ -188,42 +124,69 @@ const PermissiveToken = z.strictObject({
|
|
|
188
124
|
*/
|
|
189
125
|
const ColorPalette = z.record(z.string(), ColorToken);
|
|
190
126
|
/**
|
|
191
|
-
*
|
|
192
|
-
*
|
|
193
|
-
*
|
|
194
|
-
*
|
|
195
|
-
* Kept open (`z.record` not `z.strictObject`) so authored themes
|
|
196
|
-
* can add brand-specific palettes (`accent`, `brand-green`, …) and
|
|
197
|
-
* semantic roles without a schema change. `color` itself is
|
|
198
|
-
* required but its internal shape is author-extensible.
|
|
199
|
-
*
|
|
200
|
-
* Each entry can be EITHER a singleton `ColorToken` (e.g. a Material
|
|
201
|
-
* role like `surface`) OR a `ColorPalette` (a scale like `primary`'s
|
|
202
|
-
* 50-900 stops). Tools that emit only singleton colors (no scales)
|
|
203
|
-
* stay parseable.
|
|
127
|
+
* The colour block: five family anchors (`500` stated; the other stops
|
|
128
|
+
* derived unless stated) + the six layering roles, all REQUIRED; the
|
|
129
|
+
* derived roles (`neutral`, outlines, `on*` inks, containers,
|
|
130
|
+
* `tertiary`, `link`) are optional and win over derivation when stated.
|
|
204
131
|
*/
|
|
205
|
-
const ColorGroup = z.
|
|
132
|
+
const ColorGroup = z.strictObject({
|
|
133
|
+
primary: ColorPalette,
|
|
134
|
+
neutral: ColorPalette.optional(),
|
|
135
|
+
success: ColorPalette,
|
|
136
|
+
warning: ColorPalette,
|
|
137
|
+
error: ColorPalette,
|
|
138
|
+
info: ColorPalette,
|
|
139
|
+
ground: ColorToken,
|
|
140
|
+
onGround: ColorToken,
|
|
141
|
+
container: ColorToken,
|
|
142
|
+
onContainer: ColorToken,
|
|
143
|
+
sunken: ColorToken,
|
|
144
|
+
onSunken: ColorToken,
|
|
145
|
+
link: ColorToken.optional(),
|
|
146
|
+
outline: ColorToken.optional(),
|
|
147
|
+
outlineVariant: ColorToken.optional(),
|
|
148
|
+
onPrimary: ColorToken.optional(),
|
|
149
|
+
primaryContainer: ColorToken.optional(),
|
|
150
|
+
onPrimaryContainer: ColorToken.optional(),
|
|
151
|
+
onSuccess: ColorToken.optional(),
|
|
152
|
+
successContainer: ColorToken.optional(),
|
|
153
|
+
onSuccessContainer: ColorToken.optional(),
|
|
154
|
+
onWarning: ColorToken.optional(),
|
|
155
|
+
warningContainer: ColorToken.optional(),
|
|
156
|
+
onWarningContainer: ColorToken.optional(),
|
|
157
|
+
onError: ColorToken.optional(),
|
|
158
|
+
errorContainer: ColorToken.optional(),
|
|
159
|
+
onErrorContainer: ColorToken.optional(),
|
|
160
|
+
onInfo: ColorToken.optional(),
|
|
161
|
+
infoContainer: ColorToken.optional(),
|
|
162
|
+
onInfoContainer: ColorToken.optional(),
|
|
163
|
+
tertiary: ColorToken.optional(),
|
|
164
|
+
onTertiary: ColorToken.optional(),
|
|
165
|
+
tertiaryContainer: ColorToken.optional(),
|
|
166
|
+
onTertiaryContainer: ColorToken.optional(),
|
|
167
|
+
});
|
|
206
168
|
const SpacingGroup = z.record(z.string(), DimensionToken);
|
|
207
169
|
/**
|
|
208
|
-
* Font group — `family`
|
|
209
|
-
*
|
|
210
|
-
* `
|
|
211
|
-
* shape's `font.family` / `font.size` / `font.weight` /
|
|
212
|
-
* `font.lineHeight`.
|
|
213
|
-
*
|
|
214
|
-
* Inside `family`, `sans` is the only required slot — the canonical
|
|
215
|
-
* theme always carries a sans-serif default. `mono` and any other
|
|
216
|
-
* family slot (e.g. `serif`, `display`) are optional. The record
|
|
217
|
-
* stays open so authors can declare additional named families.
|
|
170
|
+
* Font group — `family` (the `sans` slot required; `mono`, `heading`
|
|
171
|
+
* and any other named family optional) and `weight`; optional
|
|
172
|
+
* `letterSpacing` and the ONE `ramp` the type scale is derived from.
|
|
218
173
|
*/
|
|
174
|
+
/** Font families: `sans` required; `mono`, `heading` and any other named family optional. */
|
|
219
175
|
const FontFamilyGroup = z.record(z.string(), FontFamilyToken).and(z.object({
|
|
220
176
|
sans: FontFamilyToken,
|
|
221
177
|
}));
|
|
222
178
|
const FontGroup = z.strictObject({
|
|
223
179
|
family: FontFamilyGroup,
|
|
224
|
-
size: z.record(z.string(), DimensionToken),
|
|
225
180
|
weight: z.record(z.string(), FontWeightToken),
|
|
226
|
-
|
|
181
|
+
letterSpacing: z
|
|
182
|
+
.strictObject({ body: DimensionToken.optional(), heading: DimensionToken.optional() })
|
|
183
|
+
.optional(),
|
|
184
|
+
ramp: z.strictObject({ base: DimensionToken, ratio: NumberToken }).optional(),
|
|
185
|
+
});
|
|
186
|
+
/** Declared font faces (ggui#987 §5). */
|
|
187
|
+
const TypographyGroup = z.strictObject({
|
|
188
|
+
/** The ONE face grammar — `@ggui-ai/protocol`'s `fontFaceDeclarationSchema` (ggui#1093): the document door and the AppTheme wire's `fonts` validate identically. */
|
|
189
|
+
faces: z.array(fontFaceDeclarationSchema).optional(),
|
|
227
190
|
});
|
|
228
191
|
/**
|
|
229
192
|
* Shape group — `radius` + `shadow`. Moved here from the previous
|
|
@@ -233,18 +196,162 @@ const FontGroup = z.strictObject({
|
|
|
233
196
|
const ShapeGroup = z.strictObject({
|
|
234
197
|
radius: z.record(z.string(), DimensionToken),
|
|
235
198
|
shadow: z.record(z.string(), ShadowToken),
|
|
199
|
+
border: z.strictObject({ width: DimensionToken.optional(), style: StringToken.optional() }).optional(),
|
|
200
|
+
});
|
|
201
|
+
/**
|
|
202
|
+
* Motion group — `transition` (required sub-record) + `keyframes`
|
|
203
|
+
* (optional). Durations and easings are not ladders any more: they ride
|
|
204
|
+
* each transition's own value.
|
|
205
|
+
*/
|
|
206
|
+
/**
|
|
207
|
+
* The HOST DESIGN LANGUAGE's document members (ggui#1093 P1b, #1075 Track C (a);
|
|
208
|
+
* grammar co-signed with rnd 2026-09-15). These are DOCUMENT tokens, never wire
|
|
209
|
+
* members: they reach the card through `deriveThemeVariables` → the overlay,
|
|
210
|
+
* which stays the ONE projection (`docs/principles/mcp-apps-compliance.md`).
|
|
211
|
+
* Every group is OPTIONAL and absent ⇒ the document parses exactly as before
|
|
212
|
+
* (INVARIANT 1: no declaration, byte-identical output).
|
|
213
|
+
*/
|
|
214
|
+
/**
|
|
215
|
+
* One type role. `size` is required; `leading` is a RATIO (`NumberToken`) and
|
|
216
|
+
* never a length — the projection writes `line-height: <ratio>`, which survives
|
|
217
|
+
* a size change, while a length leading breaks the moment the role's size moves.
|
|
218
|
+
*/
|
|
219
|
+
const TypeScaleRole = z.strictObject({
|
|
220
|
+
size: DimensionToken,
|
|
221
|
+
weight: FontWeightToken.optional(),
|
|
222
|
+
tracking: DimensionToken.optional(),
|
|
223
|
+
leading: NumberToken.optional(),
|
|
224
|
+
});
|
|
225
|
+
/**
|
|
226
|
+
* PRECEDENCE, and the ONE sentence that governs every member in this group
|
|
227
|
+
* (ggui#1093 R1, ruled with rnd 2026-09-16): **a host-design-language member
|
|
228
|
+
* overrides the base document's corresponding ladder.** A stated `typeScale`
|
|
229
|
+
* role beats the `font.ramp` stop that would have generated it; a stated
|
|
230
|
+
* `rhythm.base` beats the `spacing` stops it re-derives. Those two read as
|
|
231
|
+
* opposite directions (role over scale, scale over rungs) and are not: in
|
|
232
|
+
* both, the member the HOST measured wins over the ladder ggui supplied.
|
|
233
|
+
*
|
|
234
|
+
* The asymmetry that makes the sentence necessary: a `typeScale` role IS a
|
|
235
|
+
* rung, so it wins its own stop and leaves the others to the ramp — a harvest
|
|
236
|
+
* that measured only `h1` and `body` must not blank the rest. A `rhythm.base`
|
|
237
|
+
* is a statement about the SCALE, and every real document already carries
|
|
238
|
+
* spacing stops (the base theme supplies them), so "stated stop wins" would
|
|
239
|
+
* make the member inert on every document a harvest can produce.
|
|
240
|
+
*
|
|
241
|
+
* Nothing is lost by it: a document needing one bespoke step states no
|
|
242
|
+
* rhythm, and an app needing one pinned value has `AppTheme.cssVariables`,
|
|
243
|
+
* which overrides the whole projection at the overlay layer.
|
|
244
|
+
*
|
|
245
|
+
* CONTRACTED AHEAD OF THEIR CONSUMERS — read this before assuming a stated
|
|
246
|
+
* member changes a pixel. A member may be contracted before its consumer
|
|
247
|
+
* exists; a VARIABLE may not enter the consumed-token manifest before its
|
|
248
|
+
* consumer exists (a name nothing reads is coverage debt, not a feature).
|
|
249
|
+
* Today that makes three of these members STATED, STORED and NOT YET
|
|
250
|
+
* VISIBLE: the role-named type families and `scrim` arrive with their
|
|
251
|
+
* consumers (ggui#1075 Track C (b), ggui#1083), and `motion` with
|
|
252
|
+
* **ggui#1106** — the card's motion is build-time constants in the
|
|
253
|
+
* primitives, so no `--ggui-motion-*` variable exists to project onto yet
|
|
254
|
+
* (the manifest carries 122 tokens and none of them is motion). A writer
|
|
255
|
+
* may declare all of them now, which is the point of contracting first;
|
|
256
|
+
* nobody should read the contract as shipping the behaviour.
|
|
257
|
+
*/
|
|
258
|
+
/** The five type roles; `display` is the poster-scale role a host page leads with. */
|
|
259
|
+
const TypeScaleGroup = z.strictObject({
|
|
260
|
+
display: TypeScaleRole.optional(),
|
|
261
|
+
h1: TypeScaleRole.optional(),
|
|
262
|
+
h2: TypeScaleRole.optional(),
|
|
263
|
+
body: TypeScaleRole.optional(),
|
|
264
|
+
label: TypeScaleRole.optional(),
|
|
265
|
+
});
|
|
266
|
+
/**
|
|
267
|
+
* The spacing base a rhythm re-derives its scale from: a positive length in
|
|
268
|
+
* `px` (2–16) or `rem` / `em` (0.125–1, the same range at a 16px root). Other
|
|
269
|
+
* units and `calc()` are refused at the door — a base the reader cannot
|
|
270
|
+
* multiply is not a base.
|
|
271
|
+
*/
|
|
272
|
+
const RHYTHM_BASE_RE = /^(\d+(?:\.\d+)?)(px|rem|em)$/;
|
|
273
|
+
function isRhythmBase(value) {
|
|
274
|
+
const m = RHYTHM_BASE_RE.exec(value);
|
|
275
|
+
if (m === null)
|
|
276
|
+
return false;
|
|
277
|
+
const n = Number(m[1]);
|
|
278
|
+
return m[2] === 'px' ? n >= 2 && n <= 16 : n >= 0.125 && n <= 1;
|
|
279
|
+
}
|
|
280
|
+
const RhythmBaseToken = DimensionToken.refine((t) => isRhythmBase(t.$value), 'rhythm.base must be a length in px (2–16) or rem/em (0.125–1)');
|
|
281
|
+
/** `base` re-derives the spacing scale; `section` and `inset` are named steps. */
|
|
282
|
+
const RhythmGroup = z.strictObject({
|
|
283
|
+
base: RhythmBaseToken,
|
|
284
|
+
section: DimensionToken.optional(),
|
|
285
|
+
inset: DimensionToken.optional(),
|
|
236
286
|
});
|
|
287
|
+
/** The CSS easing keywords a document may name. */
|
|
288
|
+
const CSS_EASING_KEYWORDS = new Set(['linear', 'ease', 'ease-in', 'ease-out', 'ease-in-out', 'step-start', 'step-end']);
|
|
289
|
+
const CUBIC_BEZIER_RE = /^cubic-bezier\(\s*-?\d*\.?\d+\s*(?:,\s*-?\d*\.?\d+\s*){3}\)$/;
|
|
290
|
+
const STEPS_RE = /^steps\(\s*\d+\s*(?:,\s*(?:jump-start|jump-end|jump-none|jump-both|start|end)\s*)?\)$/;
|
|
237
291
|
/**
|
|
238
|
-
*
|
|
239
|
-
*
|
|
240
|
-
*
|
|
241
|
-
* tools (Figma Tokens, Style Dictionary) don't always emit them.
|
|
292
|
+
* An easing a projection can emit verbatim: a CSS keyword, a well-formed
|
|
293
|
+
* `cubic-bezier(a,b,c,d)`, or `steps(n[, position])`. Validated HERE so no
|
|
294
|
+
* reader has to clamp a string it cannot trust.
|
|
242
295
|
*/
|
|
296
|
+
function isCssEasing(value) {
|
|
297
|
+
return CSS_EASING_KEYWORDS.has(value) || CUBIC_BEZIER_RE.test(value) || STEPS_RE.test(value);
|
|
298
|
+
}
|
|
299
|
+
const EasingToken = z.strictObject({
|
|
300
|
+
$type: z.string().min(1),
|
|
301
|
+
$value: z.string().min(1).refine(isCssEasing, 'easing must be a CSS keyword, cubic-bezier(a,b,c,d) or steps(n[, position])'),
|
|
302
|
+
$description: z.string().optional(),
|
|
303
|
+
});
|
|
304
|
+
/**
|
|
305
|
+
* The per-app motion TEMPO OVERRIDE (ggui#1093 P1c; reverses the layer-1
|
|
306
|
+
* retirement of the theming spec §2.3, co-signed with rnd 2026-09-16).
|
|
307
|
+
*
|
|
308
|
+
* Layer-1 keeps the motion SCALE — the durations and easings the design
|
|
309
|
+
* system ships, which every card gets by default and no app may redefine
|
|
310
|
+
* wholesale. What a document may state is a BOUNDED tempo override, for a
|
|
311
|
+
* card that has to sit inside someone else's site: exactly the standing
|
|
312
|
+
* `palette` has (per-app forever, without that making colour "not
|
|
313
|
+
* layer-1"). The retired FREE-MAP ladders stay refused — three duration
|
|
314
|
+
* steps and three easing roles are admitted, and nothing else.
|
|
315
|
+
*/
|
|
316
|
+
const MotionDurationGroup = z.strictObject({
|
|
317
|
+
fast: DurationToken.optional(),
|
|
318
|
+
base: DurationToken.optional(),
|
|
319
|
+
slow: DurationToken.optional(),
|
|
320
|
+
});
|
|
321
|
+
const MotionEasingGroup = z.strictObject({
|
|
322
|
+
standard: EasingToken.optional(),
|
|
323
|
+
emphasized: EasingToken.optional(),
|
|
324
|
+
exit: EasingToken.optional(),
|
|
325
|
+
});
|
|
326
|
+
/**
|
|
327
|
+
* The scrim a host page's ground shows through (ggui#1083): `tone` is a word
|
|
328
|
+
* (`light` / `dark`) or a stated colour token, `opacity` a 0..1 ratio, `blur` a
|
|
329
|
+
* length. Absent ⇒ the completion derives it from the ground pair, so a
|
|
330
|
+
* builder who declares nothing still gets a scrim that matches their page.
|
|
331
|
+
*/
|
|
332
|
+
const ScrimGroup = z.strictObject({
|
|
333
|
+
tone: z.union([z.enum(['light', 'dark']), ColorToken]),
|
|
334
|
+
opacity: NumberToken.refine((t) => t.$value >= 0 && t.$value <= 1, 'scrim.opacity must be a ratio between 0 and 1'),
|
|
335
|
+
blur: DimensionToken,
|
|
336
|
+
});
|
|
243
337
|
const MotionGroup = z.strictObject({
|
|
244
|
-
duration: z.record(z.string(), DurationToken),
|
|
245
|
-
easing: z.record(z.string(), CubicBezierToken).optional(),
|
|
246
338
|
transition: z.record(z.string(), TransitionToken),
|
|
247
|
-
keyframes: z.record(z.string(),
|
|
339
|
+
keyframes: z.record(z.string(), StringToken).optional(),
|
|
340
|
+
/** Per-app tempo override — three steps, bounded (ggui#1093 P1c). */
|
|
341
|
+
duration: MotionDurationGroup.optional(),
|
|
342
|
+
/** Per-app easing override — three roles, each value validated at the door (ggui#1093 P1c). */
|
|
343
|
+
easing: MotionEasingGroup.optional(),
|
|
344
|
+
/**
|
|
345
|
+
* What the composer does under `prefers-reduced-motion: reduce`.
|
|
346
|
+
*
|
|
347
|
+
* Two defaults that must never be read as disagreeing (rnd, ggui#1093
|
|
348
|
+
* P1c): the DOOR does not stamp a value — absent stays absent, so the
|
|
349
|
+
* document never carries something its author did not write — and the
|
|
350
|
+
* COMPOSER's default is `respect`: it emits the reduced-motion rule
|
|
351
|
+
* unless a document explicitly says `ignore`. Absence therefore means
|
|
352
|
+
* "respect" downstream, without the wire inventing it.
|
|
353
|
+
*/
|
|
354
|
+
reduce: z.enum(['respect', 'ignore']).optional(),
|
|
248
355
|
});
|
|
249
356
|
const AccessibilityGroup = z.strictObject({
|
|
250
357
|
focusRing: z
|
|
@@ -276,8 +383,9 @@ const ZIndexGroup = z.record(z.string(), NumberToken);
|
|
|
276
383
|
*/
|
|
277
384
|
const MetadataGroup = z.strictObject({
|
|
278
385
|
font: z.string().min(1).optional(),
|
|
279
|
-
fontUrl: z.string().min(1).optional(),
|
|
280
386
|
philosophy: z.string().min(1).optional(),
|
|
387
|
+
/** The embedding host draws the card silhouette; the renderer suppresses root-children strokes. */
|
|
388
|
+
frameless: z.boolean().optional(),
|
|
281
389
|
});
|
|
282
390
|
// ─── Root document ───────────────────────────────────────────────────
|
|
283
391
|
/**
|
|
@@ -299,7 +407,7 @@ const MetadataGroup = z.strictObject({
|
|
|
299
407
|
* allow-listed (DTCG metadata); anything else is a typo. Additive
|
|
300
408
|
* slices add new fields here under `schema: '1'`.
|
|
301
409
|
*/
|
|
302
|
-
export const
|
|
410
|
+
export const ThemeDocumentV2 = z.strictObject({
|
|
303
411
|
/** Optional DTCG spec URL. Not validated — the spec hasn't frozen
|
|
304
412
|
* a canonical URL and hand-authored themes often omit it. */
|
|
305
413
|
$schema: z.string().min(1).optional(),
|
|
@@ -320,11 +428,27 @@ export const ThemeDocumentV1 = z.strictObject({
|
|
|
320
428
|
spacing: SpacingGroup,
|
|
321
429
|
/** Font tokens — `family` / `size` / `weight` / `lineHeight`. Required. */
|
|
322
430
|
font: FontGroup,
|
|
431
|
+
/** Declared font faces (ggui#987 §5) — https `src` only; the card installs them. */
|
|
432
|
+
typography: TypographyGroup.optional(),
|
|
323
433
|
/** Shape tokens — `radius` + `shadow`. Required. */
|
|
324
434
|
shape: ShapeGroup,
|
|
325
|
-
/**
|
|
326
|
-
* `
|
|
435
|
+
/** The five type ROLES of the host's design language (ggui#1093 P1b):
|
|
436
|
+
* display / h1 / h2 / body / label, each `size` + optional weight,
|
|
437
|
+
* tracking and a RATIO leading. Role-based, beside `font.ramp`'s
|
|
438
|
+
* size-stop ladder — the projection reads a role when it is stated and
|
|
439
|
+
* falls to the ramp when it is not. Optional. */
|
|
440
|
+
typeScale: TypeScaleGroup.optional(),
|
|
441
|
+
/** The host's spacing rhythm (ggui#1093 P1b): `base` re-derives the scale,
|
|
442
|
+
* `section` / `inset` are named steps. Optional. */
|
|
443
|
+
rhythm: RhythmGroup.optional(),
|
|
444
|
+
/** Motion tokens — `transition` (required sub-record), `keyframes`, and
|
|
445
|
+
* the bounded per-app TEMPO OVERRIDE (ggui#1093 P1c): `duration`
|
|
446
|
+
* (fast/base/slow), `easing` (standard/emphasized/exit) and `reduce`.
|
|
447
|
+
* The motion SCALE stays layer-1; the free-map ladders stay refused. */
|
|
327
448
|
motion: MotionGroup.optional(),
|
|
449
|
+
/** The scrim between the host page's ground and the card (ggui#1093 P1b,
|
|
450
|
+
* ggui#1083). Absent ⇒ derived from the ground pair. Optional. */
|
|
451
|
+
scrim: ScrimGroup.optional(),
|
|
328
452
|
/** Accessibility tokens (focus ring, reduced motion, high contrast).
|
|
329
453
|
* Optional. */
|
|
330
454
|
accessibility: AccessibilityGroup.optional(),
|
|
@@ -336,7 +460,7 @@ export const ThemeDocumentV1 = z.strictObject({
|
|
|
336
460
|
* Throws a `ZodError` with human-readable issues on invalid input.
|
|
337
461
|
*/
|
|
338
462
|
export function parseThemeDocument(raw) {
|
|
339
|
-
return
|
|
463
|
+
return ThemeDocumentV2.parse(raw);
|
|
340
464
|
}
|
|
341
465
|
/**
|
|
342
466
|
* Safe-parse variant — returns a discriminated `z.safeParse` result.
|
|
@@ -344,5 +468,69 @@ export function parseThemeDocument(raw) {
|
|
|
344
468
|
* list without try/catch.
|
|
345
469
|
*/
|
|
346
470
|
export function safeParseThemeDocument(raw) {
|
|
347
|
-
return
|
|
471
|
+
return ThemeDocumentV2.safeParse(raw);
|
|
472
|
+
}
|
|
473
|
+
// ─── Normalisation → the producer's document ─────────────────────────
|
|
474
|
+
function stringToken(token, value) {
|
|
475
|
+
return { $type: token.$type, $value: value, ...(token.$description !== undefined ? { $description: token.$description } : {}) };
|
|
476
|
+
}
|
|
477
|
+
function mapRecord(record, f) {
|
|
478
|
+
return Object.fromEntries(Object.entries(record).map(([k, v]) => [k, f(v)]));
|
|
479
|
+
}
|
|
480
|
+
/**
|
|
481
|
+
* Turn a parsed document into the EXACT {@link DtcgTheme} the one
|
|
482
|
+
* producer (`deriveThemeVariables`) consumes:
|
|
483
|
+
*
|
|
484
|
+
* - names the document (`$name` ⇒ `'custom'`, `$description` ⇒ `''`);
|
|
485
|
+
* - drops the DTCG envelope fields the producer does not read
|
|
486
|
+
* (`$schema`, `$version`);
|
|
487
|
+
* - flattens the DTCG object forms to the CSS strings the producer
|
|
488
|
+
* reads: array font stacks → `'Inter, system-ui'`, numeric weights →
|
|
489
|
+
* `'400'`, structured shadows → `'0 1px 2px 0 …'`, structured
|
|
490
|
+
* transitions → `'<property ?? all> <duration> <timingFunction>'`;
|
|
491
|
+
* - fills the optional groups (`motion`, `accessibility`, `zIndex` and
|
|
492
|
+
* their sub-records) from the shipped default, so an external tool's
|
|
493
|
+
* colours-and-dimensions export still derives every consumed token.
|
|
494
|
+
*
|
|
495
|
+
* The authored roles, anchors and faces pass through verbatim.
|
|
496
|
+
*/
|
|
497
|
+
export function normalizeThemeDocument(doc) {
|
|
498
|
+
const family = doc.font.family;
|
|
499
|
+
const fontFamily = (t) => stringToken(t, Array.isArray(t.$value) ? t.$value.join(', ') : t.$value);
|
|
500
|
+
const accessibility = doc.accessibility;
|
|
501
|
+
return {
|
|
502
|
+
$name: doc.$name ?? 'custom',
|
|
503
|
+
$description: doc.$description ?? '',
|
|
504
|
+
...(doc.$metadata !== undefined ? { $metadata: doc.$metadata } : {}),
|
|
505
|
+
color: doc.color,
|
|
506
|
+
font: {
|
|
507
|
+
family: { ...mapRecord(family, fontFamily), sans: fontFamily(family.sans) },
|
|
508
|
+
weight: mapRecord(doc.font.weight, (t) => stringToken(t, String(t.$value))),
|
|
509
|
+
...(doc.font.letterSpacing !== undefined ? { letterSpacing: doc.font.letterSpacing } : {}),
|
|
510
|
+
...(doc.font.ramp !== undefined ? { ramp: doc.font.ramp } : {}),
|
|
511
|
+
},
|
|
512
|
+
...(doc.typography !== undefined ? { typography: doc.typography } : {}),
|
|
513
|
+
spacing: doc.spacing,
|
|
514
|
+
shape: {
|
|
515
|
+
radius: doc.shape.radius,
|
|
516
|
+
shadow: mapRecord(doc.shape.shadow, (t) => stringToken(t, typeof t.$value === 'string'
|
|
517
|
+
? t.$value
|
|
518
|
+
: `${t.$value.offsetX} ${t.$value.offsetY} ${t.$value.blur} ${t.$value.spread} ${t.$value.color}`)),
|
|
519
|
+
...(doc.shape.border !== undefined ? { border: doc.shape.border } : {}),
|
|
520
|
+
},
|
|
521
|
+
motion: doc.motion === undefined
|
|
522
|
+
? lightTheme.motion
|
|
523
|
+
: {
|
|
524
|
+
transition: mapRecord(doc.motion.transition, (t) => stringToken(t, typeof t.$value === 'string'
|
|
525
|
+
? t.$value
|
|
526
|
+
: `${t.$value.property ?? 'all'} ${t.$value.duration} ${t.$value.timingFunction}`)),
|
|
527
|
+
keyframes: doc.motion.keyframes ?? lightTheme.motion.keyframes,
|
|
528
|
+
},
|
|
529
|
+
accessibility: {
|
|
530
|
+
focusRing: accessibility?.focusRing ?? lightTheme.accessibility.focusRing,
|
|
531
|
+
reducedMotion: accessibility?.reducedMotion ?? lightTheme.accessibility.reducedMotion,
|
|
532
|
+
highContrast: accessibility?.highContrast ?? lightTheme.accessibility.highContrast,
|
|
533
|
+
},
|
|
534
|
+
zIndex: doc.zIndex ?? lightTheme.zIndex,
|
|
535
|
+
};
|
|
348
536
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ggui-ai/project-config",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.0",
|
|
4
4
|
"description": "Schema and loader for `ggui.json` — the portable app/agent manifest for the ggui protocol. Vendor-neutral. Consumed by the `ggui` CLI and any host that reconstitutes an agent from the manifest.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ggui",
|
|
@@ -33,8 +33,8 @@
|
|
|
33
33
|
"dependencies": {
|
|
34
34
|
"tinyglobby": "^0.2.16",
|
|
35
35
|
"zod": "^4.3.6",
|
|
36
|
-
"@ggui-ai/design": "0.
|
|
37
|
-
"@ggui-ai/protocol": "0.
|
|
36
|
+
"@ggui-ai/design": "0.17.0",
|
|
37
|
+
"@ggui-ai/protocol": "0.17.0"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
40
|
"@types/node": "^24.0.0",
|