@timbal-ai/timbal-react 1.5.0 → 1.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/CHANGELOG.md +32 -1
  2. package/README.md +33 -0
  3. package/dist/app.cjs +884 -642
  4. package/dist/app.d.cts +4 -4
  5. package/dist/app.d.ts +4 -4
  6. package/dist/app.esm.js +6 -6
  7. package/dist/{chart-artifact-2OTDTRwM.d.ts → chart-artifact-BYl5C-dk.d.ts} +90 -29
  8. package/dist/{chart-artifact-CS3qyGIY.d.cts → chart-artifact-Dpt4t5sf.d.cts} +90 -29
  9. package/dist/{chat-ClmzWzCX.d.cts → chat-DDsp-Vzz.d.cts} +1 -1
  10. package/dist/{chat-ClmzWzCX.d.ts → chat-DDsp-Vzz.d.ts} +1 -1
  11. package/dist/chat.cjs +26 -26
  12. package/dist/chat.d.cts +3 -3
  13. package/dist/chat.d.ts +3 -3
  14. package/dist/chat.esm.js +3 -3
  15. package/dist/{chunk-TZI3ID3C.esm.js → chunk-24B4I4XC.esm.js} +3 -3
  16. package/dist/{chunk-QIABF4KB.esm.js → chunk-ELEY66OH.esm.js} +2 -2
  17. package/dist/{chunk-WMKPT5BV.esm.js → chunk-HSL36SJ4.esm.js} +6 -6
  18. package/dist/chunk-JJOO4PR5.esm.js +391 -0
  19. package/dist/{chunk-AZL2WANO.esm.js → chunk-MBS7XHV2.esm.js} +28 -28
  20. package/dist/{chunk-5ECRZ5O7.esm.js → chunk-NO5AWNWT.esm.js} +224 -57
  21. package/dist/{chunk-ZNYAETFD.esm.js → chunk-R4RQT2XQ.esm.js} +2 -2
  22. package/dist/{chunk-JYDJRGDE.esm.js → chunk-TMP7RIA7.esm.js} +2 -2
  23. package/dist/{chunk-SZDYIRMB.esm.js → chunk-UVPXH4MB.esm.js} +647 -532
  24. package/dist/{chunk-IGHBLJV3.esm.js → chunk-WQIQW7EM.esm.js} +3 -2
  25. package/dist/{chunk-B4XAC4G7.esm.js → chunk-YYEI6XME.esm.js} +361 -527
  26. package/dist/{circular-progress-CDsJwIPF.d.cts → circular-progress-B9nnwzCu.d.cts} +1 -1
  27. package/dist/{circular-progress-CDsJwIPF.d.ts → circular-progress-B9nnwzCu.d.ts} +1 -1
  28. package/dist/cli/timbal-ui-lint.mjs +503 -0
  29. package/dist/index.cjs +1358 -856
  30. package/dist/index.d.cts +9 -8
  31. package/dist/index.d.ts +9 -8
  32. package/dist/index.esm.js +40 -20
  33. package/dist/{kanban-U5xNe9py.d.cts → kanban-FFBeaZPS.d.cts} +4 -4
  34. package/dist/{kanban-U5xNe9py.d.ts → kanban-FFBeaZPS.d.ts} +4 -4
  35. package/dist/{layout-B8r6Jbat.d.ts → layout-CuKeSY74.d.ts} +1 -1
  36. package/dist/{layout-Cu7Ijn04.d.cts → layout-PzVwkJyL.d.cts} +1 -1
  37. package/dist/site.cjs +71 -0
  38. package/dist/site.d.cts +15 -1
  39. package/dist/site.d.ts +15 -1
  40. package/dist/site.esm.js +12 -311
  41. package/dist/studio.cjs +31 -31
  42. package/dist/studio.d.cts +2 -2
  43. package/dist/studio.d.ts +2 -2
  44. package/dist/studio.esm.js +7 -7
  45. package/dist/{timbal-v2-button-B7vPs7gg.d.ts → timbal-v2-button-DCAZNyUx.d.cts} +1 -1
  46. package/dist/{timbal-v2-button-B7vPs7gg.d.cts → timbal-v2-button-DCAZNyUx.d.ts} +1 -1
  47. package/dist/ui.cjs +77 -77
  48. package/dist/ui.d.cts +3 -3
  49. package/dist/ui.d.ts +3 -3
  50. package/dist/ui.esm.js +15 -15
  51. package/dist/{welcome-NXZlcihe.d.cts → welcome-B00oH5Io.d.cts} +1 -1
  52. package/dist/{welcome-DduQAC4K.d.ts → welcome-DU-4NTjZ.d.ts} +1 -1
  53. package/package.json +13 -3
