openuispec 0.2.18 → 0.2.20

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.
Files changed (45) hide show
  1. package/README.md +2 -10
  2. package/dist/check/audit.js +392 -0
  3. package/dist/check/index.js +216 -0
  4. package/dist/cli/configure-target.js +391 -0
  5. package/dist/cli/index.js +510 -0
  6. package/dist/cli/init.js +1047 -0
  7. package/dist/drift/index.js +903 -0
  8. package/dist/mcp-server/index.js +886 -0
  9. package/dist/mcp-server/preview-render.js +1761 -0
  10. package/dist/mcp-server/preview.js +233 -0
  11. package/dist/mcp-server/screenshot-android.js +458 -0
  12. package/dist/mcp-server/screenshot-ios.js +639 -0
  13. package/dist/mcp-server/screenshot-shared.js +180 -0
  14. package/dist/mcp-server/screenshot.js +459 -0
  15. package/dist/prepare/index.js +1216 -0
  16. package/dist/runtime/package-paths.js +33 -0
  17. package/dist/schema/semantic-lint.js +564 -0
  18. package/dist/schema/validate.js +689 -0
  19. package/dist/status/index.js +194 -0
  20. package/docs/images/how-it-works.svg +56 -0
  21. package/docs/images/workflows.svg +76 -0
  22. package/package.json +12 -13
  23. package/check/audit.ts +0 -426
  24. package/check/index.ts +0 -320
  25. package/cli/configure-target.ts +0 -523
  26. package/cli/index.ts +0 -537
  27. package/cli/init.ts +0 -1253
  28. package/docs/images/how-it-works-dark.png +0 -0
  29. package/docs/images/how-it-works-light.png +0 -0
  30. package/docs/images/workflows-dark.png +0 -0
  31. package/docs/images/workflows-light.png +0 -0
  32. package/drift/index.ts +0 -1165
  33. package/mcp-server/index.ts +0 -1041
  34. package/mcp-server/preview-render.ts +0 -1922
  35. package/mcp-server/preview.ts +0 -292
  36. package/mcp-server/screenshot-android.ts +0 -621
  37. package/mcp-server/screenshot-ios.ts +0 -753
  38. package/mcp-server/screenshot-shared.ts +0 -237
  39. package/mcp-server/screenshot.ts +0 -563
  40. package/prepare/index.ts +0 -1530
  41. package/schema/semantic-lint.ts +0 -692
  42. package/schema/validate.ts +0 -870
  43. package/scripts/regenerate-previews.ts +0 -136
  44. package/scripts/take-all-screenshots.ts +0 -507
  45. package/status/index.ts +0 -275
