@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/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. It is one of the open file-format
6
- * surfaces in the ggui manifest model, alongside
7
- * `ggui.primitives.json` and `ggui.ui.json`.
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
- * **Shape parity with the internal `DtcgTheme`.** This v1 schema is
10
- * deliberately the same shape as `@ggui-ai/design`'s canonical
11
- * {@link DtcgTheme} — `color`/`font`/`spacing`/`shape`/`motion`/
12
- * `accessibility`/`zIndex` — so an authored `theme.json`
13
- * uses the same vocabulary the curated registry themes (light/dark/
14
- * premium-*) use. Previously this schema was Tailwind-style flat
15
- * (`typography`/`radius`/`shadow` at root); the v1 schema now mirrors
16
- * the internal shape to remove the divergence.
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 compatibility.** External tooling (Figma Tokens,
19
- * Style Dictionary, Tokens Studio) commonly emits only a subset of
20
- * the canonical shape. Only the original required groups (`color`,
21
- * `font`, `spacing`, `shape`) stay required; `motion`,
22
- * `accessibility`, `zIndex`, and the new DTCG metadata fields
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* DTCG tokens become CSS variables → `@ggui-ai/design`'s
48
- * `generateCssVariables` (stays over there; this schema does not
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
- * 1. Additive only within `schema: '1'`. New optional fields must
59
- * default to no-op behaviour so older tooling ignores them.
60
- * 2. Framework-neutral + host-neutral. No vendor names in enum
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
- const LineHeightToken = z.union([
158
- z.strictObject({
159
- $type: z.literal('number'),
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.unknown(),
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
- * Color group — two-tier: `{palette}` records for scales (primary /
192
- * neutral / semantic scales) plus single-token semantic roles
193
- * (surface / onSurface / outline / …) at the top level.
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.record(z.string(), z.union([ColorToken, ColorPalette]));
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` / `size` / `weight` / `lineHeight` records.
209
- * Renamed from `typography` (previously: `typography.fontFamily`,
210
- * `typography.fontSize`, …) to mirror the internal `DtcgTheme`
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
- lineHeight: z.record(z.string(), LineHeightToken),
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
- * Motion group — `duration` / `easing` / `transition` / `keyframes`.
239
- * Moved here from the previous top-level `duration` and `transition`
240
- * fields. `easing` and `keyframes` are optional because external
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(), PermissiveToken).optional(),
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 ThemeDocumentV1 = z.strictObject({
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
- /** Motion tokens — `duration` + `transition` (required sub-records),
326
- * `easing` + `keyframes` (optional sub-records). Optional. */
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 ThemeDocumentV1.parse(raw);
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 ThemeDocumentV1.safeParse(raw);
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.15.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.15.0",
37
- "@ggui-ai/protocol": "0.15.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",