@verevoir/design-gate 0.1.0 → 0.2.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.
@@ -0,0 +1,272 @@
1
+ import { z } from 'zod';
2
+ import { validateDtcg } from '../design/dtcg.mjs';
3
+ // DESIGN SURFACE — the common interlingua you go in and out of. It reads like a
4
+ // design system's own documentation: not just a component catalogue, but how they
5
+ // compose, and the softer, load-bearing qualities a design system actually turns on
6
+ // — its VOICE, its FEEL, its PRINCIPLES. Those aren't decoration: across an
7
+ // ecosystem the real question is "do these surfaces cohere as one design language,
8
+ // or has each drifted?", and you can only answer it if voice/feel/principles are
9
+ // first-class, carry provenance, and synthesise + deviate the same way components do.
10
+ //
11
+ // Stance (first stab, expect the schema to move under real surfaces):
12
+ // - PRIMARY artefact / source of truth. Doc renders it, overlay diffs it,
13
+ // synthesis merges it, export projects it.
14
+ // - Sits directly above the DTCG token plane (a variant links down to tokens).
15
+ // - Everything repeated carries PROVENANCE — the same button, or the same voice,
16
+ // seen in two apps is one entry with two provenances. The cross-source map is
17
+ // the point.
18
+ // - CLOSED under synthesis: a single-source surface has one source and one voice;
19
+ // a landscape has many, same type. So audit-of-existing and proposed-new are the
20
+ // same type, and you fold an ecosystem with `sources.reduce(merge)`.
21
+ export const SCHEMA_URL = 'https://verevoir.dev/schemas/design-surface/v0';
22
+ /** Where something was found. First-class + repeated. `confidence` is the adapter's
23
+ * own reading confidence (a screenshot read is less sure than a parsed repo). */
24
+ export const Provenance = z.object({
25
+ sourceId: z.string(),
26
+ kind: z.enum(['screenshot', 'flat', 'figma', 'repo', 'website', 'claude-design', 'v0']),
27
+ locator: z.string(),
28
+ confidence: z.number().min(0).max(1).optional(),
29
+ });
30
+ // ── the soft, load-bearing design-system qualities ───────────────────────────
31
+ /** How the product SPEAKS — tone, personality, language. Attributes are the axes a
32
+ * design system names ("plain-spoken", "authoritative", "warm"); examples are real
33
+ * copy that shows it. Per-source, so the landscape can compare voices across apps. */
34
+ export const Voice = z.object({
35
+ sourceId: z.string().optional(), // set once attributed to a source; a synthesised/proposed voice may omit it
36
+ summary: z.string(),
37
+ attributes: z.array(z.string()).default([]),
38
+ examples: z.array(z.string()).default([]),
39
+ provenance: z.array(Provenance).default([]),
40
+ });
41
+ /** How the product FEELS — the aesthetic character. Attributes are the axes
42
+ * ("minimal", "high-contrast", "dense", "playful"); references point at what
43
+ * evidences it (a screenshot, a page). */
44
+ export const Feel = z.object({
45
+ sourceId: z.string().optional(),
46
+ summary: z.string(),
47
+ attributes: z.array(z.string()).default([]),
48
+ references: z.array(z.string()).default([]),
49
+ provenance: z.array(Provenance).default([]),
50
+ });
51
+ /** A design PRINCIPLE the surface holds — a rule with teeth ("no decorative borders
52
+ * or shadows", "one primary action per view"). Deviations from these are the
53
+ * high-value findings of an overlay. */
54
+ export const Principle = z.object({
55
+ id: z.string(),
56
+ statement: z.string(),
57
+ rationale: z.string().optional(),
58
+ provenance: z.array(Provenance).min(1),
59
+ });
60
+ /** The project CONTEXT a surface must serve — the yardstick the overlay judges
61
+ * against, so the design is never evaluated in a vacuum. A young audience should
62
+ * show TENSION with something oak-panelled: `audience` + `positioning` say what it is
63
+ * FOR, and `tensions` name what it must NOT drift into. Pulled from the aigency
64
+ * project context for an audit; authored for a proposal; carried forward on an
65
+ * accepted surface. This is the aigency plug: the design surface answers to the same
66
+ * project intent the code and the ADRs do. */
67
+ export const Intent = z.object({
68
+ audience: z.string().optional(),
69
+ positioning: z.string().optional(), // "modern, approachable" / "authoritative" / "playful but trustworthy"
70
+ tensions: z.array(z.string()).default([]), // what it must NOT be — "oak-panelled", "stuffy", "enterprise-grey"
71
+ notes: z.string().optional(),
72
+ });
73
+ /** Where a surface sits in its lifecycle. An `accepted` surface IS project context —
74
+ * persisted in the substrate, versioned, and developed over time exactly as the code
75
+ * is; it's not a one-off report. */
76
+ export const SurfaceStatus = z.enum(['audit', 'landscape', 'proposal', 'accepted']);
77
+ /** The design TOKENS a surface uses — the DTCG set (colour / type / space / …), the
78
+ * bottom layer this whole surface sits on. This is where the earlier design-token
79
+ * work FOLDS IN, not as a separate thing: `ingest-style-guide` is a token importer,
80
+ * `generate-design-tokens` a token exporter, and the DTCG design-gate is the token
81
+ * half of `validateSurface`. Per-source + collected (like voices), so the landscape
82
+ * shows token DIVERGENCE across the ecosystem (Nexus's blue vs Timesheets's blue).
83
+ * `dtcg` is the raw DTCG object; the zod schema keeps it an opaque record, and
84
+ * `validateSurface` runs the shared design-gate (`validateDtcg`) over it — the same
85
+ * zero-dependency check a produced pack runs in CI — so the token layer is gated
86
+ * exactly like the rest of the surface (STDIO-524). */
87
+ export const TokenSet = z.object({
88
+ sourceId: z.string().optional(),
89
+ dtcg: z.record(z.unknown()), // { color: {...}, spacing: {...}, typography: {...}, ... }
90
+ provenance: z.array(Provenance).default([]),
91
+ });
92
+ // ── the component catalogue ──────────────────────────────────────────────────
93
+ export const Variant = z.object({
94
+ name: z.string(),
95
+ description: z.string().optional(),
96
+ tokens: z.record(z.string()).optional(), // the link DOWN to the DTCG token layer
97
+ });
98
+ export const PropSpec = z.object({
99
+ name: z.string(),
100
+ type: z.string().optional(),
101
+ required: z.boolean().optional(),
102
+ description: z.string().optional(),
103
+ });
104
+ export const ComponentKind = z.enum(['atom', 'molecule', 'organism', 'layout', 'page']);
105
+ export const Component = z.object({
106
+ id: z.string(),
107
+ name: z.string(),
108
+ kind: ComponentKind,
109
+ role: z.string().optional(),
110
+ description: z.string().optional(),
111
+ composition: z.string().optional(), // prose: how it composes / when to reach for it (design-system doc voice)
112
+ variants: z.array(Variant).default([]),
113
+ props: z.array(PropSpec).default([]),
114
+ states: z.array(z.string()).default([]),
115
+ composes: z.array(z.string()).default([]), // ids it is built from
116
+ a11y: z.array(z.string()).default([]),
117
+ evidence: z.array(z.string()).default([]),
118
+ provenance: z.array(Provenance).min(1), // never empty — no provenance = hallucination
119
+ });
120
+ export const Pattern = z.object({
121
+ id: z.string(),
122
+ name: z.string(),
123
+ description: z.string().optional(),
124
+ components: z.array(z.string()).default([]),
125
+ provenance: z.array(Provenance).min(1),
126
+ });
127
+ export const SourceRef = z.object({
128
+ id: z.string(),
129
+ kind: z.enum(['screenshot', 'flat', 'figma', 'repo', 'website', 'claude-design', 'v0']),
130
+ locator: z.string(),
131
+ label: z.string().optional(),
132
+ });
133
+ /** The surface. `sources`/`voices`/`feels` are length 1 for a single-source surface,
134
+ * N for a synthesised landscape — same type either way (closed under synthesis). */
135
+ export const DesignSurface = z.object({
136
+ $schema: z.literal(SCHEMA_URL).default(SCHEMA_URL),
137
+ status: SurfaceStatus.default('audit'),
138
+ intent: Intent.optional(), // the project context this surface serves / is judged against
139
+ sources: z.array(SourceRef).min(1),
140
+ voices: z.array(Voice).default([]),
141
+ feels: z.array(Feel).default([]),
142
+ principles: z.array(Principle).default([]),
143
+ tokens: z.array(TokenSet).default([]), // the DTCG base layer — the design-token work, folded in
144
+ components: z.array(Component).default([]),
145
+ patterns: z.array(Pattern).default([]),
146
+ generatedAt: z.string().optional(),
147
+ });
148
+ /** Parse against the schema, then the semantic checks it can't express: every
149
+ * `composes` / pattern `components` id resolves, and every `tokens` layer is valid
150
+ * DTCG (delegated to the shared design-gate). Deterministic gate, precise patchable
151
+ * findings — the surface's own half plus the token half of the DTCG gate. */
152
+ export function validateSurface(input) {
153
+ const parsed = DesignSurface.safeParse(input);
154
+ if (!parsed.success) {
155
+ return {
156
+ ok: false,
157
+ findings: parsed.error.issues.map((i) => ({
158
+ kind: 'SCHEMA',
159
+ where: i.path.join('.') || '(root)',
160
+ message: i.message,
161
+ })),
162
+ };
163
+ }
164
+ const s = parsed.data;
165
+ const findings = [];
166
+ const ids = new Set(s.components.map((c) => c.id));
167
+ for (const c of s.components)
168
+ for (const dep of c.composes)
169
+ if (!ids.has(dep))
170
+ findings.push({
171
+ kind: 'DANGLING_REF',
172
+ where: `components.${c.id}.composes`,
173
+ message: `composes "${dep}" but no component with that id exists`,
174
+ });
175
+ for (const p of s.patterns)
176
+ for (const dep of p.components)
177
+ if (!ids.has(dep))
178
+ findings.push({
179
+ kind: 'DANGLING_REF',
180
+ where: `patterns.${p.id}.components`,
181
+ message: `references component "${dep}" but no component with that id exists`,
182
+ });
183
+ // the token half of the gate: the same zero-dependency DTCG check a produced pack
184
+ // runs in CI, applied here so a malformed token layer fails the surface too.
185
+ s.tokens.forEach((t, i) => {
186
+ for (const f of validateDtcg(t.dtcg))
187
+ findings.push({
188
+ kind: 'DTCG',
189
+ where: `tokens.${t.sourceId ?? i}.${f.path}`,
190
+ message: `${f.code}: ${f.message}`,
191
+ });
192
+ });
193
+ return { ok: findings.length === 0, findings };
194
+ }
195
+ // ── synthesis (the merge — closed over the schema) ───────────────────────────
196
+ function dedupeBy(items, key) {
197
+ const seen = new Map();
198
+ for (const it of items)
199
+ if (!seen.has(key(it)))
200
+ seen.set(key(it), it);
201
+ return [...seen.values()];
202
+ }
203
+ function foldById(items, combine) {
204
+ const order = [];
205
+ const byId = new Map();
206
+ for (const it of items) {
207
+ const cur = byId.get(it.id);
208
+ if (cur)
209
+ byId.set(it.id, combine(cur, it));
210
+ else {
211
+ byId.set(it.id, it);
212
+ order.push(it.id);
213
+ }
214
+ }
215
+ return order.map((id) => byId.get(id));
216
+ }
217
+ const provKey = (p) => `${p.sourceId}:${p.kind}:${p.locator}`;
218
+ function mergeComponent(a, b) {
219
+ return {
220
+ ...a,
221
+ description: a.description ?? b.description,
222
+ role: a.role ?? b.role,
223
+ composition: a.composition ?? b.composition,
224
+ variants: dedupeBy([...a.variants, ...b.variants], (v) => v.name),
225
+ props: dedupeBy([...a.props, ...b.props], (p) => p.name),
226
+ states: [...new Set([...a.states, ...b.states])],
227
+ composes: [...new Set([...a.composes, ...b.composes])],
228
+ a11y: [...new Set([...a.a11y, ...b.a11y])],
229
+ evidence: [...new Set([...a.evidence, ...b.evidence])],
230
+ provenance: dedupeBy([...a.provenance, ...b.provenance], provKey),
231
+ };
232
+ }
233
+ function mergePattern(a, b) {
234
+ return {
235
+ ...a,
236
+ description: a.description ?? b.description,
237
+ components: [...new Set([...a.components, ...b.components])],
238
+ provenance: dedupeBy([...a.provenance, ...b.provenance], provKey),
239
+ };
240
+ }
241
+ /** Merge two surfaces, CLOSED over the schema. Components/patterns fold by id (same
242
+ * id → one entry, unioned provenance). Voices/feels/principles are COLLECTED (kept
243
+ * per-source, deduped) rather than folded into one — because the landscape's job is
244
+ * to let you SEE the spread ("Nexus feels dense, .com feels airy"); reconciling them
245
+ * into a single unified voice/feel is a PROPOSE-stage decision, not a merge. So the
246
+ * merge preserves the divergence that the overlay then reasons about. */
247
+ export function mergeSurfaces(a, b) {
248
+ return {
249
+ $schema: SCHEMA_URL,
250
+ // merging surfaces yields a landscape; the project intent is shared, so carry it
251
+ status: 'landscape',
252
+ intent: a.intent ?? b.intent,
253
+ sources: dedupeBy([...a.sources, ...b.sources], (s) => s.id),
254
+ voices: dedupeBy([...a.voices, ...b.voices], (v) => `${v.sourceId ?? ''}:${v.summary}`),
255
+ feels: dedupeBy([...a.feels, ...b.feels], (f) => `${f.sourceId ?? ''}:${f.summary}`),
256
+ principles: foldById([...a.principles, ...b.principles], (x, y) => ({
257
+ ...x,
258
+ rationale: x.rationale ?? y.rationale,
259
+ provenance: dedupeBy([...x.provenance, ...y.provenance], provKey),
260
+ })),
261
+ // token sets collected per-source (like voices), so the landscape shows token drift
262
+ tokens: dedupeBy([...a.tokens, ...b.tokens], (t) => t.sourceId ?? JSON.stringify(t.dtcg)),
263
+ components: foldById([...a.components, ...b.components], mergeComponent),
264
+ patterns: foldById([...a.patterns, ...b.patterns], mergePattern),
265
+ generatedAt: a.generatedAt ?? b.generatedAt,
266
+ };
267
+ }
268
+ export function synthesise(surfaces) {
269
+ if (surfaces.length === 0)
270
+ throw new Error('synthesise: need at least one surface');
271
+ return surfaces.reduce(mergeSurfaces);
272
+ }