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.
- package/README.md +2 -10
- package/dist/check/audit.js +392 -0
- package/dist/check/index.js +216 -0
- package/dist/cli/configure-target.js +391 -0
- package/dist/cli/index.js +510 -0
- package/dist/cli/init.js +1047 -0
- package/dist/drift/index.js +903 -0
- package/dist/mcp-server/index.js +886 -0
- package/dist/mcp-server/preview-render.js +1761 -0
- package/dist/mcp-server/preview.js +233 -0
- package/dist/mcp-server/screenshot-android.js +458 -0
- package/dist/mcp-server/screenshot-ios.js +639 -0
- package/dist/mcp-server/screenshot-shared.js +180 -0
- package/dist/mcp-server/screenshot.js +459 -0
- package/dist/prepare/index.js +1216 -0
- package/dist/runtime/package-paths.js +33 -0
- package/dist/schema/semantic-lint.js +564 -0
- package/dist/schema/validate.js +689 -0
- package/dist/status/index.js +194 -0
- package/docs/images/how-it-works.svg +56 -0
- package/docs/images/workflows.svg +76 -0
- package/package.json +12 -13
- package/check/audit.ts +0 -426
- package/check/index.ts +0 -320
- package/cli/configure-target.ts +0 -523
- package/cli/index.ts +0 -537
- package/cli/init.ts +0 -1253
- package/docs/images/how-it-works-dark.png +0 -0
- package/docs/images/how-it-works-light.png +0 -0
- package/docs/images/workflows-dark.png +0 -0
- package/docs/images/workflows-light.png +0 -0
- package/drift/index.ts +0 -1165
- package/mcp-server/index.ts +0 -1041
- package/mcp-server/preview-render.ts +0 -1922
- package/mcp-server/preview.ts +0 -292
- package/mcp-server/screenshot-android.ts +0 -621
- package/mcp-server/screenshot-ios.ts +0 -753
- package/mcp-server/screenshot-shared.ts +0 -237
- package/mcp-server/screenshot.ts +0 -563
- package/prepare/index.ts +0 -1530
- package/schema/semantic-lint.ts +0 -692
- package/schema/validate.ts +0 -870
- package/scripts/regenerate-previews.ts +0 -136
- package/scripts/take-all-screenshots.ts +0 -507
- 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: "✓",
|
|
756
|
-
checkmark_circle: "✓",
|
|
757
|
-
checkmark_circle_fill: "✓",
|
|
758
|
-
checkmark_list: "☑",
|
|
759
|
-
checkmark_list_fill: "☑",
|
|
760
|
-
plus: "+",
|
|
761
|
-
plus_circle: "⊕",
|
|
762
|
-
pencil: "✎",
|
|
763
|
-
trash: "🗑",
|
|
764
|
-
search: "🔍",
|
|
765
|
-
gear: "⚙",
|
|
766
|
-
gear_fill: "⚙",
|
|
767
|
-
folder: "📁",
|
|
768
|
-
folder_fill: "📂",
|
|
769
|
-
calendar: "📅",
|
|
770
|
-
calendar_fill: "📅",
|
|
771
|
-
clock: "🕑",
|
|
772
|
-
tag: "🏷",
|
|
773
|
-
person: "👤",
|
|
774
|
-
chevron_right: "❯",
|
|
775
|
-
chevron_left: "❮",
|
|
776
|
-
flag: "⚑",
|
|
777
|
-
flag_fill: "⚑",
|
|
778
|
-
circle_fill: "●",
|
|
779
|
-
star: "☆",
|
|
780
|
-
star_fill: "★",
|
|
781
|
-
heart: "♡",
|
|
782
|
-
arrow_uturn_left: "↩",
|
|
783
|
-
square_arrow_up: "⬆",
|
|
784
|
-
exclamationmark_triangle: "⚠",
|
|
785
|
-
};
|
|
786
|
-
|
|
787
|
-
function renderIcon(name: string, size: number, color: string): string {
|
|
788
|
-
const symbol = ICON_MAP[name] ?? ICON_MAP[name.replace(/_fill$/, "")] ?? "○";
|
|
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;">✓</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, "&")
|
|
1746
|
-
.replace(/</g, "<")
|
|
1747
|
-
.replace(/>/g, ">")
|
|
1748
|
-
.replace(/"/g, """);
|
|
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
|
-
}
|