@@ -1,1922 +0,0 @@
1
- /**
2
- * preview-render.ts — Renders OpenUISpec screen specs as HTML+CSS.
3
- *
4
- * Resolves tokens, locale strings, bindings, and maps contracts to
5
- * semantic HTML elements for visual preview.
6
- */
7
-
8
- // ── types ───────────────────────────────────────────────────────────
9
-
10
- export interface PreviewContext {
11
- manifest: any; // includes _contractDefs for project contract extensions
12
- screen: any;
13
- screenName: string;
14
- tokens: Record<string, any>; // category → parsed YAML
15
- locale: Record<string, string>; // flat key → value
16
- mockData: Record<string, any>; // data.key → value
17
- mockParams: Record<string, any>; // params.key → value
18
- sizeClass: "compact" | "regular" | "expanded";
19
- theme: "light" | "dark";
20
- }
21
-
22
- // ── token resolution ────────────────────────────────────────────────
23
-
24
- function resolveTokenPath(tokens: Record<string, any>, path: string): string | undefined {
25
- // e.g. "color.brand.primary" → tokens.color.color.brand.primary.reference
26
- // or "typography.heading_lg" → font props
27
- // or "spacing.md" → pixel value
28
-
29
- const parts = path.split(".");
30
- const category = parts[0];
31
- const tokenData = tokens[category];
32
- if (!tokenData) return undefined;
33
-
34
- if (category === "color") {
35
- // Navigate: color.<rest>.reference
36
- let node = tokenData.color;
37
- for (let i = 1; i < parts.length; i++) {
38
- if (!node || typeof node !== "object") return undefined;
39
- node = node[parts[i]];
40
- }
41
- if (typeof node === "string") return node;
42
- if (node?.reference) return node.reference;
43
- return undefined;
44
- }
45
-
46
- if (category === "typography") {
47
- // typography.heading_lg → look in scale
48
- const scaleName = parts[1];
49
- const scale = tokenData.typography?.scale?.[scaleName];
50
- if (!scale) return undefined;
51
- // Return as CSS shorthand for use in tokens_override
52
- return scaleName; // Handled specially in renderTypographyStyle
53
- }
54
-
55
- if (category === "spacing") {
56
- const scaleName = parts[1];
57
- // Check aliases first
58
- const alias = tokenData.spacing?.aliases?.[scaleName];
59
- if (alias !== undefined) {
60
- if (typeof alias === "object" && !Array.isArray(alias)) {
61
- // e.g. page_margin: { horizontal: md, vertical: md }
62
- const h = resolveSpacingValue(tokenData, alias.horizontal ?? alias.all);
63
- const v = resolveSpacingValue(tokenData, alias.vertical ?? alias.all);
64
- return `${v}px ${h}px`;
65
- }
66
- return `${resolveSpacingValue(tokenData, alias)}px`;
67
- }
68
- const val = tokenData.spacing?.scale?.[scaleName];
69
- if (val !== undefined) {
70
- return `${resolveSpacingValue(tokenData, scaleName)}px`;
71
- }
72
- return undefined;
73
- }
74
-
75
- if (category === "elevation") {
76
- // Resolve to CSS box-shadow from the web platform value
77
- const level = parts[1]; // sm, md, lg
78
- const elevData = tokenData.elevation?.[level];
79
- if (!elevData) return level === "none" ? "none" : undefined;
80
- const webShadow = elevData.platform?.web?.box_shadow;
81
- if (webShadow) return webShadow;
82
- // Fallback from iOS shadow definition
83
- const iosShadow = elevData.platform?.ios?.shadow;
84
- if (iosShadow) {
85
- return `0 ${iosShadow.y ?? 2}px ${iosShadow.radius ?? 8}px rgba(0,0,0,${iosShadow.opacity ?? 0.08})`;
86
- }
87
- return undefined;
88
- }
89
-
90
- return undefined;
91
- }
92
-
93
- // ── theme-aware color resolution ────────────────────────────────────
94
-
95
- function hexToHSL(hex: string): { h: number; s: number; l: number; a: number } {
96
- hex = hex.replace("#", "");
97
- let a = 1;
98
- if (hex.length === 8) { a = parseInt(hex.slice(6, 8), 16) / 255; hex = hex.slice(0, 6); }
99
- const r = parseInt(hex.slice(0, 2), 16) / 255;
100
- const g = parseInt(hex.slice(2, 4), 16) / 255;
101
- const b = parseInt(hex.slice(4, 6), 16) / 255;
102
- const max = Math.max(r, g, b), min = Math.min(r, g, b);
103
- const l = (max + min) / 2;
104
- if (max === min) return { h: 0, s: 0, l: l * 100, a };
105
- const d = max - min;
106
- const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
107
- let h = 0;
108
- if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
109
- else if (max === g) h = ((b - r) / d + 2) / 6;
110
- else h = ((r - g) / d + 4) / 6;
111
- return { h: h * 360, s: s * 100, l: l * 100, a };
112
- }
113
-
114
- function hslToHex(h: number, s: number, l: number, a = 1): string {
115
- s /= 100; l /= 100;
116
- const k = (n: number) => (n + h / 30) % 12;
117
- const f = (n: number) => l - s * Math.min(l, 1 - l) * Math.max(-1, Math.min(k(n) - 3, 9 - k(n), 1));
118
- const toHex = (v: number) => Math.round(v * 255).toString(16).padStart(2, "0");
119
- const hex = `#${toHex(f(0))}${toHex(f(8))}${toHex(f(4))}`;
120
- if (a < 1) return hex + toHex(a);
121
- return hex;
122
- }
123
-
124
- function applyThemeTransform(baseHex: string, rule: any): string {
125
- const hsl = hexToHSL(baseHex);
126
- if (rule.lightness) {
127
- const [lo, hi] = rule.lightness;
128
- hsl.l = (lo + hi) / 2;
129
- }
130
- if (rule.saturation) {
131
- const [lo, hi] = rule.saturation;
132
- hsl.s = (lo + hi) / 2;
133
- }
134
- if (rule.hue !== undefined) {
135
- hsl.h = typeof rule.hue === "number" ? rule.hue : (rule.hue[0] + rule.hue[1]) / 2;
136
- }
137
- if (rule.opacity !== undefined) {
138
- hsl.a = rule.opacity;
139
- }
140
- return hslToHex(hsl.h, hsl.s, hsl.l, hsl.a);
141
- }
142
-
143
- /**
144
- * Resolve a color token path with theme awareness.
145
- * If ctx.theme != "light", applies transforms from themes.yaml.
146
- */
147
- function resolveColor(ctx: PreviewContext, path: string): string | undefined {
148
- const baseColor = resolveTokenPath(ctx.tokens, path);
149
- if (!baseColor || ctx.theme === "light") return baseColor;
150
-
151
- // Look up theme transform rules
152
- const themeVariants = ctx.tokens.themes?.themes?.variants?.[ctx.theme];
153
- if (!themeVariants) return baseColor;
154
-
155
- // The path is like "color.surface.primary" → theme key is "surface.primary"
156
- const parts = path.split(".");
157
- if (parts[0] !== "color" || parts.length < 3) return baseColor;
158
- const themeKey = parts.slice(1).join(".");
159
-
160
- const rule = themeVariants[themeKey];
161
- if (!rule) return baseColor;
162
-
163
- return applyThemeTransform(baseColor, rule);
164
- }
165
-
166
- function resolveSpacingValue(spacingData: any, key: string | number): number {
167
- if (typeof key === "number") return key;
168
- const val = spacingData.spacing?.scale?.[key];
169
- if (val === undefined) return 0;
170
- if (typeof val === "number") return val;
171
- if (typeof val === "object" && val.base !== undefined) return val.base;
172
- return 0;
173
- }
174
-
175
- /** Resolve spacing token with px fallback. */
176
- function sp(ctx: PreviewContext, scaleName: string, fallbackPx: number): string {
177
- return resolveTokenPath(ctx.tokens, `spacing.${scaleName}`) ?? `${fallbackPx}px`;
178
- }
179
-
180
- /** Apply alpha to a hex color via rgba(). Replaces the broken `${hex}15` pattern. */
181
- function colorWithAlpha(hex: string, alpha: number): string {
182
- const c = hex.replace("#", "");
183
- if (c.length >= 6) {
184
- return `rgba(${parseInt(c.slice(0,2),16)}, ${parseInt(c.slice(2,4),16)}, ${parseInt(c.slice(4,6),16)}, ${alpha})`;
185
- }
186
- return hex;
187
- }
188
-
189
- function getTypographyCSS(tokens: Record<string, any>, scaleName: string): string {
190
- const typo = tokens.typography?.typography;
191
- const scale = typo?.scale?.[scaleName];
192
- if (!scale) return "";
193
-
194
- const fontFamily = typo?.font_family?.primary?.value ?? "system-ui";
195
- const size = typeof scale.size === "object" ? scale.size.base : scale.size;
196
- const weight = scale.weight ?? 400;
197
- const lineHeight = scale.line_height ?? 1.5;
198
- const tracking = scale.tracking ?? 0;
199
- const transform = scale.transform ?? "none";
200
-
201
- let css = `font-family: '${fontFamily}', system-ui, sans-serif; `;
202
- css += `font-size: ${size}px; `;
203
- css += `font-weight: ${weight}; `;
204
- css += `line-height: ${lineHeight}; `;
205
- if (tracking !== 0) css += `letter-spacing: ${tracking}em; `;
206
- if (transform !== "none") css += `text-transform: ${transform}; `;
207
- return css;
208
- }
209
-
210
- // ── locale resolution ───────────────────────────────────────────────
211
-
212
- function resolveLocale(
213
- locale: Record<string, string>,
214
- key: string,
215
- tParams?: Record<string, any>,
216
- ctx?: PreviewContext,
217
- ): string {
218
- // key is like "$t:settings.theme" → strip "$t:"
219
- let localeKey = key.startsWith("$t:") ? key.slice(3) : key;
220
-
221
- // Resolve any binding expressions within the locale key itself
222
- // e.g. "home.greeting.{time_of_day | format:greeting}" → "home.greeting.morning"
223
- if (ctx && localeKey.includes("{")) {
224
- localeKey = localeKey.replace(/\{([^}]+)\}/g, (_, inner) => resolveBindingExpr(inner.trim(), ctx));
225
- }
226
-
227
- let value = locale[localeKey];
228
- if (value === undefined) return `[${localeKey}]`;
229
-
230
- // Handle ICU plural: "{count, plural, =0 {No tasks} one {# task} other {# tasks}}"
231
- if (value.includes("{") && value.includes("plural")) {
232
- value = simplifyPlural(value, tParams, ctx);
233
- }
234
-
235
- // Interpolate {param} references
236
- if (tParams && ctx) {
237
- for (const [paramKey, paramValue] of Object.entries(tParams)) {
238
- const resolved = typeof paramValue === "string"
239
- ? resolveBinding(paramValue, ctx)
240
- : String(paramValue);
241
- value = value.replace(new RegExp(`\\{${paramKey}\\}`, "g"), resolved);
242
- }
243
- }
244
-
245
- return value;
246
- }
247
-
248
- function simplifyPlural(template: string, tParams?: Record<string, any>, ctx?: PreviewContext): string {
249
- // Extract: "{count, plural, =0 {No tasks} one {# task} other {# tasks left out of {total}}}"
250
- const match = template.match(/\{(\w+),\s*plural,\s*(.+)\}/s);
251
- if (!match) return template;
252
-
253
- const paramName = match[1];
254
- const forms = match[2];
255
-
256
- // Get the count value
257
- let count = 3; // default for preview
258
- if (tParams && ctx) {
259
- const raw = tParams[paramName];
260
- if (raw !== undefined) {
261
- const resolved = typeof raw === "string" ? resolveBinding(raw, ctx) : raw;
262
- const parsed = Number(resolved);
263
- if (!isNaN(parsed)) count = parsed;
264
- }
265
- }
266
-
267
- // Extract form content handling nested braces (e.g. "{# tasks out of {total}}")
268
- function extractForm(prefix: string): string | null {
269
- const idx = forms.indexOf(prefix);
270
- if (idx === -1) return null;
271
- const start = forms.indexOf("{", idx + prefix.length);
272
- if (start === -1) return null;
273
- // Count braces to find matching close
274
- let depth = 0;
275
- for (let i = start; i < forms.length; i++) {
276
- if (forms[i] === "{") depth++;
277
- else if (forms[i] === "}") { depth--; if (depth === 0) return forms.slice(start + 1, i); }
278
- }
279
- return null;
280
- }
281
-
282
- // Check for exact match first (=0, =1)
283
- const exact = extractForm(`=${count} `) ?? extractForm(`=${count}{`);
284
- if (exact !== null) return exact.replace(/#/g, String(count));
285
-
286
- // Then check named forms
287
- const formName = count === 0 ? "zero" : count === 1 ? "one" : "other";
288
- const named = extractForm(`${formName} `) ?? extractForm(`${formName}{`);
289
- if (named !== null) return named.replace(/#/g, String(count));
290
-
291
- // Fallback to "other"
292
- if (formName !== "other") {
293
- const other = extractForm("other ") ?? extractForm("other{");
294
- if (other !== null) return other.replace(/#/g, String(count));
295
- }
296
-
297
- return template;
298
- }
299
-
300
- // Handle ICU select: "{is_done, select, true {Reopen task} other {Mark complete}}"
301
- function resolveSelect(template: string, tParams?: Record<string, any>, ctx?: PreviewContext): string {
302
- const match = template.match(/\{(\w+),\s*select,\s*(.+)\}/s);
303
- if (!match) return template;
304
-
305
- const paramName = match[1];
306
- const options = match[2];
307
-
308
- let paramValue = "other";
309
- if (tParams && ctx) {
310
- const raw = tParams[paramName];
311
- if (raw !== undefined) {
312
- paramValue = typeof raw === "string" ? resolveBinding(raw, ctx) : String(raw);
313
- }
314
- }
315
-
316
- // Look for exact match
317
- const exactMatch = options.match(new RegExp(`${paramValue}\\s*\\{([^}]*)\\}`));
318
- if (exactMatch) return exactMatch[1];
319
-
320
- // Fallback to "other"
321
- const otherMatch = options.match(/other\s*\{([^}]*)\}/);
322
- if (otherMatch) return otherMatch[1];
323
-
324
- return template;
325
- }
326
-
327
- // ── binding resolution ──────────────────────────────────────────────
328
-
329
- function resolveBinding(expr: string, ctx: PreviewContext): string {
330
- if (!expr || typeof expr !== "string") return String(expr ?? "");
331
-
332
- // Locale reference
333
- if (expr.startsWith("$t:")) {
334
- return resolveLocale(ctx.locale, expr, undefined, ctx);
335
- }
336
-
337
- // Binding expression: {data.field} or {data.field | format:name}
338
- if (expr.startsWith("{") && expr.endsWith("}")) {
339
- return resolveBindingExpr(expr.slice(1, -1).trim(), ctx);
340
- }
341
-
342
- // Template with mixed text and bindings: "text {binding} more"
343
- if (expr.includes("{") && expr.includes("}")) {
344
- return expr.replace(/\{([^}]+)\}/g, (_, inner) => resolveBindingExpr(inner.trim(), ctx));
345
- }
346
-
347
- // Direct data path (no braces)
348
- const directValue = resolveDotPath(expr, ctx);
349
- if (directValue !== undefined) return String(directValue);
350
-
351
- return expr;
352
- }
353
-
354
- function resolveBindingExpr(expr: string, ctx: PreviewContext): string {
355
- // Check for pipes: "data.field | format:name"
356
- const pipeIndex = expr.indexOf("|");
357
- if (pipeIndex > -1) {
358
- const path = expr.slice(0, pipeIndex).trim();
359
- const pipe = expr.slice(pipeIndex + 1).trim();
360
- return applyPipe(path, pipe, ctx);
361
- }
362
-
363
- // Check for ternary (deferred — show placeholder)
364
- if (expr.includes("?")) {
365
- return "[conditional]";
366
- }
367
-
368
- // Check for comparison (e.g. "item.status == done")
369
- if (expr.includes("==")) {
370
- const [left, right] = expr.split("==").map((s) => s.trim());
371
- const leftVal = String(resolveDotPath(left, ctx) ?? left);
372
- const rightVal = right.replace(/['"]/g, "");
373
- return String(leftVal === rightVal);
374
- }
375
-
376
- // Simple path
377
- const val = resolveDotPath(expr, ctx);
378
- return val !== undefined ? String(val) : `[${expr}]`;
379
- }
380
-
381
- function applyPipe(path: string, pipe: string, ctx: PreviewContext): string {
382
- const rawValue = resolveDotPath(path, ctx);
383
-
384
- if (pipe.startsWith("format:")) {
385
- const formatName = pipe.slice(7);
386
- // Check manifest formatters
387
- const formatter = ctx.manifest?.formatters?.[formatName];
388
- if (formatter?.mapping) {
389
- const key = rawValue !== undefined ? String(rawValue) : Object.keys(formatter.mapping)[0];
390
- const mapped = formatter.mapping[key];
391
- if (mapped) {
392
- // If the mapped value is a locale ref, resolve it
393
- if (typeof mapped === "string" && mapped.startsWith("$t:")) {
394
- return resolveLocale(ctx.locale, mapped, undefined, ctx);
395
- }
396
- return String(mapped);
397
- }
398
- }
399
- // Complex formatters — show placeholder
400
- if (formatName === "date_relative" || formatName === "date") {
401
- return rawValue !== undefined ? String(rawValue) : "[date]";
402
- }
403
- return rawValue !== undefined ? String(rawValue) : `[${formatName}]`;
404
- }
405
-
406
- if (pipe.startsWith("map:")) {
407
- const mapName = pipe.slice(4);
408
- const mapper = ctx.manifest?.mappers?.[mapName];
409
- if (mapper && rawValue !== undefined) {
410
- return String(mapper[String(rawValue)] ?? rawValue);
411
- }
412
- return String(rawValue ?? `[${mapName}]`);
413
- }
414
-
415
- if (pipe.startsWith("default:")) {
416
- const fallback = pipe.slice(8).replace(/^['"]|['"]$/g, "");
417
- if (rawValue !== undefined && rawValue !== null && rawValue !== "") {
418
- return String(rawValue);
419
- }
420
- // Fallback might be a locale ref
421
- if (fallback.startsWith("$t:")) {
422
- return resolveLocale(ctx.locale, fallback, undefined, ctx);
423
- }
424
- return fallback;
425
- }
426
-
427
- return String(rawValue ?? `[${pipe}]`);
428
- }
429
-
430
- function resolveDotPath(path: string, ctx: PreviewContext): any {
431
- const parts = path.split(".");
432
- const root = parts[0];
433
-
434
- let data: any;
435
- if (root === "state") {
436
- // Use defaults from screen's state block
437
- const stateKey = parts[1];
438
- data = ctx.screen[ctx.screenName]?.state?.[stateKey]?.default;
439
- if (parts.length === 2) return data;
440
- // Navigate deeper if needed
441
- for (let i = 2; i < parts.length; i++) {
442
- if (data == null) return undefined;
443
- data = data[parts[i]];
444
- }
445
- return data;
446
- }
447
-
448
- if (root === "params") {
449
- data = ctx.mockParams;
450
- for (let i = 1; i < parts.length; i++) {
451
- if (data == null) return undefined;
452
- data = data[parts[i]];
453
- }
454
- return data;
455
- }
456
-
457
- // Try mock data first
458
- data = ctx.mockData;
459
- for (const part of parts) {
460
- if (data == null) return undefined;
461
- data = data[part];
462
- }
463
- if (data !== undefined) return data;
464
-
465
- // Try from data keys defined in screen
466
- const screenDef = ctx.screen[ctx.screenName];
467
- if (screenDef?.data && root in screenDef.data) {
468
- data = ctx.mockData[root];
469
- if (data === undefined) return undefined;
470
- for (let i = 1; i < parts.length; i++) {
471
- if (data == null) return undefined;
472
- data = data[parts[i]];
473
- }
474
- return data;
475
- }
476
-
477
- return undefined;
478
- }
479
-
480
- // ── item context for collections ────────────────────────────────────
481
-
482
- function resolveWithItem(expr: string, item: any, ctx: PreviewContext): string {
483
- if (!expr || typeof expr !== "string") return String(expr ?? "");
484
-
485
- // Also handle standalone "item" (for simple arrays like tags)
486
- if (expr === "item" && typeof item !== "object") {
487
- return String(item);
488
- }
489
-
490
- // For binding expressions with {item.X}, resolve each binding block individually
491
- if (expr.includes("{") && expr.includes("}")) {
492
- const result = expr.replace(/\{([^}]+)\}/g, (_, inner) => {
493
- const trimmed = inner.trim();
494
- // Replace item.X refs in this binding expression
495
- const withItemResolved = trimmed.replace(/\bitem\.(\w+(?:\.\w+)*)/g, (_m: string, path: string) => {
496
- let val: any = item;
497
- for (const p of path.split(".")) {
498
- if (val == null) return `[item.${path}]`;
499
- val = val[p];
500
- }
501
- return val !== undefined ? String(val) : `[item.${path}]`;
502
- });
503
-
504
- // If it has a pipe, apply it
505
- const pipeIdx = withItemResolved.indexOf("|");
506
- if (pipeIdx > -1) {
507
- const valuePart = withItemResolved.slice(0, pipeIdx).trim();
508
- const pipePart = withItemResolved.slice(pipeIdx + 1).trim();
509
- return applyPipeWithValue(valuePart, pipePart, ctx);
510
- }
511
-
512
- // If it starts with $t:, resolve locale
513
- if (withItemResolved.startsWith("$t:")) {
514
- return resolveLocale(ctx.locale, withItemResolved, undefined, ctx);
515
- }
516
-
517
- // Try as a dot path first, otherwise return as literal
518
- const resolved = resolveDotPath(withItemResolved, ctx);
519
- return resolved !== undefined ? String(resolved) : withItemResolved;
520
- });
521
-
522
- // Resolve any remaining $t: references outside braces
523
- return result.replace(/\$t:([\w.]+)/g, (match) => {
524
- return resolveLocale(ctx.locale, match, undefined, ctx);
525
- });
526
- }
527
-
528
- // Simple item.X path (no braces)
529
- const replaced = expr.replace(/\bitem\.(\w+(?:\.\w+)*)/g, (_, path) => {
530
- let val: any = item;
531
- for (const p of path.split(".")) {
532
- if (val == null) return `[item.${path}]`;
533
- val = val[p];
534
- }
535
- return val !== undefined ? String(val) : `[item.${path}]`;
536
- });
537
-
538
- // After item substitution, resolve any $t: references that appeared
539
- const withLocale = replaced.replace(/\$t:([\w.]+)/g, (match) => {
540
- return resolveLocale(ctx.locale, match, undefined, ctx);
541
- });
542
-
543
- return resolveBinding(withLocale, ctx);
544
- }
545
-
546
- function applyPipeWithValue(value: string, pipe: string, ctx: PreviewContext): string {
547
- if (pipe.startsWith("format:")) {
548
- const formatName = pipe.slice(7);
549
- const formatter = ctx.manifest?.formatters?.[formatName];
550
- if (formatter?.mapping) {
551
- const mapped = formatter.mapping[value];
552
- if (mapped) {
553
- if (typeof mapped === "string" && mapped.startsWith("$t:")) {
554
- return resolveLocale(ctx.locale, mapped, undefined, ctx);
555
- }
556
- return String(mapped);
557
- }
558
- }
559
- if (formatName === "date_relative" || formatName === "date") {
560
- return value || "[date]";
561
- }
562
- return value || `[${formatName}]`;
563
- }
564
-
565
- if (pipe.startsWith("map:")) {
566
- const mapName = pipe.slice(4);
567
- const mapper = ctx.manifest?.mappers?.[mapName];
568
- if (mapper) {
569
- return String(mapper[value] ?? value);
570
- }
571
- return value;
572
- }
573
-
574
- if (pipe.startsWith("default:")) {
575
- const fallback = pipe.slice(8).replace(/^['"]|['"]$/g, "");
576
- if (value) return value;
577
- if (fallback.startsWith("$t:")) return resolveLocale(ctx.locale, fallback, undefined, ctx);
578
- return fallback;
579
- }
580
-
581
- return value;
582
- }
583
-
584
- // ── spec-defined contract token defaults ────────────────────────────
585
- // These are the built-in tokens from the OpenUISpec specification.
586
- // Project `contracts/*.yaml` extensions and screen-level `tokens_override`
587
- // merge on top of these. This makes rendering spec-driven, not hardcoded.
588
-
589
- const CONTRACT_TOKENS: Record<string, Record<string, any>> = {
590
- data_display: {
591
- card: {
592
- background: "color.surface.primary",
593
- border: { width: 0.5, color: "color.border.default" },
594
- radius: "spacing.md",
595
- padding: "spacing.md",
596
- title_style: "typography.heading_sm",
597
- subtitle_style: "typography.body_sm",
598
- body_style: "typography.body",
599
- },
600
- compact: {
601
- min_height: 44,
602
- padding_v: "spacing.sm",
603
- padding_h: "spacing.md",
604
- title_style: "typography.body",
605
- subtitle_style: "typography.caption",
606
- separator: { color: "color.border.default", inset_leading: "spacing.md" },
607
- },
608
- hero: {
609
- padding: "spacing.lg",
610
- title_style: "typography.display",
611
- subtitle_style: "typography.body",
612
- },
613
- stat: {
614
- padding: "spacing.md",
615
- background: "color.surface.secondary",
616
- radius: "spacing.sm",
617
- label_style: "typography.caption",
618
- value_style: "typography.heading_lg",
619
- },
620
- inline: {
621
- padding: "spacing.xs",
622
- title_style: "typography.body_sm",
623
- },
624
- },
625
- action_trigger: {
626
- primary: {
627
- background: "color.brand.primary",
628
- text: "color.brand.primary.on_color",
629
- min_height: 44,
630
- padding_h: "spacing.md",
631
- radius: "spacing.sm",
632
- },
633
- secondary: {
634
- background: "color.surface.secondary",
635
- text: "color.text.primary",
636
- border: { width: 1, color: "color.border.emphasis" },
637
- min_height: 44,
638
- padding_h: "spacing.md",
639
- radius: "spacing.sm",
640
- },
641
- tertiary: {
642
- background: "transparent",
643
- text: "color.brand.primary",
644
- min_height: 36,
645
- padding_h: "spacing.sm",
646
- },
647
- destructive: {
648
- background: "color.semantic.danger",
649
- text: "color.semantic.danger.on_color",
650
- min_height: 44,
651
- padding_h: "spacing.md",
652
- radius: "spacing.sm",
653
- },
654
- ghost: {
655
- background: "transparent",
656
- text: "color.text.secondary",
657
- min_height: 36,
658
- padding_h: "spacing.xs",
659
- },
660
- },
661
- input_field: {
662
- text: {
663
- min_height: 44,
664
- padding_h: "spacing.md",
665
- padding_v: "spacing.sm",
666
- background: "color.surface.primary",
667
- border: { width: 1, color: "color.border.default" },
668
- radius: "spacing.sm",
669
- label_style: "typography.caption",
670
- value_style: "typography.body",
671
- placeholder_color: "color.text.tertiary",
672
- },
673
- toggle: {
674
- track_width: 51,
675
- track_height: 31,
676
- thumb_size: 27,
677
- track_on: "color.brand.primary",
678
- track_off: "color.border.emphasis",
679
- },
680
- },
681
- nav_container: {
682
- tab_bar: {
683
- height: 49,
684
- background: "color.surface.primary",
685
- border_top: { width: 0.5, color: "color.border.default" },
686
- icon_size: 24,
687
- label_style: "typography.caption",
688
- },
689
- sidebar: {
690
- width_expanded: 240,
691
- background: "color.surface.secondary",
692
- item_height: 44,
693
- item_radius: "spacing.sm",
694
- item_padding_h: "spacing.md",
695
- icon_size: 20,
696
- label_style: "typography.body_sm",
697
- },
698
- rail: {
699
- width: 72,
700
- icon_size: 24,
701
- label_style: "typography.caption",
702
- },
703
- },
704
- };
705
-
706
- /**
707
- * Resolve a contract token value. Priority:
708
- * 1. Screen-instance tokens_override (highest)
709
- * 2. Project contract extension tokens
710
- * 3. Spec-defined defaults (CONTRACT_TOKENS)
711
- */
712
- function resolveContractToken(
713
- contract: string,
714
- variant: string,
715
- tokenKey: string,
716
- tokensOverride: Record<string, any>,
717
- ctx: PreviewContext,
718
- ): string | undefined {
719
- // 1. Instance override
720
- if (tokensOverride[tokenKey] !== undefined) {
721
- const val = tokensOverride[tokenKey];
722
- if (typeof val === "string") {
723
- return resolveTokenPath(ctx.tokens, val) ?? val;
724
- }
725
- return typeof val === "number" ? `${val}px` : String(val);
726
- }
727
-
728
- // 2. Project contract extension tokens
729
- const contractDefs = ctx.manifest?._contractDefs;
730
- const projectTokens = contractDefs?.[contract]?.[contract]?.tokens?.[variant];
731
- if (projectTokens?.[tokenKey] !== undefined) {
732
- const val = projectTokens[tokenKey];
733
- if (typeof val === "string") {
734
- return resolveTokenPath(ctx.tokens, val) ?? val;
735
- }
736
- return typeof val === "number" ? `${val}px` : String(val);
737
- }
738
-
739
- // 3. Spec defaults
740
- const specTokens = CONTRACT_TOKENS[contract]?.[variant];
741
- if (specTokens?.[tokenKey] !== undefined) {
742
- const val = specTokens[tokenKey];
743
- if (typeof val === "string") {
744
- return resolveTokenPath(ctx.tokens, val) ?? val;
745
- }
746
- return typeof val === "number" ? `${val}px` : String(val);
747
- }
748
-
749
- return undefined;
750
- }
751
-
752
- // ── icon rendering ──────────────────────────────────────────────────
753
-
754
- const ICON_MAP: Record<string, string> = {
755
- checkmark: "&#x2713;",
756
- checkmark_circle: "&#x2713;",
757
- checkmark_circle_fill: "&#x2713;",
758
- checkmark_list: "&#x2611;",
759
- checkmark_list_fill: "&#x2611;",
760
- plus: "&#x002B;",
761
- plus_circle: "&#x2295;",
762
- pencil: "&#x270E;",
763
- trash: "&#x1F5D1;",
764
- search: "&#x1F50D;",
765
- gear: "&#x2699;",
766
- gear_fill: "&#x2699;",
767
- folder: "&#x1F4C1;",
768
- folder_fill: "&#x1F4C2;",
769
- calendar: "&#x1F4C5;",
770
- calendar_fill: "&#x1F4C5;",
771
- clock: "&#x1F551;",
772
- tag: "&#x1F3F7;",
773
- person: "&#x1F464;",
774
- chevron_right: "&#x276F;",
775
- chevron_left: "&#x276E;",
776
- flag: "&#x2691;",
777
- flag_fill: "&#x2691;",
778
- circle_fill: "&#x25CF;",
779
- star: "&#x2606;",
780
- star_fill: "&#x2605;",
781
- heart: "&#x2661;",
782
- arrow_uturn_left: "&#x21A9;",
783
- square_arrow_up: "&#x2B06;",
784
- exclamationmark_triangle: "&#x26A0;",
785
- };
786
-
787
- function renderIcon(name: string, size: number, color: string): string {
788
- const symbol = ICON_MAP[name] ?? ICON_MAP[name.replace(/_fill$/, "")] ?? "&#x25CB;";
789
- return `<span style="font-size: ${Math.round(size * 0.85)}px; line-height: 1; color: ${color}; flex-shrink: 0; display: inline-flex; align-items: center; justify-content: center; width: ${size}px; height: ${size}px; font-style: normal;">${symbol}</span>`;
790
- }
791
-
792
- // ── contract → HTML rendering ───────────────────────────────────────
793
-
794
- function renderSection(section: any, ctx: PreviewContext, depth = 0): string {
795
- if (!section) return "";
796
-
797
- // Handle condition
798
- if (section.condition) {
799
- const condResult = evaluateCondition(section.condition, ctx);
800
- if (!condResult) return "";
801
- }
802
-
803
- // Handle adaptive — pick the right branch
804
- const adaptedSection = applyAdaptive(section, ctx.sizeClass);
805
-
806
- // Determine layout
807
- const layout = adaptedSection.layout ?? {};
808
- const adaptedLayout = applyAdaptive(layout, ctx.sizeClass);
809
-
810
- const containerStyle = buildContainerStyle(adaptedSection, ctx);
811
- const layoutStyle = buildLayoutStyle(adaptedLayout, ctx);
812
-
813
- const id = adaptedSection.id ? ` id="${escapeHtml(adaptedSection.id)}"` : "";
814
- const position = adaptedSection.position;
815
- let positionStyle = "";
816
- if (position === "floating-bottom-trailing") {
817
- // Dynamically compute bottom offset from nav height + spacing
818
- const screenDef = ctx.screen[ctx.screenName];
819
- const nav = screenDef?.navigation;
820
- const adaptedNav = nav ? applyAdaptive(nav, ctx.sizeClass) : undefined;
821
- const navVariant = adaptedNav?.variant ?? "tab_bar";
822
- let bottomOffset = 0;
823
- if (nav && navVariant === "tab_bar") {
824
- const navTokensOverride = adaptedNav?.tokens_override ?? {};
825
- const tabHeight = parseInt(resolveContractToken("nav_container", "tab_bar", "height", navTokensOverride, ctx) ?? "49", 10);
826
- bottomOffset = tabHeight;
827
- }
828
- // Add spacing offset
829
- const spacingOffset = parseInt(sp(ctx, "lg", 24).replace("px", ""), 10);
830
- const rightOffset = sp(ctx, "lg", 24);
831
- positionStyle = `position: fixed; bottom: ${bottomOffset + spacingOffset}px; right: ${rightOffset}; z-index: 100; `;
832
- }
833
-
834
- // If this section references a component, render it
835
- if (adaptedSection.component) {
836
- const inner = renderComponent(adaptedSection, ctx, depth);
837
- if (containerStyle || positionStyle) {
838
- return `<div${id} style="${positionStyle}${containerStyle}">${inner}</div>`;
839
- }
840
- return inner;
841
- }
842
-
843
- // If this section IS a contract (leaf node), wrap it with container styles
844
- if (adaptedSection.contract) {
845
- const inner = renderContract(adaptedSection, ctx, depth);
846
- // Only wrap if there's container/position styling to apply
847
- if (containerStyle || positionStyle) {
848
- return `<div${id} style="${positionStyle}${containerStyle}">${inner}</div>`;
849
- }
850
- return inner;
851
- }
852
-
853
- // Render children
854
- const children = adaptedSection.children ?? adaptedSection.sections ?? [];
855
- const childrenHtml = children.map((child: any) => renderSection(child, ctx, depth + 1)).join("\n");
856
-
857
- return `<div${id} style="${positionStyle}${containerStyle}${layoutStyle}">${childrenHtml}</div>`;
858
- }
859
-
860
- function renderCustomContractPlaceholder(
861
- contract: string,
862
- variant: string,
863
- props: Record<string, any>,
864
- ctx: PreviewContext,
865
- ): string {
866
- const def = ctx.manifest?._contractDefs?.[contract]?.[contract];
867
- if (!def) {
868
- return `<div class="contract-placeholder" style="padding: ${sp(ctx,"sm",12)}; border: 1px dashed ${FALLBACK.borderDefault}; border-radius: ${sp(ctx,"sm",8)}; color: ${FALLBACK.textTertiary}; font-size: 13px; text-align: center;">[${contract}${variant !== "default" ? `:${variant}` : ""}]</div>`;
869
- }
870
-
871
- const tokenDef = def.tokens?.[variant] ?? def.tokens?.[Object.keys(def.tokens ?? {})[0]] ?? {};
872
-
873
- const minHeightRaw = tokenDef.min_height;
874
- const minHeightPx = Array.isArray(minHeightRaw) ? minHeightRaw[0] : minHeightRaw;
875
- const minHeightCSS = minHeightPx ? `min-height: ${minHeightPx}px;` : "";
876
-
877
- const bgPath = tokenDef.background;
878
- const bg = bgPath
879
- ? (resolveColor(ctx, bgPath) ?? resolveTokenPath(ctx.tokens, bgPath) ?? FALLBACK.surfaceSecondary)
880
- : FALLBACK.surfaceSecondary;
881
-
882
- const radiusPath = tokenDef.radius;
883
- const radius = radiusPath
884
- ? (resolveTokenPath(ctx.tokens, radiusPath) ?? sp(ctx, "sm", 8))
885
- : sp(ctx, "sm", 8);
886
-
887
- const borderDef = tokenDef.border;
888
- const borderCSS = borderDef
889
- ? `border: ${borderDef.width ?? 1}px solid ${resolveColor(ctx, borderDef.color) ?? resolveTokenPath(ctx.tokens, borderDef.color) ?? FALLBACK.borderDefault};`
890
- : `border: 1px dashed ${FALLBACK.borderDefault};`;
891
-
892
- const paddingPath = tokenDef.padding;
893
- const padding = paddingPath
894
- ? (resolveTokenPath(ctx.tokens, paddingPath) ?? sp(ctx, "md", 16))
895
- : sp(ctx, "md", 16);
896
-
897
- const semantic = def.semantic ?? "";
898
-
899
- const webMappingForVariant = def.platform_mapping?.web?.[variant];
900
- const webMappingFallback = def.platform_mapping?.web;
901
- const webMapping = webMappingForVariant ?? (typeof webMappingFallback === "object" && !Array.isArray(webMappingFallback) ? webMappingFallback : undefined);
902
- const platformHint = webMapping ? (webMapping.component ?? webMapping.element ?? "") : "";
903
-
904
- const propLines: string[] = [];
905
- if (props && def.props) {
906
- for (const [key] of Object.entries(def.props as Record<string, any>)) {
907
- if (props[key] !== undefined) {
908
- propLines.push(`${key}: ${escapeHtml(resolveBinding(String(props[key]), ctx))}`);
909
- }
910
- }
911
- }
912
-
913
- const headerColor = resolveColor(ctx, "color.text.tertiary") ?? FALLBACK.textTertiary;
914
- const bodyColor = resolveColor(ctx, "color.text.secondary") ?? FALLBACK.textSecondary;
915
-
916
- return `<div class="contract-placeholder" style="padding: ${padding}; background: ${bg}; border-radius: ${radius}; ${borderCSS} ${minHeightCSS} display: flex; flex-direction: column; justify-content: center; gap: ${sp(ctx,"xs",4)};">
917
- <div style="display: flex; align-items: center; gap: ${sp(ctx,"sm",8)};">
918
- <span style="${getTypographyCSS(ctx.tokens, "caption")} color: ${headerColor}; text-transform: uppercase; letter-spacing: 0.05em;">${escapeHtml(contract)}${variant !== "default" ? `.${escapeHtml(variant)}` : ""}</span>
919
- ${platformHint ? `<span style="${getTypographyCSS(ctx.tokens, "caption")} color: ${headerColor}; opacity: 0.7;">· ${escapeHtml(platformHint)}</span>` : ""}
920
- </div>
921
- ${semantic ? `<div style="${getTypographyCSS(ctx.tokens, "body_sm")} color: ${bodyColor};">${escapeHtml(semantic)}</div>` : ""}
922
- ${propLines.length > 0 ? `<div style="${getTypographyCSS(ctx.tokens, "caption")} color: ${headerColor}; margin-top: ${sp(ctx,"xs",4)};">${propLines.map(p => escapeHtml(p)).join(" · ")}</div>` : ""}
923
- </div>`;
924
- }
925
-
926
- function renderComponent(section: any, ctx: PreviewContext, depth: number): string {
927
- const componentName = section.component;
928
- const def = ctx.manifest?._componentDefs?.[componentName];
929
- if (!def) {
930
- return `<div class="contract-placeholder" style="padding: ${sp(ctx,"sm",12)}; border: 1px dashed ${FALLBACK.borderDefault}; border-radius: ${sp(ctx,"sm",8)}; color: ${FALLBACK.textTertiary}; font-size: 13px; text-align: center;">[component: ${escapeHtml(componentName)}]</div>`;
931
- }
932
-
933
- const variantName = section.variant;
934
- const screenSlotOverrides = section.slots ?? {};
935
-
936
- // Merge: slot defaults → variant overrides → screen-level overrides
937
- const variantDef = variantName ? def.variants?.[variantName] : undefined;
938
- const hiddenSlots = new Set<string>([
939
- ...(variantDef?.hide_slots ?? []),
940
- ]);
941
-
942
- // Build per-slot merged overrides
943
- const slotOverrides: Record<string, any> = {};
944
- // Variant slot overrides
945
- if (variantDef?.slot_overrides) {
946
- for (const [slotName, override] of Object.entries(variantDef.slot_overrides)) {
947
- slotOverrides[slotName] = { ...(slotOverrides[slotName] ?? {}), ...(override as any) };
948
- }
949
- }
950
- // Screen-level slot overrides (highest priority)
951
- for (const [slotName, override] of Object.entries(screenSlotOverrides)) {
952
- const prev = slotOverrides[slotName] ?? {};
953
- const screenOverride = override as any;
954
- slotOverrides[slotName] = { ...prev, ...screenOverride };
955
- if (screenOverride.hidden) {
956
- hiddenSlots.add(slotName);
957
- }
958
- // Merge props deeply
959
- if (prev.props && screenOverride.props) {
960
- slotOverrides[slotName].props = { ...prev.props, ...screenOverride.props };
961
- }
962
- }
963
-
964
- // Resolve layout (variant layout overrides default)
965
- const layout = variantDef?.layout ?? def.layout;
966
-
967
- // Container tokens
968
- const tokens = { ...(def.tokens ?? {}), ...(variantDef?.tokens ?? {}) };
969
- const bg = tokens.background
970
- ? (resolveColor(ctx, tokens.background) ?? resolveTokenPath(ctx.tokens, tokens.background) ?? FALLBACK.surfaceSecondary)
971
- : FALLBACK.surfaceSecondary;
972
- const radius = tokens.radius
973
- ? (resolveTokenPath(ctx.tokens, tokens.radius) ?? sp(ctx, "md", 16))
974
- : sp(ctx, "md", 16);
975
- const padding = tokens.padding
976
- ? (resolveTokenPath(ctx.tokens, tokens.padding) ?? sp(ctx, "md", 16))
977
- : sp(ctx, "md", 16);
978
-
979
- // Render layout recursively
980
- function renderLayoutItems(items: any[]): string {
981
- return items.map((item: any) => {
982
- if (item.slot) {
983
- if (hiddenSlots.has(item.slot)) return "";
984
- const slotDef = def.slots?.[item.slot];
985
- if (!slotDef) return "";
986
- const override = slotOverrides[item.slot] ?? {};
987
- // Build section-like object for renderContract
988
- const slotSection: any = {
989
- contract: slotDef.contract,
990
- variant: override.variant ?? slotDef.variant,
991
- input_type: slotDef.input_type,
992
- props: { ...(slotDef.props ?? {}), ...(override.props ?? {}) },
993
- tokens_override: { ...(slotDef.tokens_override ?? {}), ...(override.tokens_override ?? {}) },
994
- };
995
- return renderContract(slotSection, ctx, depth + 1);
996
- }
997
- if (item.layout) {
998
- const nested = item.layout;
999
- const dir = nested.type === "row" ? "row" : "column";
1000
- const spacing = nested.spacing
1001
- ? (resolveTokenPath(ctx.tokens, nested.spacing) ?? sp(ctx, "sm", 8))
1002
- : sp(ctx, "sm", 8);
1003
- const inner = renderLayoutItems(nested.sections ?? []);
1004
- return `<div style="display: flex; flex-direction: ${dir}; gap: ${spacing};">${inner}</div>`;
1005
- }
1006
- return "";
1007
- }).join("\n");
1008
- }
1009
-
1010
- const layoutDir = layout?.type === "row" ? "row" : "column";
1011
- const layoutSpacing = layout?.spacing
1012
- ? (resolveTokenPath(ctx.tokens, layout.spacing) ?? sp(ctx, "sm", 8))
1013
- : sp(ctx, "sm", 8);
1014
- const layoutSections = layout?.sections ?? [];
1015
-
1016
- // If no layout, render all non-hidden slots in order
1017
- const innerHtml = layoutSections.length > 0
1018
- ? renderLayoutItems(layoutSections)
1019
- : Object.entries(def.slots ?? {}).map(([slotName, slotDef]: [string, any]) => {
1020
- if (hiddenSlots.has(slotName)) return "";
1021
- const override = slotOverrides[slotName] ?? {};
1022
- const slotSection: any = {
1023
- contract: slotDef.contract,
1024
- variant: override.variant ?? slotDef.variant,
1025
- input_type: slotDef.input_type,
1026
- props: { ...(slotDef.props ?? {}), ...(override.props ?? {}) },
1027
- tokens_override: { ...(slotDef.tokens_override ?? {}), ...(override.tokens_override ?? {}) },
1028
- };
1029
- return renderContract(slotSection, ctx, depth + 1);
1030
- }).join("\n");
1031
-
1032
- const semantic = def.semantic ?? "";
1033
- const headerColor = resolveColor(ctx, "color.text.tertiary") ?? FALLBACK.textTertiary;
1034
-
1035
- return `<div class="component" style="padding: ${padding}; background: ${bg}; border-radius: ${radius}; display: flex; flex-direction: ${layoutDir}; gap: ${layoutSpacing};">
1036
- <div style="${getTypographyCSS(ctx.tokens, "caption")} color: ${headerColor}; text-transform: uppercase; letter-spacing: 0.05em;">${escapeHtml(componentName)}${variantName ? `.${escapeHtml(variantName)}` : ""}</div>
1037
- ${semantic ? `<div style="${getTypographyCSS(ctx.tokens, "body_sm")} color: ${resolveColor(ctx, "color.text.secondary") ?? FALLBACK.textSecondary};">${escapeHtml(semantic)}</div>` : ""}
1038
- ${innerHtml}
1039
- </div>`;
1040
- }
1041
-
1042
- function renderContract(section: any, ctx: PreviewContext, depth: number): string {
1043
- const contract = section.contract;
1044
- const variant = section.variant ?? "default";
1045
- const adapted = applyAdaptive(section, ctx.sizeClass);
1046
- const props = adapted.props ?? {};
1047
-
1048
- switch (contract) {
1049
- case "data_display":
1050
- return renderDataDisplay(adapted, props, ctx);
1051
- case "action_trigger":
1052
- return renderActionTrigger(adapted, props, ctx);
1053
- case "input_field":
1054
- return renderInputField(adapted, props, ctx);
1055
- case "collection":
1056
- return renderCollection(adapted, props, ctx, depth);
1057
- case "nav_container":
1058
- return renderNavContainer(adapted, props, ctx);
1059
- case "feedback":
1060
- case "surface":
1061
- return ""; // Not visible by default
1062
- default: {
1063
- // Check if this is a component reference
1064
- const componentDef = ctx.manifest?._componentDefs?.[contract];
1065
- if (componentDef) {
1066
- return renderComponent({ component: contract, variant: variant !== "default" ? variant : undefined, props, slots: section.slots }, ctx, depth);
1067
- }
1068
- return renderCustomContractPlaceholder(contract, variant, props, ctx);
1069
- }
1070
- }
1071
- }
1072
-
1073
- function renderDataDisplay(section: any, props: any, ctx: PreviewContext): string {
1074
- const variant = section.variant ?? "card";
1075
- const tokensOverride = section.tokens_override ?? {};
1076
- const interactive = section.interactive ?? false;
1077
-
1078
- const title = props.title ? resolveBinding(props.title, ctx) : "";
1079
- const subtitle = props.subtitle ? resolveBinding(props.subtitle, ctx) : "";
1080
- const body = props.body ? resolveBinding(props.body, ctx) : "";
1081
-
1082
- // Resolve locale with t_params
1083
- let resolvedTitle = title;
1084
- if (props.title?.startsWith?.("$t:") && props.t_params) {
1085
- const resolvedParams: Record<string, any> = {};
1086
- for (const [k, v] of Object.entries(props.t_params)) {
1087
- resolvedParams[k] = typeof v === "string" ? resolveBinding(v, ctx) : v;
1088
- }
1089
- resolvedTitle = resolveLocale(ctx.locale, props.title, resolvedParams, ctx);
1090
- }
1091
-
1092
- let resolvedSubtitle = subtitle;
1093
- if (props.subtitle?.startsWith?.("$t:") && props.t_params) {
1094
- const resolvedParams: Record<string, any> = {};
1095
- for (const [k, v] of Object.entries(props.t_params)) {
1096
- resolvedParams[k] = typeof v === "string" ? resolveBinding(v, ctx) : v;
1097
- }
1098
- resolvedSubtitle = resolveLocale(ctx.locale, props.subtitle, resolvedParams, ctx);
1099
- }
1100
-
1101
- // Resolve typography from contract tokens → tokens_override → spec defaults
1102
- const ct = (key: string) => resolveContractToken("data_display", variant, key, tokensOverride, ctx);
1103
-
1104
- const titleStyleName = (ct("title_style") ?? "heading_sm").replace("typography.", "");
1105
- let titleStyle = getTypographyCSS(ctx.tokens, titleStyleName);
1106
- let titleColor = resolveColor(ctx, "color.text.primary") ?? FALLBACK.textPrimary;
1107
- if (tokensOverride.title_color) {
1108
- titleColor = resolveTokenPath(ctx.tokens, tokensOverride.title_color) ?? titleColor;
1109
- }
1110
- const subtitleStyleName = (ct("subtitle_style") ?? "body_sm").replace("typography.", "");
1111
- const bodyStyleName = (ct("body_style") ?? "body").replace("typography.", "");
1112
-
1113
- const containerStyle = buildContainerStyle(section, ctx);
1114
- const cursor = interactive ? "cursor: pointer; " : "";
1115
-
1116
- if (variant === "inline") {
1117
- const inlinePadding = ct("padding") ?? "4px";
1118
- // Handle badge in inline variant (e.g. priority dot)
1119
- let badgeHtml = "";
1120
- if (props.badge) {
1121
- if (props.badge.dot) {
1122
- const severity = props.badge.severity ? resolveBinding(String(props.badge.severity), ctx) : "neutral";
1123
- const severityColor = getSeverityColor(severity, ctx);
1124
- badgeHtml = `<span style="width: ${sp(ctx,"sm",8)}; height: ${sp(ctx,"sm",8)}; border-radius: 50%; background: ${severityColor}; display: inline-block; flex-shrink: 0;"></span>`;
1125
- } else {
1126
- badgeHtml = renderBadge(props.badge, ctx);
1127
- }
1128
- }
1129
- return `<div style="${containerStyle}${cursor} display: flex; align-items: center; gap: ${sp(ctx,"xs",4)}; padding: ${inlinePadding};">
1130
- ${badgeHtml}
1131
- <span style="${titleStyle} color: ${titleColor};">${escapeHtml(resolvedTitle)}</span>
1132
- ${resolvedSubtitle ? `<span style="${getTypographyCSS(ctx.tokens, subtitleStyleName)} color: ${resolveColor(ctx, "color.text.secondary") ?? FALLBACK.textSecondary}; margin-left: ${sp(ctx,"xxs",2)};">${escapeHtml(resolvedSubtitle)}</span>` : ""}
1133
- </div>`;
1134
- }
1135
-
1136
- if (variant === "hero") {
1137
- const heroPadding = ct("padding") ?? "24px";
1138
- const badge = props.badge ? renderBadge(props.badge, ctx) : "";
1139
- const metadata = props.metadata ? renderMetadata(props.metadata, ctx) : "";
1140
- return `<div style="${containerStyle} padding: ${heroPadding};">
1141
- <div style="${getTypographyCSS(ctx.tokens, titleStyleName)} color: ${titleColor};">${escapeHtml(resolvedTitle)}</div>
1142
- ${badge}${metadata}
1143
- </div>`;
1144
- }
1145
-
1146
- if (variant === "stat") {
1147
- const statPadding = ct("padding") ?? "16px";
1148
- const statBg = ct("background") ?? FALLBACK.surfaceSecondary;
1149
- const statRadius = ct("radius") ?? "8px";
1150
- const labelStyleName = (ct("label_style") ?? "caption").replace("typography.", "");
1151
- const valueStyleName = (ct("value_style") ?? "heading_lg").replace("typography.", "");
1152
- const leading = props.leading ? renderLeading(props.leading, ctx) : "";
1153
- return `<div style="${containerStyle} padding: ${statPadding}; background: ${statBg}; border-radius: ${statRadius};">
1154
- <div style="${getTypographyCSS(ctx.tokens, labelStyleName)} color: ${resolveColor(ctx, "color.text.tertiary") ?? FALLBACK.textTertiary}; margin-bottom: ${sp(ctx,"xs",4)};">${escapeHtml(resolvedTitle)}</div>
1155
- <div style="display: flex; align-items: center; gap: ${sp(ctx,"xs",4)};">
1156
- ${leading}
1157
- <span style="${getTypographyCSS(ctx.tokens, valueStyleName)} color: ${titleColor};">${escapeHtml(body || resolvedSubtitle)}</span>
1158
- </div>
1159
- </div>`;
1160
- }
1161
-
1162
- if (variant === "compact") {
1163
- const compactPaddingV = ct("padding_v") ?? "8px";
1164
- const separatorColor = resolveColor(ctx, "color.border.default") ?? FALLBACK.borderDefault;
1165
- const leading = props.leading ? renderLeading(props.leading, ctx) : "";
1166
- const trailing = props.trailing ? renderTrailing(props.trailing, ctx) : "";
1167
- return `<div style="${containerStyle}${cursor} display: flex; align-items: center; padding: ${compactPaddingV} 0; border-bottom: 1px solid ${separatorColor};">
1168
- ${leading}
1169
- <div style="flex: 1; min-width: 0;">
1170
- <div style="${getTypographyCSS(ctx.tokens, titleStyleName)} color: ${titleColor};">${escapeHtml(resolvedTitle)}</div>
1171
- ${resolvedSubtitle ? `<div style="${getTypographyCSS(ctx.tokens, subtitleStyleName)} color: ${resolveColor(ctx, "color.text.secondary") ?? FALLBACK.textSecondary};">${escapeHtml(resolvedSubtitle)}</div>` : ""}
1172
- </div>
1173
- ${trailing}
1174
- </div>`;
1175
- }
1176
-
1177
- // Default "card" variant
1178
- const cardPadding = ct("padding") ?? "16px";
1179
- const cardBg = ct("background") ?? FALLBACK.surfacePrimary;
1180
- const cardRadius = ct("radius") ?? "16px";
1181
- const leading = props.leading ? renderLeading(props.leading, ctx) : "";
1182
- const trailing = props.trailing ? renderTrailing(props.trailing, ctx) : "";
1183
- const cardShadow = resolveTokenPath(ctx.tokens, "elevation.sm") ?? "0 1px 2px rgba(0,0,0,0.04)";
1184
- return `<div style="${containerStyle}${cursor} padding: ${cardPadding}; background: ${cardBg}; border-radius: ${cardRadius}; display: flex; align-items: center; gap: ${sp(ctx,"sm",8)}; box-shadow: ${cardShadow};">
1185
- ${leading}
1186
- <div style="flex: 1; min-width: 0;">
1187
- <div style="${titleStyle} color: ${titleColor};">${escapeHtml(resolvedTitle)}</div>
1188
- ${resolvedSubtitle ? `<div style="${getTypographyCSS(ctx.tokens, subtitleStyleName)} color: ${resolveColor(ctx, "color.text.secondary") ?? FALLBACK.textSecondary}; margin-top: ${sp(ctx,"xxs",2)};">${escapeHtml(resolvedSubtitle)}</div>` : ""}
1189
- </div>
1190
- ${trailing}
1191
- </div>`;
1192
- }
1193
-
1194
- function renderLeading(leading: any, ctx: PreviewContext): string {
1195
- if (typeof leading === "object" && leading.icon) {
1196
- const color = leading.color ? (resolveTokenPath(ctx.tokens, leading.color) ?? leading.color) : FALLBACK.textSecondary;
1197
- const size = leading.size ?? 20;
1198
- const iconName = typeof leading.icon === "string" ? leading.icon : (leading.ref ?? "circle_fill");
1199
- return `<span style="margin-right: ${sp(ctx,"sm",8)}; flex-shrink: 0;">${renderIcon(iconName, size, color)}</span>`;
1200
- }
1201
- if (typeof leading === "object" && leading.media) {
1202
- const size = leading.size ?? 40;
1203
- const radius = leading.radius ?? 8;
1204
- const bg = leading.fallback?.background
1205
- ? (resolveTokenPath(ctx.tokens, leading.fallback.background) ?? FALLBACK.brand)
1206
- : FALLBACK.brand;
1207
- // Resolve initials from mock data if possible
1208
- const initialsSource = leading.fallback?.initials;
1209
- let initials = "U";
1210
- if (initialsSource && ctx) {
1211
- const name = resolveDotPath(initialsSource, ctx);
1212
- if (typeof name === "string" && name.length > 0) {
1213
- initials = name.split(" ").map((w: string) => w[0]).join("").toUpperCase().slice(0, 2);
1214
- }
1215
- }
1216
- return `<div style="width: ${size}px; height: ${size}px; border-radius: ${radius}px; background: ${bg}; flex-shrink: 0; display: flex; align-items: center; justify-content: center; color: white; font-weight: 600; font-size: ${size * 0.35}px; margin-right: ${sp(ctx,"sm",8)};">${initials}</div>`;
1217
- }
1218
- if (typeof leading === "object" && leading.contract) {
1219
- return renderContract(leading, ctx, 0);
1220
- }
1221
- return "";
1222
- }
1223
-
1224
- function renderTrailing(trailing: any, ctx: PreviewContext): string {
1225
- if (typeof trailing === "string") {
1226
- const resolved = resolveBinding(trailing, ctx);
1227
- return `<span style="${getTypographyCSS(ctx.tokens, "body_sm")} color: ${resolveColor(ctx, "color.text.secondary") ?? FALLBACK.textSecondary}; flex-shrink: 0;">${escapeHtml(resolved)}</span>`;
1228
- }
1229
- if (typeof trailing === "object" && trailing.icon) {
1230
- const color = resolveTokenPath(ctx.tokens, trailing.color ?? "color.text.tertiary") ?? FALLBACK.textTertiary;
1231
- const iconName = typeof trailing.icon === "string" ? trailing.icon : "chevron_right";
1232
- const size = trailing.size ?? 14;
1233
- return `<span style="flex-shrink: 0; margin-left: ${sp(ctx,"xs",4)};">${renderIcon(iconName, size, color)}</span>`;
1234
- }
1235
- if (typeof trailing === "object" && trailing.contract) {
1236
- return `<span style="flex-shrink: 0;">${renderContract(trailing, ctx, 0)}</span>`;
1237
- }
1238
- if (typeof trailing === "object" && trailing.dot) {
1239
- const severity = trailing.severity ? resolveBinding(String(trailing.severity), ctx) : "neutral";
1240
- const severityColor = getSeverityColor(severity, ctx);
1241
- return `<span style="width: ${sp(ctx,"sm",8)}; height: ${sp(ctx,"sm",8)}; border-radius: 50%; background: ${severityColor}; display: inline-block; flex-shrink: 0;"></span>`;
1242
- }
1243
- return "";
1244
- }
1245
-
1246
- function renderBadge(badge: any, ctx: PreviewContext): string {
1247
- if (!badge) return "";
1248
- const text = badge.text ? resolveBinding(badge.text, ctx) : "";
1249
- const severity = badge.severity ? resolveBinding(badge.severity, ctx) : "neutral";
1250
- const severityColor = getSeverityColor(severity, ctx);
1251
- return `<span style="display: inline-block; padding: ${sp(ctx,"xs",4)} ${sp(ctx,"sm",8)}; border-radius: ${sp(ctx,"xs",4)}; background: ${colorWithAlpha(severityColor, 0.13)}; color: ${severityColor}; ${getTypographyCSS(ctx.tokens, "caption")} font-weight: 500; margin-top: ${sp(ctx,"sm",8)};">${escapeHtml(text)}</span>`;
1252
- }
1253
-
1254
- function renderMetadata(metadata: any, ctx: PreviewContext): string {
1255
- if (!metadata) return "";
1256
- const items: string[] = [];
1257
- for (const [key, value] of Object.entries(metadata)) {
1258
- const resolved = resolveBinding(String(value), ctx);
1259
- items.push(`<span style="${getTypographyCSS(ctx.tokens, "caption")} color: ${resolveColor(ctx, "color.text.secondary") ?? FALLBACK.textSecondary};">${escapeHtml(resolved)}</span>`);
1260
- }
1261
- return items.length ? `<div style="display: flex; gap: ${sp(ctx,"sm",8)}; margin-top: ${sp(ctx,"sm",8)};">${items.join("")}</div>` : "";
1262
- }
1263
-
1264
- function renderActionTrigger(section: any, props: any, ctx: PreviewContext): string {
1265
- const variant = section.variant ?? "primary";
1266
- const adapted = applyAdaptive(section, ctx.sizeClass);
1267
- const fullWidth = adapted.full_width ?? false;
1268
- const tokensOverride = adapted.tokens_override ?? {};
1269
- const size = adapted.size ?? "md";
1270
-
1271
- const ct = (key: string) => resolveContractToken("action_trigger", variant, key, tokensOverride, ctx);
1272
-
1273
- const label = props.label ? resolveBinding(props.label, ctx) : "";
1274
-
1275
- // Resolve label with t_params
1276
- let resolvedLabel = label;
1277
- if (props.label?.startsWith?.("$t:") && props.t_params) {
1278
- const resolvedParams: Record<string, any> = {};
1279
- for (const [k, v] of Object.entries(props.t_params)) {
1280
- resolvedParams[k] = typeof v === "string" ? resolveBinding(v, ctx) : v;
1281
- }
1282
- resolvedLabel = resolveLocale(ctx.locale, props.label, resolvedParams, ctx);
1283
- // Handle ICU select in result
1284
- if (resolvedLabel.includes("select,")) {
1285
- resolvedLabel = resolveSelect(resolvedLabel, resolvedParams, ctx);
1286
- }
1287
- }
1288
-
1289
- // Resolve colors from contract tokens (spec defaults → project overrides → instance overrides)
1290
- let bg = ct("background") ?? FALLBACK.brand;
1291
- let color = ct("text") ?? FALLBACK.onBrand;
1292
- let border = "none";
1293
-
1294
- // Handle border from contract tokens
1295
- const borderToken = CONTRACT_TOKENS.action_trigger?.[variant]?.border;
1296
- if (borderToken && typeof borderToken === "object") {
1297
- const bColor = resolveTokenPath(ctx.tokens, borderToken.color) ?? FALLBACK.borderDefault;
1298
- border = `${borderToken.width ?? 1}px solid ${bColor}`;
1299
- }
1300
-
1301
- // Token overrides for color (legacy support)
1302
- if (tokensOverride.text) {
1303
- color = resolveTokenPath(ctx.tokens, tokensOverride.text) ?? color;
1304
- }
1305
-
1306
- const paddingH = ct("padding_h") ?? (size === "lg" ? sp(ctx,"xl",28) : size === "sm" ? sp(ctx,"sm",12) : sp(ctx,"md",20));
1307
- const paddingV = size === "lg" ? sp(ctx,"md",14) : size === "sm" ? sp(ctx,"xs",6) : sp(ctx,"sm",10);
1308
- const padding = `${paddingV} ${paddingH}`;
1309
- const radius = ct("radius") ?? "8px";
1310
- const width = fullWidth ? "width: 100%; " : "";
1311
- let shadow = "";
1312
- if (tokensOverride.shadow && tokensOverride.shadow !== "none") {
1313
- const resolved = resolveTokenPath(ctx.tokens, tokensOverride.shadow);
1314
- if (resolved && resolved !== "none") {
1315
- shadow = `box-shadow: ${resolved}; `;
1316
- }
1317
- }
1318
-
1319
- const containerStyle = buildContainerStyle(section, ctx);
1320
-
1321
- const iconName = props.icon ? resolveBinding(String(props.icon), ctx) : "";
1322
- const iconHtml = iconName ? renderIcon(iconName, size === "lg" ? 20 : 16, color) : "";
1323
-
1324
- return `<button style="${containerStyle}${width}display: inline-flex; align-items: center; justify-content: center; gap: 8px; padding: ${padding}; background: ${bg}; color: ${color}; border: ${border}; border-radius: ${radius}; ${getTypographyCSS(ctx.tokens, "body")} font-weight: 500; cursor: pointer; ${shadow}">${iconHtml}${escapeHtml(resolvedLabel)}</button>`;
1325
- }
1326
-
1327
- function renderInputField(section: any, props: any, ctx: PreviewContext): string {
1328
- const inputType = section.input_type ?? "text";
1329
- const tokensOverride = section.tokens_override ?? {};
1330
- // Map input_type to the contract token variant name (most map to "text")
1331
- const tokenVariant = inputType === "toggle" ? "toggle" : "text";
1332
- const ct = (key: string) => resolveContractToken("input_field", tokenVariant, key, tokensOverride, ctx);
1333
-
1334
- const label = props.label ? resolveBinding(props.label, ctx) : "";
1335
- const placeholder = props.placeholder ? resolveBinding(props.placeholder, ctx) : "";
1336
- const value = props.value ? resolveBinding(props.value, ctx) : "";
1337
- const helperText = props.helper_text ? resolveBinding(props.helper_text, ctx) : "";
1338
-
1339
- const bg = ct("background") ?? FALLBACK.surfacePrimary;
1340
- const borderColor = resolveColor(ctx, "color.border.default") ?? FALLBACK.borderDefault;
1341
- const borderWidth = tokensOverride.border?.width ?? CONTRACT_TOKENS.input_field?.text?.border?.width ?? 1;
1342
- const radius = ct("radius") ?? "8px";
1343
-
1344
- const containerStyle = buildContainerStyle(section, ctx);
1345
-
1346
- if (inputType === "toggle") {
1347
- const isOn = value === "true" || value === true;
1348
- const trackOn = ct("track_on") ?? FALLBACK.brand;
1349
- const trackOff = ct("track_off") ?? FALLBACK.borderDefault;
1350
- const trackColor = isOn ? trackOn : trackOff;
1351
- const trackW = parseInt(ct("track_width") ?? "51", 10);
1352
- const trackH = parseInt(ct("track_height") ?? "31", 10);
1353
- const thumbSize = parseInt(ct("thumb_size") ?? "27", 10);
1354
- const thumbOffset = Math.round((trackH - thumbSize) / 2);
1355
- return `<div style="${containerStyle} display: flex; align-items: center; justify-content: space-between; padding: ${sp(ctx,"sm",12)} 0; border-bottom: 1px solid ${borderColor};">
1356
- <span style="${getTypographyCSS(ctx.tokens, "body")}">${escapeHtml(label)}</span>
1357
- <div style="width: ${trackW}px; height: ${trackH}px; border-radius: ${trackH / 2}px; background: ${trackColor}; position: relative;">
1358
- <div style="width: ${thumbSize}px; height: ${thumbSize}px; border-radius: 50%; background: white; position: absolute; top: ${thumbOffset}px; ${isOn ? `right: ${thumbOffset}px` : `left: ${thumbOffset}px`}; box-shadow: 0 1px 3px rgba(0,0,0,0.2);"></div>
1359
- </div>
1360
- </div>
1361
- ${helperText ? `<div style="${getTypographyCSS(ctx.tokens, "caption")} color: ${resolveColor(ctx, "color.text.tertiary") ?? FALLBACK.textTertiary}; margin-top: ${sp(ctx,"xxs",2)};">${escapeHtml(helperText)}</div>` : ""}`;
1362
- }
1363
-
1364
- if (inputType === "select") {
1365
- const options = props.options ?? [];
1366
- const optionsHtml = options.map((opt: any) => {
1367
- const optLabel = opt.label ? resolveBinding(opt.label, ctx) : opt.value;
1368
- const selected = value === opt.value ? " selected" : "";
1369
- return `<option value="${escapeHtml(opt.value)}"${selected}>${escapeHtml(optLabel)}</option>`;
1370
- }).join("");
1371
- return `<div style="${containerStyle} display: flex; align-items: center; justify-content: space-between; padding: ${sp(ctx,"sm",12)} 0; border-bottom: 1px solid ${borderColor};">
1372
- <span style="${getTypographyCSS(ctx.tokens, "body")}">${escapeHtml(label)}</span>
1373
- <select style="padding: ${sp(ctx,"xs",6)} ${sp(ctx,"sm",10)}; border: 1px solid ${borderColor}; border-radius: ${sp(ctx,"xs",6)}; ${getTypographyCSS(ctx.tokens, "body_sm")} background: ${bg}; color: inherit;">${optionsHtml}</select>
1374
- </div>`;
1375
- }
1376
-
1377
- if (inputType === "checkbox") {
1378
- const isChecked = value === "true" || value === true;
1379
- const brandColor = resolveColor(ctx, "color.brand.primary") ?? FALLBACK.brand;
1380
- const successColor = resolveColor(ctx, "color.semantic.success") ?? FALLBACK.success;
1381
- const checkColor = isChecked ? successColor : brandColor;
1382
- return `<div style="${containerStyle} display: flex; align-items: center; margin-right: ${sp(ctx,"sm",10)}; flex-shrink: 0;">
1383
- <div style="width: 22px; height: 22px; border-radius: 11px; border: 2px solid ${isChecked ? checkColor : borderColor}; background: ${isChecked ? checkColor : "transparent"}; display: flex; align-items: center; justify-content: center;">
1384
- ${isChecked ? `<span style="color: white; font-size: 13px; line-height: 1;">&#x2713;</span>` : ""}
1385
- </div>
1386
- </div>`;
1387
- }
1388
-
1389
- // Default text input
1390
- const maxWidth = section.adaptive?.[ctx.sizeClass]?.max_width;
1391
- const maxWidthStyle = maxWidth ? `max-width: ${maxWidth}px; ` : "";
1392
- return `<div style="${containerStyle}${maxWidthStyle}">
1393
- <input type="text" placeholder="${escapeHtml(placeholder)}" value="${escapeHtml(value)}" style="width: 100%; padding: ${sp(ctx,"sm",10)} ${sp(ctx,"sm",12)}; border: ${borderWidth}px solid ${borderColor}; border-radius: ${radius}; ${getTypographyCSS(ctx.tokens, "body")} background: ${bg}; box-sizing: border-box;" />
1394
- </div>`;
1395
- }
1396
-
1397
- function renderCollection(section: any, props: any, ctx: PreviewContext, depth: number): string {
1398
- const variant = section.variant ?? "list";
1399
- const data = props.data;
1400
- const itemContract = props.item_contract;
1401
- const itemVariant = props.item_variant;
1402
- const itemPropsMap = props.item_props_map ?? {};
1403
-
1404
- // Resolve data — could be a string path to mock data array
1405
- let items: any[] = [];
1406
- if (typeof data === "string") {
1407
- const resolved = resolveDotPath(data, ctx);
1408
- if (Array.isArray(resolved)) items = resolved;
1409
- } else if (Array.isArray(data)) {
1410
- items = data;
1411
- }
1412
-
1413
- // Empty state
1414
- if (items.length === 0 && props.empty_state) {
1415
- const es = props.empty_state;
1416
- const title = es.title ? resolveBinding(es.title, ctx) : "";
1417
- const body = es.body ? resolveBinding(es.body, ctx) : "";
1418
- return `<div style="text-align: center; padding: 48px 24px;">
1419
- <div style="${getTypographyCSS(ctx.tokens, "heading")} color: ${resolveColor(ctx, "color.text.primary") ?? FALLBACK.textPrimary}; margin-bottom: 8px;">${escapeHtml(title)}</div>
1420
- <div style="${getTypographyCSS(ctx.tokens, "body_sm")} color: ${resolveColor(ctx, "color.text.secondary") ?? FALLBACK.textSecondary};">${escapeHtml(body)}</div>
1421
- </div>`;
1422
- }
1423
-
1424
- const containerStyle = buildContainerStyle(section, ctx);
1425
-
1426
- if (variant === "chips" || variant === "chip_row") {
1427
- const chipItems = items.map((item) => {
1428
- const chipProps: Record<string, any> = {};
1429
- for (const [key, expr] of Object.entries(itemPropsMap)) {
1430
- chipProps[key] = resolveWithItem(String(expr), item, ctx);
1431
- }
1432
- const label = chipProps.label ?? (typeof item === "object" ? item.label : String(item));
1433
- const resolvedLabel = typeof label === "string" && label.startsWith("$t:")
1434
- ? resolveLocale(ctx.locale, label, undefined, ctx)
1435
- : label;
1436
- const brandPrimary = resolveColor(ctx, "color.brand.primary") ?? FALLBACK.brand;
1437
- const isSelected = item.id === (resolveDotPath(String(props.selected ?? ""), ctx) ?? "");
1438
- const bg = isSelected ? colorWithAlpha(brandPrimary, 0.08) : "transparent";
1439
- const color = isSelected ? brandPrimary : (resolveColor(ctx, "color.text.primary") ?? FALLBACK.textPrimary);
1440
- const border = isSelected ? `1px solid ${brandPrimary}` : `1px solid ${resolveColor(ctx, "color.border.default") ?? FALLBACK.borderDefault}`;
1441
- return `<button style="padding: ${sp(ctx,"xs",6)} ${sp(ctx,"sm",14)}; border-radius: 20px; background: ${bg}; color: ${color}; border: ${border}; ${getTypographyCSS(ctx.tokens, "body_sm")} cursor: pointer; white-space: nowrap;">${escapeHtml(String(resolvedLabel))}</button>`;
1442
- }).join("");
1443
- return `<div style="${containerStyle} display: flex; gap: ${sp(ctx,"sm",8)}; overflow-x: auto; flex-wrap: wrap;">${chipItems}</div>`;
1444
- }
1445
-
1446
- // Shared item resolver for all list-like variants
1447
- function resolveCollectionItem(item: any): string {
1448
- const mappedProps: Record<string, any> = {};
1449
- for (const [key, expr] of Object.entries(itemPropsMap)) {
1450
- if (typeof expr === "string") {
1451
- mappedProps[key] = resolveWithItem(expr, item, ctx);
1452
- } else if (typeof expr === "object") {
1453
- mappedProps[key] = expr; // Pass through complex objects like leading/trailing
1454
- }
1455
- }
1456
-
1457
- const contractSection = {
1458
- contract: itemContract,
1459
- variant: itemVariant ?? "compact",
1460
- props: { ...mappedProps },
1461
- interactive: props.interactive ?? false,
1462
- };
1463
-
1464
- // Resolve leading contract within item context (e.g. checkbox)
1465
- if (mappedProps.leading && typeof mappedProps.leading === "object" && mappedProps.leading.contract) {
1466
- const leadingProps = { ...mappedProps.leading.props };
1467
- if (leadingProps.value && typeof leadingProps.value === "string") {
1468
- leadingProps.value = resolveWithItem(leadingProps.value, item, ctx);
1469
- }
1470
- contractSection.props.leading = { ...mappedProps.leading, props: leadingProps };
1471
- }
1472
-
1473
- // Resolve trailing contract within item context (e.g. priority dot)
1474
- if (mappedProps.trailing && typeof mappedProps.trailing === "object" && mappedProps.trailing.contract) {
1475
- const trailingProps = { ...mappedProps.trailing.props };
1476
- if (trailingProps.badge && typeof trailingProps.badge === "object") {
1477
- const resolvedBadge = { ...trailingProps.badge };
1478
- if (resolvedBadge.severity && typeof resolvedBadge.severity === "string") {
1479
- resolvedBadge.severity = resolveWithItem(resolvedBadge.severity, item, ctx);
1480
- }
1481
- trailingProps.badge = resolvedBadge;
1482
- }
1483
- contractSection.props.trailing = { ...mappedProps.trailing, props: trailingProps };
1484
- }
1485
-
1486
- return renderContract(contractSection, ctx, depth + 1);
1487
- }
1488
-
1489
- // Grid variant
1490
- if (variant === "grid") {
1491
- const columns = props.columns ?? 2;
1492
- const gap = resolveTokenPath(ctx.tokens, props.gap ?? "spacing.md") ?? sp(ctx, "md", 16);
1493
- const gridItems = items.map((item) => resolveCollectionItem(item)).join("");
1494
- return `<div style="${containerStyle} display: grid; grid-template-columns: repeat(${columns}, 1fr); gap: ${gap};">${gridItems}</div>`;
1495
- }
1496
-
1497
- // Horizontal scroll / carousel variants
1498
- if (variant === "horizontal_scroll" || variant === "carousel") {
1499
- const gap = resolveTokenPath(ctx.tokens, props.gap ?? "spacing.sm") ?? sp(ctx, "sm", 8);
1500
- const scrollItems = items.map((item) =>
1501
- `<div style="flex-shrink: 0;">${resolveCollectionItem(item)}</div>`
1502
- ).join("");
1503
- return `<div style="${containerStyle} display: flex; overflow-x: auto; gap: ${gap}; -webkit-overflow-scrolling: touch;">${scrollItems}</div>`;
1504
- }
1505
-
1506
- // List variant (default)
1507
- const listItems = items.map((item) => resolveCollectionItem(item)).join("");
1508
-
1509
- return `<div style="${containerStyle}">${listItems}</div>`;
1510
- }
1511
-
1512
- function renderNavContainer(section: any, props: any, ctx: PreviewContext): string {
1513
- const adapted = applyAdaptive(section, ctx.sizeClass);
1514
- const variant = adapted.variant ?? "tab_bar";
1515
- const tokensOverride = adapted.tokens_override ?? {};
1516
- const ct = (key: string) => resolveContractToken("nav_container", variant, key, tokensOverride, ctx);
1517
- const items = props.items ?? [];
1518
- const selected = props.selected ?? "";
1519
-
1520
- const brandPrimary = resolveColor(ctx, "color.brand.primary") ?? FALLBACK.brand;
1521
- const textSecondary = resolveColor(ctx, "color.text.secondary") ?? FALLBACK.textSecondary;
1522
- const surface = ct("background") ?? FALLBACK.surfacePrimary;
1523
- const border = resolveColor(ctx, "color.border.default") ?? FALLBACK.borderDefault;
1524
- const iconSize = parseInt(ct("icon_size") ?? "24", 10);
1525
- const labelStyleName = (ct("label_style") ?? "caption").replace("typography.", "");
1526
-
1527
- if (variant === "tab_bar") {
1528
- const tabHeight = parseInt(ct("height") ?? "49", 10);
1529
- const tabs = items.map((item: any) => {
1530
- const label = item.label ? resolveBinding(item.label, ctx) : "";
1531
- const isSelected = item.id === selected;
1532
- const color = isSelected ? brandPrimary : textSecondary;
1533
- const iconName = isSelected ? (item.icon_active ?? item.icon ?? "") : (item.icon ?? "");
1534
- // Badge
1535
- let badgeHtml = "";
1536
- if (item.badge) {
1537
- const count = item.badge.count ? resolveDotPath(String(item.badge.count), ctx) : undefined;
1538
- if (count !== undefined && Number(count) > 0) {
1539
- badgeHtml = `<span style="position: absolute; top: -4px; right: -8px; min-width: 16px; height: 16px; border-radius: 8px; background: ${brandPrimary}; color: white; font-size: 10px; font-weight: 600; display: flex; align-items: center; justify-content: center; padding: 0 4px;">${count}</span>`;
1540
- }
1541
- }
1542
- return `<div style="flex: 1; text-align: center; padding: ${sp(ctx,"sm",8)} 0; color: ${color}; ${getTypographyCSS(ctx.tokens, labelStyleName)} cursor: pointer;">
1543
- <div style="position: relative; display: inline-flex; align-items: center; justify-content: center; width: ${iconSize + 4}px; height: ${iconSize}px; margin: 0 auto ${sp(ctx,"xxs",2)}; border-radius: ${(iconSize + 4) / 2}px; background: ${isSelected ? colorWithAlpha(brandPrimary, 0.08) : "transparent"};">
1544
- ${iconName ? renderIcon(iconName, iconSize, color) : ""}
1545
- ${badgeHtml}
1546
- </div>
1547
- ${escapeHtml(label)}
1548
- </div>`;
1549
- }).join("");
1550
- return `<nav style="position: fixed; bottom: 0; left: 0; right: 0; display: flex; background: ${surface}; border-top: 1px solid ${border}; padding: ${sp(ctx,"xs",6)} 0 env(safe-area-inset-bottom, ${sp(ctx,"sm",8)}); z-index: 50;">${tabs}</nav>`;
1551
- }
1552
-
1553
- if (variant === "rail") {
1554
- const railWidth = parseInt(ct("width") ?? "72", 10);
1555
- const railItems = items.map((item: any) => {
1556
- const label = item.label ? resolveBinding(item.label, ctx) : "";
1557
- const isSelected = item.id === selected;
1558
- const color = isSelected ? brandPrimary : textSecondary;
1559
- const iconName = isSelected ? (item.icon_active ?? item.icon ?? "") : (item.icon ?? "");
1560
- return `<div style="text-align: center; padding: ${sp(ctx,"sm",12)} ${sp(ctx,"sm",8)}; color: ${color}; ${getTypographyCSS(ctx.tokens, labelStyleName)} cursor: pointer;">
1561
- <div style="display: inline-flex; align-items: center; justify-content: center; width: ${iconSize + 4}px; height: ${iconSize}px; margin: 0 auto ${sp(ctx,"xs",4)}; border-radius: ${(iconSize + 4) / 2}px; background: ${isSelected ? colorWithAlpha(brandPrimary, 0.08) : "transparent"};">
1562
- ${iconName ? renderIcon(iconName, iconSize, color) : ""}
1563
- </div>
1564
- ${escapeHtml(label)}
1565
- </div>`;
1566
- }).join("");
1567
- return `<nav style="position: fixed; left: 0; top: 0; bottom: 0; width: ${railWidth}px; display: flex; flex-direction: column; align-items: center; background: ${surface}; border-right: 1px solid ${border}; padding-top: ${sp(ctx,"md",16)}; z-index: 50;">${railItems}</nav>`;
1568
- }
1569
-
1570
- if (variant === "sidebar") {
1571
- const sidebarWidth = parseInt(ct("width_expanded") ?? "240", 10);
1572
- const itemRadius = ct("item_radius") ?? "8px";
1573
- const itemPaddingH = ct("item_padding_h") ?? "16px";
1574
- const sidebarItems = items.map((item: any) => {
1575
- const label = item.label ? resolveBinding(item.label, ctx) : "";
1576
- const isSelected = item.id === selected;
1577
- const bg = isSelected ? colorWithAlpha(brandPrimary, 0.08) : "transparent";
1578
- const color = isSelected ? brandPrimary : (resolveColor(ctx, "color.text.primary") ?? FALLBACK.textPrimary);
1579
- const iconName = isSelected ? (item.icon_active ?? item.icon ?? "") : (item.icon ?? "");
1580
- return `<div style="padding: ${sp(ctx,"sm",10)} ${itemPaddingH}; margin: ${sp(ctx,"xxs",2)} ${sp(ctx,"sm",8)}; border-radius: ${itemRadius}; background: ${bg}; color: ${color}; ${getTypographyCSS(ctx.tokens, labelStyleName)} cursor: pointer; display: flex; align-items: center; gap: ${sp(ctx,"sm",12)};">
1581
- ${iconName ? renderIcon(iconName, iconSize, color) : ""}
1582
- ${escapeHtml(label)}
1583
- </div>`;
1584
- }).join("");
1585
- return `<nav style="position: fixed; left: 0; top: 0; bottom: 0; width: ${sidebarWidth}px; display: flex; flex-direction: column; background: ${surface}; border-right: 1px solid ${border}; padding-top: ${sp(ctx,"md",16)}; z-index: 50;">${sidebarItems}</nav>`;
1586
- }
1587
-
1588
- return "";
1589
- }
1590
-
1591
- // ── helpers ──────────────────────────────────────────────────────────
1592
-
1593
- function applyAdaptive(section: any, sizeClass: string): any {
1594
- if (!section?.adaptive) return section;
1595
-
1596
- const adaptive = section.adaptive;
1597
- // Find the best match: exact > fallback to broader
1598
- let override: any = null;
1599
- if (adaptive[sizeClass]) {
1600
- override = adaptive[sizeClass];
1601
- } else if (sizeClass === "regular" && adaptive.compact) {
1602
- override = adaptive.compact;
1603
- } else if (sizeClass === "expanded") {
1604
- override = adaptive.regular ?? adaptive.compact;
1605
- }
1606
-
1607
- if (!override) return section;
1608
-
1609
- // Merge override into section (shallow)
1610
- const merged = { ...section };
1611
- for (const [key, value] of Object.entries(override)) {
1612
- if (key === "adaptive") continue;
1613
- if (typeof value === "object" && !Array.isArray(value) && merged[key] && typeof merged[key] === "object") {
1614
- merged[key] = { ...merged[key], ...value };
1615
- } else {
1616
- merged[key] = value;
1617
- }
1618
- }
1619
- return merged;
1620
- }
1621
-
1622
- function evaluateCondition(condition: string, ctx: PreviewContext): boolean {
1623
- if (condition.includes("!=")) {
1624
- const [left, right] = condition.split("!=").map((s) => s.trim());
1625
- const leftVal = resolveDotPath(left, ctx);
1626
- const rightVal = right === "null" ? null : right.replace(/['"]/g, "");
1627
- return leftVal != rightVal;
1628
- }
1629
- if (condition.includes("==")) {
1630
- const [left, right] = condition.split("==").map((s) => s.trim());
1631
- const leftVal = resolveDotPath(left, ctx);
1632
- const rightVal = right.replace(/['"]/g, "");
1633
- return String(leftVal) === rightVal;
1634
- }
1635
- // Truthy check
1636
- const val = resolveDotPath(condition, ctx);
1637
- return !!val;
1638
- }
1639
-
1640
- function getSeverityColor(severity: string, ctx: PreviewContext): string {
1641
- const map: Record<string, string> = {
1642
- success: resolveColor(ctx, "color.semantic.success") ?? FALLBACK.success,
1643
- warning: resolveColor(ctx, "color.semantic.warning") ?? FALLBACK.warning,
1644
- error: resolveColor(ctx, "color.semantic.danger") ?? FALLBACK.danger,
1645
- danger: resolveColor(ctx, "color.semantic.danger") ?? FALLBACK.danger,
1646
- info: resolveColor(ctx, "color.semantic.info") ?? FALLBACK.info,
1647
- neutral: resolveColor(ctx, "color.text.tertiary") ?? FALLBACK.textTertiary,
1648
- };
1649
- return map[severity] ?? map.neutral;
1650
- }
1651
-
1652
- function buildContainerStyle(section: any, ctx: PreviewContext): string {
1653
- let style = "";
1654
-
1655
- // Padding — full or directional
1656
- if (section.padding) {
1657
- const val = resolveTokenPath(ctx.tokens, section.padding);
1658
- if (val) style += `padding: ${val}; `;
1659
- }
1660
- if (section.padding_h) {
1661
- const val = resolveTokenPath(ctx.tokens, section.padding_h);
1662
- if (val) {
1663
- // page_margin resolves to "16px 16px" (v h) — extract horizontal component
1664
- const parts = val.split(" ");
1665
- const h = parts.length > 1 ? parts[1] : parts[0];
1666
- style += `padding-left: ${h}; padding-right: ${h}; `;
1667
- }
1668
- }
1669
- if (section.padding_v) {
1670
- const val = resolveTokenPath(ctx.tokens, section.padding_v);
1671
- if (val) {
1672
- const parts = val.split(" ");
1673
- const v = parts[0];
1674
- style += `padding-top: ${v}; padding-bottom: ${v}; `;
1675
- }
1676
- }
1677
-
1678
- // Margins
1679
- if (section.margin_top) {
1680
- const val = resolveTokenPath(ctx.tokens, section.margin_top);
1681
- if (val) style += `margin-top: ${val.split(" ")[0]}; `;
1682
- }
1683
- if (section.margin_bottom) {
1684
- const val = resolveTokenPath(ctx.tokens, section.margin_bottom);
1685
- if (val) style += `margin-bottom: ${val.split(" ")[0]}; `;
1686
- }
1687
-
1688
- // Max width (from adaptive or direct)
1689
- if (section.max_width) {
1690
- style += `max-width: ${section.max_width}px; `;
1691
- }
1692
-
1693
- // tokens_override spacing — margin_bottom used in some overrides
1694
- if (section.tokens_override?.margin_bottom) {
1695
- const val = resolveTokenPath(ctx.tokens, section.tokens_override.margin_bottom);
1696
- if (val) style += `margin-bottom: ${val.split(" ")[0]}; `;
1697
- }
1698
-
1699
- return style;
1700
- }
1701
-
1702
- function buildLayoutStyle(layout: any, ctx: PreviewContext): string {
1703
- if (!layout?.type) return "";
1704
-
1705
- const type = layout.type;
1706
-
1707
- // Resolve spacing with spec-defined defaults per layout primitive
1708
- const defaultSpacing: Record<string, string> = {
1709
- stack: "spacing.md",
1710
- row: "spacing.sm",
1711
- grid: "spacing.md",
1712
- };
1713
- const spacingRef = layout.spacing ?? defaultSpacing[type];
1714
- const spacing = spacingRef ? (resolveTokenPath(ctx.tokens, spacingRef) ?? "0px") : "0px";
1715
- const gap = layout.gap ? (resolveTokenPath(ctx.tokens, layout.gap) ?? "0px") : spacing;
1716
- const align = layout.align ?? "stretch";
1717
-
1718
- const alignMap: Record<string, string> = {
1719
- center: "center",
1720
- leading: "flex-start",
1721
- trailing: "flex-end",
1722
- stretch: "stretch",
1723
- };
1724
-
1725
- switch (type) {
1726
- case "stack":
1727
- return `display: flex; flex-direction: column; gap: ${gap}; align-items: ${alignMap[align] ?? align}; `;
1728
- case "row":
1729
- return `display: flex; flex-direction: row; gap: ${gap}; align-items: center; ${layout.wrap ? "flex-wrap: wrap; " : ""}`;
1730
- case "grid": {
1731
- const cols = layout.columns ?? 2;
1732
- return `display: grid; grid-template-columns: repeat(${cols}, 1fr); gap: ${gap}; `;
1733
- }
1734
- case "scroll_vertical":
1735
- return "display: flex; flex-direction: column; overflow-y: auto; ";
1736
- case "split_view":
1737
- return "display: flex; flex-direction: row; ";
1738
- default:
1739
- return "";
1740
- }
1741
- }
1742
-
1743
- function escapeHtml(text: string): string {
1744
- return text
1745
- .replace(/&/g, "&amp;")
1746
- .replace(/</g, "&lt;")
1747
- .replace(/>/g, "&gt;")
1748
- .replace(/"/g, "&quot;");
1749
- }
1750
-
1751
- // ── generic fallback palette ─────────────────────────────────────────
1752
- // Neutral defaults used when tokens are missing. NOT project-specific.
1753
- const FALLBACK = {
1754
- brand: "#0066CC",
1755
- onBrand: "#FFFFFF",
1756
- textPrimary: "#1A1A1A",
1757
- textSecondary:"#666666",
1758
- textTertiary: "#999999",
1759
- surfacePrimary: "#FFFFFF",
1760
- surfaceSecondary: "#F5F5F5",
1761
- borderDefault: "#E0E0E0",
1762
- danger: "#CC3333",
1763
- warning: "#CC8800",
1764
- success: "#339933",
1765
- info: "#3366CC",
1766
- darkBg: "#1A1A1A",
1767
- darkText: "#E0E0E0",
1768
- };
1769
-
1770
- // ── page assembly ───────────────────────────────────────────────────
1771
-
1772
- export function renderPage(ctx: PreviewContext): string {
1773
- const screenDef = ctx.screen[ctx.screenName];
1774
- if (!screenDef) {
1775
- return `<!DOCTYPE html><html><body><p>Screen "${ctx.screenName}" not found.</p></body></html>`;
1776
- }
1777
-
1778
- const bgColor = resolveColor(ctx, "color.surface.primary")
1779
- ?? (ctx.theme === "dark" ? FALLBACK.darkBg : FALLBACK.surfacePrimary);
1780
- const textColor = resolveColor(ctx, "color.text.primary")
1781
- ?? (ctx.theme === "dark" ? FALLBACK.darkText : FALLBACK.textPrimary);
1782
-
1783
- const fontFamily = ctx.tokens.typography?.typography?.font_family?.primary?.value ?? "system-ui";
1784
-
1785
- // Render navigation if present
1786
- let navHtml = "";
1787
- let contentMargin = "";
1788
- if (screenDef.navigation) {
1789
- navHtml = renderNavContainer(screenDef.navigation, screenDef.navigation.props ?? {}, ctx);
1790
- const adapted = applyAdaptive(screenDef.navigation, ctx.sizeClass);
1791
- const navVariant = adapted.variant ?? "tab_bar";
1792
- const navTokensOverride = adapted.tokens_override ?? {};
1793
- if (navVariant === "tab_bar") {
1794
- const tabHeight = parseInt(resolveContractToken("nav_container", "tab_bar", "height", navTokensOverride, ctx) ?? "49", 10);
1795
- contentMargin = `padding-bottom: ${tabHeight + 12}px; `;
1796
- } else if (navVariant === "rail") {
1797
- const railWidth = parseInt(resolveContractToken("nav_container", "rail", "width", navTokensOverride, ctx) ?? "72", 10);
1798
- contentMargin = `margin-left: ${railWidth}px; `;
1799
- } else if (navVariant === "sidebar") {
1800
- const sidebarWidth = parseInt(resolveContractToken("nav_container", "sidebar", "width_expanded", navTokensOverride, ctx) ?? "240", 10);
1801
- contentMargin = `margin-left: ${sidebarWidth}px; `;
1802
- }
1803
- }
1804
-
1805
- // Build layout
1806
- const layout = screenDef.layout ?? {};
1807
- const adaptedLayout = applyAdaptive(layout, ctx.sizeClass);
1808
- const sections = adaptedLayout.sections ?? layout.sections ?? [];
1809
- const layoutType = adaptedLayout.type ?? "scroll_vertical";
1810
- const safeArea = adaptedLayout.safe_area ?? layout.safe_area ?? false;
1811
- const safeAreaPadding = safeArea ? "padding-top: 44px; " : "";
1812
-
1813
- // Resolve layout-level padding
1814
- // Sources (in priority order):
1815
- // 1. Explicit layout.padding / padding_h / padding_v on the screen
1816
- // 2. Default margin from layout.size_classes.<current_size_class>.margin tokens
1817
- // When safe_area is active, use directional properties to avoid overriding padding-top.
1818
- let layoutPadding = "";
1819
- const hasExplicitPadding = adaptedLayout.padding || adaptedLayout.padding_h || adaptedLayout.padding_v;
1820
-
1821
- if (adaptedLayout.padding) {
1822
- const val = resolveTokenPath(ctx.tokens, adaptedLayout.padding);
1823
- if (val) {
1824
- const parts = val.split(" ");
1825
- const v = parts[0];
1826
- const h = parts.length > 1 ? parts[1] : v;
1827
- if (safeArea) {
1828
- layoutPadding += `padding-bottom: ${v}; padding-left: ${h}; padding-right: ${h}; `;
1829
- } else {
1830
- layoutPadding += `padding: ${val}; `;
1831
- }
1832
- }
1833
- }
1834
- if (adaptedLayout.padding_h) {
1835
- const val = resolveTokenPath(ctx.tokens, adaptedLayout.padding_h);
1836
- if (val) {
1837
- const parts = val.split(" ");
1838
- const h = parts.length > 1 ? parts[1] : parts[0];
1839
- layoutPadding += `padding-left: ${h}; padding-right: ${h}; `;
1840
- }
1841
- }
1842
- if (adaptedLayout.padding_v) {
1843
- const val = resolveTokenPath(ctx.tokens, adaptedLayout.padding_v);
1844
- if (val) {
1845
- const parts = val.split(" ");
1846
- const v = parts[0];
1847
- layoutPadding += `padding-top: ${v}; padding-bottom: ${v}; `;
1848
- }
1849
- }
1850
-
1851
- // Fallback: apply size_class default margin from layout tokens
1852
- if (!hasExplicitPadding) {
1853
- const sizeClassDef = ctx.tokens.layout?.layout?.size_classes?.[ctx.sizeClass];
1854
- if (sizeClassDef?.margin) {
1855
- // margin can be a spacing token ref like "spacing.md" or just a scale name like "md"
1856
- const marginRef = sizeClassDef.margin.includes(".") ? sizeClassDef.margin : `spacing.${sizeClassDef.margin}`;
1857
- const val = resolveTokenPath(ctx.tokens, marginRef);
1858
- if (val) {
1859
- const parts = val.split(" ");
1860
- const m = parts.length > 1 ? parts[1] : parts[0];
1861
- layoutPadding += `padding-left: ${m}; padding-right: ${m}; `;
1862
- // Also apply vertical padding unless safe_area already provides top padding
1863
- if (!safeArea) {
1864
- layoutPadding += `padding-top: ${m}; `;
1865
- }
1866
- layoutPadding += `padding-bottom: ${m}; `;
1867
- }
1868
- }
1869
- }
1870
-
1871
- let bodyHtml: string;
1872
- if (layoutType === "split_view") {
1873
- const primarySections = adaptedLayout.primary?.sections ?? [];
1874
- const primaryWidth = adaptedLayout.primary_width ?? 0.38;
1875
- const primaryHtml = primarySections
1876
- .map((sId: string) => {
1877
- const sec = sections.find((s: any) => s.id === sId);
1878
- return sec ? renderSection(sec, ctx) : "";
1879
- })
1880
- .join("");
1881
- bodyHtml = `<div style="display: flex; height: 100%;">
1882
- <div style="width: ${primaryWidth * 100}%; overflow-y: auto; border-right: 1px solid ${resolveColor(ctx, "color.border.default") ?? FALLBACK.borderDefault};">${primaryHtml}</div>
1883
- <div style="flex: 1; display: flex; align-items: center; justify-content: center; color: ${resolveColor(ctx, "color.text.tertiary") ?? FALLBACK.textTertiary}; ${getTypographyCSS(ctx.tokens, "body_sm")}">Select an item</div>
1884
- </div>`;
1885
- } else {
1886
- // Resolve section_gap — default spacing between top-level sections
1887
- const sectionGap = resolveTokenPath(ctx.tokens, "spacing.section_gap") ?? "24px";
1888
- const wrapperStyle = `${safeAreaPadding}${layoutPadding}display: flex; flex-direction: column; gap: ${sectionGap}; `.trim();
1889
- const sectionsHtml = sections.map((section: any) => renderSection(section, ctx)).join("\n");
1890
- bodyHtml = wrapperStyle
1891
- ? `<div style="${wrapperStyle}">${sectionsHtml}</div>`
1892
- : sectionsHtml;
1893
- }
1894
-
1895
- return `<!DOCTYPE html>
1896
- <html lang="${ctx.locale.$locale ?? "en"}" dir="${ctx.locale.$direction ?? "ltr"}">
1897
- <head>
1898
- <meta charset="utf-8" />
1899
- <meta name="viewport" content="width=device-width, initial-scale=1" />
1900
- <style>
1901
- *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
1902
- html, body { width: 100%; height: 100%; }
1903
- body {
1904
- background: ${bgColor};
1905
- color: ${textColor};
1906
- font-family: '${fontFamily}', system-ui, -apple-system, sans-serif;
1907
- font-size: ${(() => { const bodyScale = ctx.tokens.typography?.typography?.scale?.body; const size = bodyScale ? (typeof bodyScale.size === "object" ? bodyScale.size.base : bodyScale.size) : 16; return `${size}px`; })()};
1908
- line-height: ${ctx.tokens.typography?.typography?.scale?.body?.line_height ?? 1.5};
1909
- -webkit-font-smoothing: antialiased;
1910
- ${contentMargin}
1911
- }
1912
- button { font-family: inherit; }
1913
- input, select, textarea { font-family: inherit; color: inherit; }
1914
- img { max-width: 100%; height: auto; }
1915
- </style>
1916
- </head>
1917
- <body>
1918
- ${navHtml}
1919
- ${bodyHtml}
1920
- </body>
1921
- </html>`;
1922
- }