@@ -144,7 +144,7 @@ declare const overlayListPanelClass: string;
144
144
  * Shared selectable row inside an overlay (menu item, list option). Consumers
145
145
  * override padding (e.g. `pl-8` for a leading indicator) on top of this.
146
146
  */
147
- declare const overlayItemClass = "relative flex cursor-default items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground";
147
+ declare const overlayItemClass = "relative flex cursor-default items-center gap-2 rounded-md px-2 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground";
148
148
 
149
149
  /**
150
150
  * Text input on the shared control-surface contract — visually identical to
@@ -144,7 +144,7 @@ declare const overlayListPanelClass: string;
144
144
  * Shared selectable row inside an overlay (menu item, list option). Consumers
145
145
  * override padding (e.g. `pl-8` for a leading indicator) on top of this.
146
146
  */
147
- declare const overlayItemClass = "relative flex cursor-default items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground";
147
+ declare const overlayItemClass = "relative flex cursor-default items-center gap-2 rounded-md px-2 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground";
148
148
 
149
149
  /**
150
150
  * Text input on the shared control-surface contract — visually identical to
@@ -0,0 +1,503 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli/ui-lint.ts
4
+ import { readFileSync } from "fs";
5
+
6
+ // src/design/ui-vocabulary.ts
7
+ var RESERVED_GRADIENT_TOKENS = [
8
+ "primary-fill-from",
9
+ "primary-fill-to",
10
+ "primary-fill-hover-from",
11
+ "primary-fill-hover-to",
12
+ "primary-fill-active-from",
13
+ "primary-fill-active-to",
14
+ "secondary-fill-hover-from",
15
+ "secondary-fill-hover-to",
16
+ "secondary-fill-active-from",
17
+ "secondary-fill-active-to",
18
+ "destructive-fill-hover-from",
19
+ "destructive-fill-hover-to",
20
+ "destructive-fill-active-from",
21
+ "destructive-fill-active-to",
22
+ "ghost-fill-hover",
23
+ "ghost-fill-active",
24
+ "elevated-from",
25
+ "elevated-to",
26
+ "modal-from",
27
+ "modal-to",
28
+ "playground-from",
29
+ "playground-via",
30
+ "playground-to"
31
+ ];
32
+ var TAILWIND_PALETTE_COLORS = [
33
+ "slate",
34
+ "gray",
35
+ "zinc",
36
+ "neutral",
37
+ "stone",
38
+ "red",
39
+ "orange",
40
+ "amber",
41
+ "yellow",
42
+ "lime",
43
+ "green",
44
+ "emerald",
45
+ "teal",
46
+ "cyan",
47
+ "sky",
48
+ "blue",
49
+ "indigo",
50
+ "violet",
51
+ "purple",
52
+ "fuchsia",
53
+ "pink",
54
+ "rose"
55
+ ];
56
+ var COLOR_UTILITY_PREFIXES = [
57
+ "bg",
58
+ "text",
59
+ "border",
60
+ "ring",
61
+ "from",
62
+ "via",
63
+ "to",
64
+ "fill",
65
+ "stroke",
66
+ "decoration",
67
+ "outline",
68
+ "shadow",
69
+ "divide",
70
+ "accent",
71
+ "caret"
72
+ ];
73
+ var SLOP_BUDGETS = {
74
+ /** Max decorative/standalone icons rendered in a single generated file. */
75
+ maxIconsPerView: 6,
76
+ /** Max consecutive list rows separated by an explicit border/divider before
77
+ * it reads as a "ruled table" — prefer spacing or zebra instead. */
78
+ maxRowDividers: 2
79
+ };
80
+
81
+ // src/design/ui-lint.ts
82
+ var PALETTE_GROUP = TAILWIND_PALETTE_COLORS.join("|");
83
+ var PREFIX_GROUP = COLOR_UTILITY_PREFIXES.join("|");
84
+ var RAW_COLOR_RE = new RegExp(
85
+ `(?:^|[\\s"'\`:])(?:[a-z-]+:)*(?:${PREFIX_GROUP})-(?:${PALETTE_GROUP})-\\d{2,3}(?:/\\d{1,3})?`,
86
+ "g"
87
+ );
88
+ var COLOR_LITERAL_RE = /#[0-9a-fA-F]{3,8}\b|\b(?:oklch|rgba?|hsla?)\s*\(/g;
89
+ var INLINE_STYLE_COLOR_RE = /style=\{\{[^}]*\b(?:color|background|backgroundColor|borderColor|fill|stroke)\b/;
90
+ var BOLD_VALUE_RE = /text-(?:xl|2xl|3xl|4xl|5xl|6xl)[^"'`]*\bfont-(?:bold|extrabold|black|semibold)|font-(?:bold|extrabold|black|semibold)[^"'`]*text-(?:xl|2xl|3xl|4xl|5xl|6xl)/;
91
+ var GRADIENT_RE = /\bbg-(?:gradient|linear|radial|conic)-/;
92
+ var GRADIENT_DIRECTIONS = /* @__PURE__ */ new Set([
93
+ "t",
94
+ "tr",
95
+ "r",
96
+ "br",
97
+ "b",
98
+ "bl",
99
+ "l",
100
+ "tl"
101
+ ]);
102
+ var ICON_IMPORT_RE = /from\s+["']lucide-react["']/;
103
+ var RAW_CONTROL_SURFACE_RE = /\bborder-input\b/;
104
+ var COLORED_HOVER_RE = /\bhover:(?:bg|from|to|via)-(?:primary|destructive|success|warn|danger|blue|emerald|green|amber|red|indigo|violet|purple|pink|rose|sky|cyan|teal|lime|yellow|orange|fuchsia)\b/;
105
+ var TREND_CONTEXT_RE = /\b(?:trend|delta|TrendingUp|TrendingDown|ArrowUp|ArrowDown|ArrowUpRight|ArrowDownRight|MoveUp|MoveDown)\b|[+\-]\d+(?:\.\d+)?\s*%/;
106
+ var TREND_COLOR_RE = /\b(?:text|bg|border)-(?:success|destructive|emerald|green|lime|teal|red|rose|orange|amber)(?:-\d{2,3})?(?:\/\d{1,3})?\b/;
107
+ var RESERVED_GRADIENT_SET = new Set(RESERVED_GRADIENT_TOKENS);
108
+ function stripVariants(util) {
109
+ return util.replace(/^(?:[a-z-]+:)*/, "");
110
+ }
111
+ function isCommentOrImport(line) {
112
+ const t = line.trim();
113
+ return t.startsWith("//") || t.startsWith("*") || t.startsWith("/*") || t.startsWith("import ") || t.startsWith("export ");
114
+ }
115
+ function lintGeneratedUi(source, options = {}) {
116
+ const maxIcons = options.maxIconsPerView ?? SLOP_BUDGETS.maxIconsPerView;
117
+ const maxRowDividers = options.maxRowDividers ?? SLOP_BUDGETS.maxRowDividers;
118
+ const findings = [];
119
+ const lines = source.split("\n");
120
+ let usesLucide = false;
121
+ let iconUsageCount = 0;
122
+ let dividerRunCount = 0;
123
+ let pageTitle = null;
124
+ const pageTitleMatch = source.match(/<Page\s+[^>]*\btitle=(?:"([^"]+)"|\{["']([^"']+)["']\})/);
125
+ if (pageTitleMatch) {
126
+ pageTitle = (pageTitleMatch[1] || pageTitleMatch[2]).trim().toLowerCase();
127
+ }
128
+ const hasChat = /\b(?:TimbalChat|AppChatPanel|Thread)\b/.test(source);
129
+ const lucideNames = /* @__PURE__ */ new Set();
130
+ const openCards = [];
131
+ for (let i = 0; i < lines.length; i++) {
132
+ const line = lines[i];
133
+ const lineNo = i + 1;
134
+ if (ICON_IMPORT_RE.test(line)) {
135
+ usesLucide = true;
136
+ const named = line.match(/\{([^}]*)\}/);
137
+ if (named) {
138
+ for (const raw of named[1].split(",")) {
139
+ const name = raw.trim().split(/\s+as\s+/)[0].trim();
140
+ if (name) lucideNames.add(name);
141
+ }
142
+ }
143
+ continue;
144
+ }
145
+ if (isCommentOrImport(line)) continue;
146
+ const cardMatch = line.match(/<(Card|SurfaceCard|ArtifactCard)\b/);
147
+ if (cardMatch) {
148
+ const isSelfClosing = /\/>/.test(line) && line.indexOf(cardMatch[0]) < line.indexOf("/>");
149
+ if (!isSelfClosing) {
150
+ if (openCards.length > 0) {
151
+ const parentCard = openCards[openCards.length - 1];
152
+ findings.push({
153
+ rule: "no-card-in-card",
154
+ severity: "warn",
155
+ line: lineNo,
156
+ message: `Card inside card. A <${cardMatch[1]}> is nested inside the <${parentCard.type}> opened on L${parentCard.line}. Double borders/shadows add no information \u2014 group with spacing or a <Section> instead.`,
157
+ snippet: line.trim().slice(0, 120)
158
+ });
159
+ }
160
+ openCards.push({ type: cardMatch[1], line: lineNo });
161
+ }
162
+ }
163
+ const closeMatch = line.match(/<\/(Card|SurfaceCard|ArtifactCard)\b/);
164
+ if (closeMatch && openCards.length > 0) {
165
+ const idx = openCards.map((c) => c.type).lastIndexOf(closeMatch[1]);
166
+ if (idx !== -1) {
167
+ openCards.splice(idx, 1);
168
+ }
169
+ }
170
+ if (openCards.length > 0) {
171
+ const tableMatch = line.match(/<(DataTable|table|Table)\b/);
172
+ if (tableMatch) {
173
+ const parentCard = openCards[openCards.length - 1];
174
+ findings.push({
175
+ rule: "no-table-in-card",
176
+ severity: "error",
177
+ line: lineNo,
178
+ message: `Table inside card. Never wrap a <${tableMatch[1]}> or table inside a <${parentCard.type}> (opened on L${parentCard.line}). Place the table directly on the Page or Section instead.`,
179
+ snippet: line.trim().slice(0, 120)
180
+ });
181
+ }
182
+ }
183
+ const rawColors = line.match(RAW_COLOR_RE);
184
+ if (rawColors) {
185
+ for (const m of rawColors) {
186
+ findings.push({
187
+ rule: "raw-color",
188
+ severity: "error",
189
+ line: lineNo,
190
+ message: "Hardcoded palette color. Use a semantic token (text-primary, bg-muted, border-border, text-muted-foreground, \u2026) so dark mode and rebranding work.",
191
+ snippet: m.trim().replace(/^["'`:\s]+/, "")
192
+ });
193
+ }
194
+ }
195
+ const literals = line.match(COLOR_LITERAL_RE);
196
+ if (literals) {
197
+ findings.push({
198
+ rule: "color-literal",
199
+ severity: "error",
200
+ line: lineNo,
201
+ message: "Hardcoded color literal. Colors must come from the theme generator (createTimbalTheme) and semantic tokens \u2014 never inline hex/oklch/rgb.",
202
+ snippet: line.trim().slice(0, 120)
203
+ });
204
+ }
205
+ if (INLINE_STYLE_COLOR_RE.test(line)) {
206
+ findings.push({
207
+ rule: "inline-style-color",
208
+ severity: "error",
209
+ line: lineNo,
210
+ message: "Inline style color. Move color to a semantic Tailwind token on className.",
211
+ snippet: line.trim().slice(0, 120)
212
+ });
213
+ }
214
+ if (RAW_CONTROL_SURFACE_RE.test(line)) {
215
+ findings.push({
216
+ rule: "raw-control-surface",
217
+ severity: "warn",
218
+ line: lineNo,
219
+ message: "Hand-rolled control surface (border-input). Use a kit control \u2014 SearchInput, Select, DropdownMenu, FieldInput, FieldSelect \u2014 so it matches every other control.",
220
+ snippet: line.trim().slice(0, 120)
221
+ });
222
+ }
223
+ if (COLORED_HOVER_RE.test(line)) {
224
+ findings.push({
225
+ rule: "no-colored-hover",
226
+ severity: "warn",
227
+ line: lineNo,
228
+ message: "Colored hover background/gradient. House style: interactive cards and list items must use neutral hover states \u2014 never hard-code colored backgrounds or borders on hover.",
229
+ snippet: line.trim().slice(0, 120)
230
+ });
231
+ }
232
+ if (TREND_CONTEXT_RE.test(line) && TREND_COLOR_RE.test(line)) {
233
+ findings.push({
234
+ rule: "neutral-trend",
235
+ severity: "warn",
236
+ line: lineNo,
237
+ message: "Colored trend indicator. House style: don't tint deltas green/red on every metric \u2014 show a trend only when the change is the point, and keep it muted (text-muted-foreground).",
238
+ snippet: line.trim().slice(0, 120)
239
+ });
240
+ }
241
+ if (BOLD_VALUE_RE.test(line)) {
242
+ findings.push({
243
+ rule: "bold-metric",
244
+ severity: "warn",
245
+ line: lineNo,
246
+ message: "Bold large value. House style: metric values use font-normal, not bold \u2014 bold giant numbers read as a template.",
247
+ snippet: line.trim().slice(0, 120)
248
+ });
249
+ }
250
+ if (GRADIENT_RE.test(line)) {
251
+ const fromTo = line.match(
252
+ new RegExp(`(?:from|via|to)-([a-z-]+)`, "g")
253
+ );
254
+ const colorStops = (fromTo ?? []).map((u) => stripVariants(u).replace(/^(?:from|via|to)-/, "")).filter((token) => !GRADIENT_DIRECTIONS.has(token));
255
+ const allReserved = colorStops.length > 0 && colorStops.every((token) => RESERVED_GRADIENT_SET.has(token));
256
+ if (!allReserved) {
257
+ findings.push({
258
+ rule: "data-gradient",
259
+ severity: "warn",
260
+ line: lineNo,
261
+ message: "Gradient outside chrome. Gradients are reserved for buttons / elevated / modal / playground \u2014 never a data card, tile, or table.",
262
+ snippet: line.trim().slice(0, 120)
263
+ });
264
+ }
265
+ }
266
+ if (/\b(?:border-t|border-b|divide-y)\b/.test(line)) {
267
+ dividerRunCount++;
268
+ if (dividerRunCount === maxRowDividers + 1) {
269
+ findings.push({
270
+ rule: "row-divider",
271
+ severity: "warn",
272
+ line: lineNo,
273
+ message: "Divider on every row. Prefer spacing (gap-*) or zebra striping over a rule under each list item.",
274
+ snippet: line.trim().slice(0, 120)
275
+ });
276
+ }
277
+ } else if (line.trim() !== "" && !line.includes("className")) {
278
+ if (!/^\s*[)>}/]/.test(line)) dividerRunCount = 0;
279
+ }
280
+ if (usesLucide && lucideNames.size > 0) {
281
+ for (const name of lucideNames) {
282
+ const usage = new RegExp(`<${name}\\b`, "g");
283
+ const hits = line.match(usage);
284
+ if (hits) iconUsageCount += hits.length;
285
+ }
286
+ }
287
+ if (pageTitle) {
288
+ const titleMatch = line.match(/<(Section|ChartPanel|Card|DataTable|SurfaceCard)\s+[^>]*\btitle=(?:"([^"]+)"|\{["']([^"']+)["']\})/);
289
+ if (titleMatch) {
290
+ const element = titleMatch[1];
291
+ const titleVal = (titleMatch[2] || titleMatch[3]).trim().toLowerCase();
292
+ if (titleVal === pageTitle || titleVal.includes(pageTitle) || pageTitle.includes(titleVal)) {
293
+ findings.push({
294
+ rule: "no-title-repetition",
295
+ severity: "warn",
296
+ line: lineNo,
297
+ message: `Title repetition. The <${element}> title "${titleVal}" repeats or is very similar to the <Page> title "${pageTitle}". Drop the title from the child element or use a title-less Section to avoid redundant headings.`,
298
+ snippet: line.trim().slice(0, 120)
299
+ });
300
+ }
301
+ }
302
+ }
303
+ if (hasChat) {
304
+ const wrappingMatch = line.match(/<(Card|Section|SurfaceCard|FormSection|SettingsSection)\b/);
305
+ if (wrappingMatch) {
306
+ findings.push({
307
+ rule: "no-chat-wrapping",
308
+ severity: "error",
309
+ line: lineNo,
310
+ message: `Chat component wrapping. Never wrap TimbalChat or AppChatPanel inside a <${wrappingMatch[1]}> or custom bordered container. Let the chat component fill the page or slot directly.`,
311
+ snippet: line.trim().slice(0, 120)
312
+ });
313
+ }
314
+ const headingMatch = line.match(/<(h[1-6])\b/);
315
+ if (headingMatch) {
316
+ findings.push({
317
+ rule: "no-chat-wrapping",
318
+ severity: "error",
319
+ line: lineNo,
320
+ message: `Custom heading in chat view. Do not render custom <${headingMatch[1]}> headings on the chat page. Pass welcome.heading to TimbalChat if you need to customize the welcome title.`,
321
+ snippet: line.trim().slice(0, 120)
322
+ });
323
+ }
324
+ }
325
+ }
326
+ if (usesLucide && iconUsageCount > maxIcons) {
327
+ findings.push({
328
+ rule: "icon-spam",
329
+ severity: "warn",
330
+ line: 1,
331
+ message: `Too many icons (${iconUsageCount} > ${maxIcons}). Icons should mark actions/nav/status \u2014 not decorate every label, tile, and card.`,
332
+ snippet: `${iconUsageCount} lucide-react icon usages`
333
+ });
334
+ }
335
+ const effectiveErrors = findings.filter(
336
+ (f) => f.severity === "error" || options.strict && f.severity === "warn"
337
+ ).length;
338
+ return {
339
+ findings,
340
+ errorCount: findings.filter((f) => f.severity === "error").length,
341
+ warnCount: findings.filter((f) => f.severity === "warn").length,
342
+ ok: effectiveErrors === 0
343
+ };
344
+ }
345
+ function formatLintReport(findings) {
346
+ if (findings.length === 0) return "";
347
+ const lines = findings.slice().sort((a, b) => a.line - b.line).map((f) => {
348
+ const tag = f.severity === "error" ? "ERROR" : "warn ";
349
+ return ` ${tag} L${f.line} [${f.rule}] ${f.message}
350
+ \u2192 ${f.snippet}`;
351
+ });
352
+ const errs = findings.filter((f) => f.severity === "error").length;
353
+ const warns = findings.filter((f) => f.severity === "warn").length;
354
+ return `Anti-slop review: ${errs} error(s), ${warns} warning(s)
355
+ ${lines.join("\n")}`;
356
+ }
357
+
358
+ // src/design/ui-review.ts
359
+ function reviewGeneratedUi(source, options = {}) {
360
+ const lint = lintGeneratedUi(source, options);
361
+ const report = formatLintReport(lint.findings);
362
+ if (lint.ok) {
363
+ return { lint, passed: true, report, revisionPrompt: null };
364
+ }
365
+ const revisionPrompt = [
366
+ "The generated UI failed the Timbal anti-slop review. Fix every issue below, then return the corrected code only.",
367
+ "",
368
+ report,
369
+ "",
370
+ "Rules: colors come only from semantic tokens (text-primary, bg-muted, border-border, text-muted-foreground, \u2026) \u2014 never palette colors, hex, or oklch. Icons mark actions/nav/status, not decoration. Metric values use font-normal. No gradients on data surfaces. No divider under every row. Do not change anything that already passed."
371
+ ].join("\n");
372
+ return { lint, passed: false, report, revisionPrompt };
373
+ }
374
+ var UI_REVIEW_AGENT_INSTRUCTIONS = `
375
+ ## Self-review before returning UI (anti-slop)
376
+
377
+ Before you output any generated UI code, silently re-read it and fix anything that matches the slop checklist \u2014 this is the same rubric an automated linter applies, so output that fails it will be rejected and sent back:
378
+
379
+ - **No hardcoded colors.** Every color is a semantic token (\`text-primary\`, \`bg-muted\`, \`border-border\`, \`text-muted-foreground\`, \`bg-destructive\`, \u2026). No \`text-blue-600\`, no \`#hex\`, no \`oklch(...)\`, no \`style={{ color }}\`.
380
+ - **No decorative icons.** An icon must mark an action, nav target, or status. Remove icons that sit beside a label that already says the thing. Aim for very few icons per view.
381
+ - **Muted, sparse trends.** No colored up/down pill on every metric. Show a trend only when the change is the point.
382
+ - **Normal-weight values.** Metric numbers use \`font-normal\`, never \`font-bold\` at large sizes.
383
+ - **No card-in-card, no per-row dividers, no gradients on data surfaces.** Group with spacing/Sections; reserve gradients for chrome.
384
+ - **Compose from blocks.** Prefer \`MetricRow\` / \`MetricChartCard\` / \`DataTable\` / \`IntegrationCard\` over hand-assembled primitives.
385
+
386
+ If a check fails, fix it and re-read once more. Only return code that would pass clean.
387
+ `.trim();
388
+
389
+ // src/cli/ui-lint.ts
390
+ function parseArgs(argv) {
391
+ const parsed = { strict: false, json: false, files: [] };
392
+ for (let i = 0; i < argv.length; i++) {
393
+ const arg = argv[i];
394
+ switch (arg) {
395
+ case "--strict":
396
+ parsed.strict = true;
397
+ break;
398
+ case "--json":
399
+ parsed.json = true;
400
+ break;
401
+ case "--max-icons":
402
+ parsed.maxIcons = Number(argv[++i]);
403
+ break;
404
+ case "--max-row-dividers":
405
+ parsed.maxRowDividers = Number(argv[++i]);
406
+ break;
407
+ case "-h":
408
+ case "--help":
409
+ printUsage();
410
+ process.exit(0);
411
+ break;
412
+ default:
413
+ if (arg.startsWith("-")) {
414
+ process.stderr.write(`timbal-ui-lint: unknown flag ${arg}
415
+ `);
416
+ process.exit(2);
417
+ }
418
+ parsed.files.push(arg);
419
+ }
420
+ }
421
+ return parsed;
422
+ }
423
+ function printUsage() {
424
+ process.stdout.write(
425
+ "Usage: timbal-ui-lint [--strict] [--max-icons N] [--max-row-dividers N] [--json] <file.tsx> [...]\n"
426
+ );
427
+ }
428
+ function main() {
429
+ const args = parseArgs(process.argv.slice(2));
430
+ if (args.files.length === 0) {
431
+ printUsage();
432
+ process.exit(2);
433
+ }
434
+ const reviewOptions = {
435
+ strict: args.strict,
436
+ maxIconsPerView: Number.isFinite(args.maxIcons) ? args.maxIcons : void 0,
437
+ maxRowDividers: Number.isFinite(args.maxRowDividers) ? args.maxRowDividers : void 0
438
+ };
439
+ const results = [];
440
+ for (const file of args.files) {
441
+ let source;
442
+ try {
443
+ source = readFileSync(file, "utf8");
444
+ } catch (e) {
445
+ results.push({
446
+ file,
447
+ passed: true,
448
+ report: "",
449
+ findings: [],
450
+ revisionPrompt: null,
451
+ error: e instanceof Error ? e.message : String(e)
452
+ });
453
+ continue;
454
+ }
455
+ const review = reviewGeneratedUi(source, reviewOptions);
456
+ results.push({
457
+ file,
458
+ passed: review.passed,
459
+ report: review.report,
460
+ findings: review.lint.findings,
461
+ revisionPrompt: review.revisionPrompt
462
+ });
463
+ }
464
+ const failed = results.filter((r) => !r.passed);
465
+ const ok = failed.length === 0;
466
+ if (args.json) {
467
+ process.stdout.write(
468
+ JSON.stringify(
469
+ {
470
+ ok,
471
+ files: results.map(({ revisionPrompt, ...rest }) => rest),
472
+ revisionPrompt: ok ? null : buildCombinedPrompt(failed)
473
+ },
474
+ null,
475
+ 2
476
+ ) + "\n"
477
+ );
478
+ process.exit(ok ? 0 : 1);
479
+ }
480
+ if (ok) {
481
+ process.stdout.write("Anti-slop review passed.\n");
482
+ process.exit(0);
483
+ }
484
+ for (const r of failed) {
485
+ process.stdout.write(`
486
+ === ${r.file} ===
487
+ ${r.report}
488
+ `);
489
+ }
490
+ process.stdout.write(`
491
+ ${buildCombinedPrompt(failed)}
492
+ `);
493
+ process.exit(1);
494
+ }
495
+ function buildCombinedPrompt(failed) {
496
+ const header = "The generated UI failed the Timbal anti-slop review. Fix every issue below, then re-check. Do not change anything that already passed.";
497
+ const perFile = failed.filter((r) => r.report).map((r) => `
498
+ --- ${r.file} ---
499
+ ${r.report}`).join("\n");
500
+ const rules = "Rules: colors come only from semantic tokens (text-primary, bg-muted, border-border, text-muted-foreground, \u2026) \u2014 never palette colors, hex, or oklch. Icons mark actions/nav/status, not decoration. Metric values use font-normal. No card-in-card, no per-row dividers, no gradients on data surfaces. Compose from kit blocks (MetricRow, DataTable, AlertCard) instead of hand-rolled primitives.";
501
+ return [header, perFile, "", rules].join("\n");
502
+ }
503
+ main();