@timbal-ai/timbal-react 1.6.0 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +39 -0
- package/README.md +8 -0
- package/dist/app.cjs +167 -75
- package/dist/app.d.cts +3 -3
- package/dist/app.d.ts +3 -3
- package/dist/app.esm.js +5 -5
- package/dist/{chart-artifact-VAqgH-My.d.cts → chart-artifact-CuTiCITz.d.cts} +230 -15
- package/dist/{chart-artifact-C2pZQsaP.d.ts → chart-artifact-U-x0UNJm.d.ts} +230 -15
- package/dist/chat.esm.js +3 -3
- package/dist/{chunk-YYEI6XME.esm.js → chunk-22KWC2LS.esm.js} +5 -5
- package/dist/{chunk-MBS7XHV2.esm.js → chunk-45NXD3IG.esm.js} +3 -3
- package/dist/{chunk-24B4I4XC.esm.js → chunk-64RHAJVG.esm.js} +1 -1
- package/dist/{chunk-WQIQW7EM.esm.js → chunk-7AGIAQE6.esm.js} +1 -1
- package/dist/{chunk-NO5AWNWT.esm.js → chunk-7WU3IKAN.esm.js} +1 -1
- package/dist/{chunk-6SQMTBPL.esm.js → chunk-M5IBJBEY.esm.js} +109 -23
- package/dist/{chunk-HSL36SJ4.esm.js → chunk-PMMI7LBV.esm.js} +20 -8
- package/dist/{chunk-TMP7RIA7.esm.js → chunk-VKXOHVDE.esm.js} +2 -2
- package/dist/{chunk-ELEY66OH.esm.js → chunk-XOCOZU7J.esm.js} +11 -1
- package/dist/cli/timbal-ui-lint.mjs +534 -0
- package/dist/index.cjs +303 -200
- package/dist/index.d.cts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.esm.js +9 -9
- package/dist/{kanban-FFBeaZPS.d.cts → kanban-BQxWliCS.d.cts} +17 -0
- package/dist/{kanban-FFBeaZPS.d.ts → kanban-BQxWliCS.d.ts} +17 -0
- package/dist/studio.cjs +104 -85
- package/dist/studio.esm.js +6 -6
- package/dist/ui.cjs +6 -6
- package/dist/ui.d.cts +1 -1
- package/dist/ui.d.ts +1 -1
- package/dist/ui.esm.js +4 -4
- package/package.json +13 -3
|
@@ -0,0 +1,534 @@
|
|
|
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 COLOR_FN_WRAPPING_VAR_RE = /\b(?:hsl|hsla|rgb|rgba|oklch|oklab|lab|lch|hwb|color)\s*\(\s*var\(\s*--/i;
|
|
90
|
+
var INLINE_STYLE_COLOR_RE = /style=\{\{[^}]*\b(?:color|background|backgroundColor|borderColor|fill|stroke)\b/;
|
|
91
|
+
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)/;
|
|
92
|
+
var GRADIENT_RE = /\bbg-(?:gradient|linear|radial|conic)-/;
|
|
93
|
+
var GRADIENT_DIRECTIONS = /* @__PURE__ */ new Set([
|
|
94
|
+
"t",
|
|
95
|
+
"tr",
|
|
96
|
+
"r",
|
|
97
|
+
"br",
|
|
98
|
+
"b",
|
|
99
|
+
"bl",
|
|
100
|
+
"l",
|
|
101
|
+
"tl"
|
|
102
|
+
]);
|
|
103
|
+
var ICON_IMPORT_RE = /from\s+["']lucide-react["']/;
|
|
104
|
+
var RAW_CONTROL_SURFACE_RE = /\bborder-input\b/;
|
|
105
|
+
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/;
|
|
106
|
+
var TREND_CONTEXT_RE = /\b(?:trend|delta|TrendingUp|TrendingDown|ArrowUp|ArrowDown|ArrowUpRight|ArrowDownRight|MoveUp|MoveDown)\b|[+\-]\d+(?:\.\d+)?\s*%/;
|
|
107
|
+
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/;
|
|
108
|
+
var RESERVED_GRADIENT_SET = new Set(RESERVED_GRADIENT_TOKENS);
|
|
109
|
+
function stripVariants(util) {
|
|
110
|
+
return util.replace(/^(?:[a-z-]+:)*/, "");
|
|
111
|
+
}
|
|
112
|
+
function describeArg(value) {
|
|
113
|
+
if (value === null) return "null";
|
|
114
|
+
if (Array.isArray(value)) return "an array";
|
|
115
|
+
const t = typeof value;
|
|
116
|
+
if (t === "object") {
|
|
117
|
+
const keys = Object.keys(value).slice(0, 4);
|
|
118
|
+
return keys.length ? `an object with keys { ${keys.join(", ")} }` : "an object";
|
|
119
|
+
}
|
|
120
|
+
return `a ${t}`;
|
|
121
|
+
}
|
|
122
|
+
function isCommentOrImport(line) {
|
|
123
|
+
const t = line.trim();
|
|
124
|
+
return t.startsWith("//") || t.startsWith("*") || t.startsWith("/*") || t.startsWith("import ") || t.startsWith("export ");
|
|
125
|
+
}
|
|
126
|
+
function lintGeneratedUi(source, options = {}) {
|
|
127
|
+
if (typeof source !== "string") {
|
|
128
|
+
throw new TypeError(
|
|
129
|
+
`lintGeneratedUi(source, options?) expects the generated code as a string, but received ${describeArg(source)}. Pass the raw .tsx source \u2014 lintGeneratedUi(code) \u2014 not an object like { filename, source } and not a previous LintResult.`
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
const maxIcons = options.maxIconsPerView ?? SLOP_BUDGETS.maxIconsPerView;
|
|
133
|
+
const maxRowDividers = options.maxRowDividers ?? SLOP_BUDGETS.maxRowDividers;
|
|
134
|
+
const findings = [];
|
|
135
|
+
const lines = source.split("\n");
|
|
136
|
+
let usesLucide = false;
|
|
137
|
+
let iconUsageCount = 0;
|
|
138
|
+
let dividerRunCount = 0;
|
|
139
|
+
let pageTitle = null;
|
|
140
|
+
const pageTitleMatch = source.match(/<Page\s+[^>]*\btitle=(?:"([^"]+)"|\{["']([^"']+)["']\})/);
|
|
141
|
+
if (pageTitleMatch) {
|
|
142
|
+
pageTitle = (pageTitleMatch[1] || pageTitleMatch[2]).trim().toLowerCase();
|
|
143
|
+
}
|
|
144
|
+
const hasChat = /\b(?:TimbalChat|AppChatPanel|Thread)\b/.test(source);
|
|
145
|
+
const lucideNames = /* @__PURE__ */ new Set();
|
|
146
|
+
const openCards = [];
|
|
147
|
+
for (let i = 0; i < lines.length; i++) {
|
|
148
|
+
const line = lines[i];
|
|
149
|
+
const lineNo = i + 1;
|
|
150
|
+
if (ICON_IMPORT_RE.test(line)) {
|
|
151
|
+
usesLucide = true;
|
|
152
|
+
const named = line.match(/\{([^}]*)\}/);
|
|
153
|
+
if (named) {
|
|
154
|
+
for (const raw of named[1].split(",")) {
|
|
155
|
+
const name = raw.trim().split(/\s+as\s+/)[0].trim();
|
|
156
|
+
if (name) lucideNames.add(name);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
if (isCommentOrImport(line)) continue;
|
|
162
|
+
const cardMatch = line.match(/<(Card|SurfaceCard|ArtifactCard)\b/);
|
|
163
|
+
if (cardMatch) {
|
|
164
|
+
const isSelfClosing = /\/>/.test(line) && line.indexOf(cardMatch[0]) < line.indexOf("/>");
|
|
165
|
+
if (!isSelfClosing) {
|
|
166
|
+
if (openCards.length > 0) {
|
|
167
|
+
const parentCard = openCards[openCards.length - 1];
|
|
168
|
+
findings.push({
|
|
169
|
+
rule: "no-card-in-card",
|
|
170
|
+
severity: "warn",
|
|
171
|
+
line: lineNo,
|
|
172
|
+
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.`,
|
|
173
|
+
snippet: line.trim().slice(0, 120)
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
openCards.push({ type: cardMatch[1], line: lineNo });
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
const closeMatch = line.match(/<\/(Card|SurfaceCard|ArtifactCard)\b/);
|
|
180
|
+
if (closeMatch && openCards.length > 0) {
|
|
181
|
+
const idx = openCards.map((c) => c.type).lastIndexOf(closeMatch[1]);
|
|
182
|
+
if (idx !== -1) {
|
|
183
|
+
openCards.splice(idx, 1);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
if (openCards.length > 0) {
|
|
187
|
+
const tableMatch = line.match(/<(DataTable|table|Table)\b/);
|
|
188
|
+
if (tableMatch) {
|
|
189
|
+
const parentCard = openCards[openCards.length - 1];
|
|
190
|
+
findings.push({
|
|
191
|
+
rule: "no-table-in-card",
|
|
192
|
+
severity: "error",
|
|
193
|
+
line: lineNo,
|
|
194
|
+
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.`,
|
|
195
|
+
snippet: line.trim().slice(0, 120)
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
const rawColors = line.match(RAW_COLOR_RE);
|
|
200
|
+
if (rawColors) {
|
|
201
|
+
for (const m of rawColors) {
|
|
202
|
+
findings.push({
|
|
203
|
+
rule: "raw-color",
|
|
204
|
+
severity: "error",
|
|
205
|
+
line: lineNo,
|
|
206
|
+
message: "Hardcoded palette color. Use a semantic token (text-primary, bg-muted, border-border, text-muted-foreground, \u2026) so dark mode and rebranding work.",
|
|
207
|
+
snippet: m.trim().replace(/^["'`:\s]+/, "")
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
const wrapsTokenInColorFn = COLOR_FN_WRAPPING_VAR_RE.test(line);
|
|
212
|
+
if (wrapsTokenInColorFn) {
|
|
213
|
+
findings.push({
|
|
214
|
+
rule: "chart-token-color-fn",
|
|
215
|
+
severity: "error",
|
|
216
|
+
line: lineNo,
|
|
217
|
+
message: "Color function wrapping a token (e.g. hsl(var(--chart-1))). The --chart-N and theme tokens are already OKLCH colors \u2014 wrapping them in hsl()/rgb() is invalid CSS and renders an empty/uncolored chart (the build still passes). Pass the token directly: var(--chart-1), or let the app-kit charts use --chart-N automatically.",
|
|
218
|
+
snippet: line.trim().slice(0, 120)
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
const literals = wrapsTokenInColorFn ? null : line.match(COLOR_LITERAL_RE);
|
|
222
|
+
if (literals) {
|
|
223
|
+
findings.push({
|
|
224
|
+
rule: "color-literal",
|
|
225
|
+
severity: "error",
|
|
226
|
+
line: lineNo,
|
|
227
|
+
message: "Hardcoded color literal. Colors must come from the theme generator (createTimbalTheme) and semantic tokens \u2014 never inline hex/oklch/rgb.",
|
|
228
|
+
snippet: line.trim().slice(0, 120)
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
if (INLINE_STYLE_COLOR_RE.test(line)) {
|
|
232
|
+
findings.push({
|
|
233
|
+
rule: "inline-style-color",
|
|
234
|
+
severity: "error",
|
|
235
|
+
line: lineNo,
|
|
236
|
+
message: "Inline style color. Move color to a semantic Tailwind token on className.",
|
|
237
|
+
snippet: line.trim().slice(0, 120)
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
if (RAW_CONTROL_SURFACE_RE.test(line)) {
|
|
241
|
+
findings.push({
|
|
242
|
+
rule: "raw-control-surface",
|
|
243
|
+
severity: "warn",
|
|
244
|
+
line: lineNo,
|
|
245
|
+
message: "Hand-rolled control surface (border-input). Use a kit control \u2014 SearchInput, Select, DropdownMenu, FieldInput, FieldSelect \u2014 so it matches every other control.",
|
|
246
|
+
snippet: line.trim().slice(0, 120)
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
if (COLORED_HOVER_RE.test(line)) {
|
|
250
|
+
findings.push({
|
|
251
|
+
rule: "no-colored-hover",
|
|
252
|
+
severity: "warn",
|
|
253
|
+
line: lineNo,
|
|
254
|
+
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.",
|
|
255
|
+
snippet: line.trim().slice(0, 120)
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
if (TREND_CONTEXT_RE.test(line) && TREND_COLOR_RE.test(line)) {
|
|
259
|
+
findings.push({
|
|
260
|
+
rule: "neutral-trend",
|
|
261
|
+
severity: "warn",
|
|
262
|
+
line: lineNo,
|
|
263
|
+
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).",
|
|
264
|
+
snippet: line.trim().slice(0, 120)
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
if (BOLD_VALUE_RE.test(line)) {
|
|
268
|
+
findings.push({
|
|
269
|
+
rule: "bold-metric",
|
|
270
|
+
severity: "warn",
|
|
271
|
+
line: lineNo,
|
|
272
|
+
message: "Bold large value. House style: metric values use font-normal, not bold \u2014 bold giant numbers read as a template.",
|
|
273
|
+
snippet: line.trim().slice(0, 120)
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
if (GRADIENT_RE.test(line)) {
|
|
277
|
+
const fromTo = line.match(
|
|
278
|
+
new RegExp(`(?:from|via|to)-([a-z-]+)`, "g")
|
|
279
|
+
);
|
|
280
|
+
const colorStops = (fromTo ?? []).map((u) => stripVariants(u).replace(/^(?:from|via|to)-/, "")).filter((token) => !GRADIENT_DIRECTIONS.has(token));
|
|
281
|
+
const allReserved = colorStops.length > 0 && colorStops.every((token) => RESERVED_GRADIENT_SET.has(token));
|
|
282
|
+
if (!allReserved) {
|
|
283
|
+
findings.push({
|
|
284
|
+
rule: "data-gradient",
|
|
285
|
+
severity: "warn",
|
|
286
|
+
line: lineNo,
|
|
287
|
+
message: "Gradient outside chrome. Gradients are reserved for buttons / elevated / modal / playground \u2014 never a data card, tile, or table.",
|
|
288
|
+
snippet: line.trim().slice(0, 120)
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
if (/\b(?:border-t|border-b|divide-y)\b/.test(line)) {
|
|
293
|
+
dividerRunCount++;
|
|
294
|
+
if (dividerRunCount === maxRowDividers + 1) {
|
|
295
|
+
findings.push({
|
|
296
|
+
rule: "row-divider",
|
|
297
|
+
severity: "warn",
|
|
298
|
+
line: lineNo,
|
|
299
|
+
message: "Divider on every row. Prefer spacing (gap-*) or zebra striping over a rule under each list item.",
|
|
300
|
+
snippet: line.trim().slice(0, 120)
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
} else if (line.trim() !== "" && !line.includes("className")) {
|
|
304
|
+
if (!/^\s*[)>}/]/.test(line)) dividerRunCount = 0;
|
|
305
|
+
}
|
|
306
|
+
if (usesLucide && lucideNames.size > 0) {
|
|
307
|
+
for (const name of lucideNames) {
|
|
308
|
+
const usage = new RegExp(`<${name}\\b`, "g");
|
|
309
|
+
const hits = line.match(usage);
|
|
310
|
+
if (hits) iconUsageCount += hits.length;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
if (pageTitle) {
|
|
314
|
+
const titleMatch = line.match(/<(Section|ChartPanel|Card|DataTable|SurfaceCard)\s+[^>]*\btitle=(?:"([^"]+)"|\{["']([^"']+)["']\})/);
|
|
315
|
+
if (titleMatch) {
|
|
316
|
+
const element = titleMatch[1];
|
|
317
|
+
const titleVal = (titleMatch[2] || titleMatch[3]).trim().toLowerCase();
|
|
318
|
+
if (titleVal === pageTitle || titleVal.includes(pageTitle) || pageTitle.includes(titleVal)) {
|
|
319
|
+
findings.push({
|
|
320
|
+
rule: "no-title-repetition",
|
|
321
|
+
severity: "warn",
|
|
322
|
+
line: lineNo,
|
|
323
|
+
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.`,
|
|
324
|
+
snippet: line.trim().slice(0, 120)
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
if (hasChat) {
|
|
330
|
+
const wrappingMatch = line.match(/<(Card|Section|SurfaceCard|FormSection|SettingsSection)\b/);
|
|
331
|
+
if (wrappingMatch) {
|
|
332
|
+
findings.push({
|
|
333
|
+
rule: "no-chat-wrapping",
|
|
334
|
+
severity: "error",
|
|
335
|
+
line: lineNo,
|
|
336
|
+
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.`,
|
|
337
|
+
snippet: line.trim().slice(0, 120)
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
const headingMatch = line.match(/<(h[1-6])\b/);
|
|
341
|
+
if (headingMatch) {
|
|
342
|
+
findings.push({
|
|
343
|
+
rule: "no-chat-wrapping",
|
|
344
|
+
severity: "error",
|
|
345
|
+
line: lineNo,
|
|
346
|
+
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.`,
|
|
347
|
+
snippet: line.trim().slice(0, 120)
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
if (usesLucide && iconUsageCount > maxIcons) {
|
|
353
|
+
findings.push({
|
|
354
|
+
rule: "icon-spam",
|
|
355
|
+
severity: "warn",
|
|
356
|
+
line: 1,
|
|
357
|
+
message: `Too many icons (${iconUsageCount} > ${maxIcons}). Icons should mark actions/nav/status \u2014 not decorate every label, tile, and card.`,
|
|
358
|
+
snippet: `${iconUsageCount} lucide-react icon usages`
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
const effectiveErrors = findings.filter(
|
|
362
|
+
(f) => f.severity === "error" || options.strict && f.severity === "warn"
|
|
363
|
+
).length;
|
|
364
|
+
return {
|
|
365
|
+
findings,
|
|
366
|
+
errorCount: findings.filter((f) => f.severity === "error").length,
|
|
367
|
+
warnCount: findings.filter((f) => f.severity === "warn").length,
|
|
368
|
+
ok: effectiveErrors === 0
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
function formatLintReport(findings) {
|
|
372
|
+
if (!Array.isArray(findings)) {
|
|
373
|
+
throw new TypeError(
|
|
374
|
+
`formatLintReport(findings) expects the findings array, but received ${describeArg(findings)}. Pass result.findings \u2014 formatLintReport(lintGeneratedUi(code).findings) \u2014 not the whole LintResult.`
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
if (findings.length === 0) return "";
|
|
378
|
+
const lines = findings.slice().sort((a, b) => a.line - b.line).map((f) => {
|
|
379
|
+
const tag = f.severity === "error" ? "ERROR" : "warn ";
|
|
380
|
+
return ` ${tag} L${f.line} [${f.rule}] ${f.message}
|
|
381
|
+
\u2192 ${f.snippet}`;
|
|
382
|
+
});
|
|
383
|
+
const errs = findings.filter((f) => f.severity === "error").length;
|
|
384
|
+
const warns = findings.filter((f) => f.severity === "warn").length;
|
|
385
|
+
return `Anti-slop review: ${errs} error(s), ${warns} warning(s)
|
|
386
|
+
${lines.join("\n")}`;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// src/design/ui-review.ts
|
|
390
|
+
function reviewGeneratedUi(source, options = {}) {
|
|
391
|
+
const lint = lintGeneratedUi(source, options);
|
|
392
|
+
const report = formatLintReport(lint.findings);
|
|
393
|
+
if (lint.ok) {
|
|
394
|
+
return { lint, passed: true, report, revisionPrompt: null };
|
|
395
|
+
}
|
|
396
|
+
const revisionPrompt = [
|
|
397
|
+
"The generated UI failed the Timbal anti-slop review. Fix every issue below, then return the corrected code only.",
|
|
398
|
+
"",
|
|
399
|
+
report,
|
|
400
|
+
"",
|
|
401
|
+
"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."
|
|
402
|
+
].join("\n");
|
|
403
|
+
return { lint, passed: false, report, revisionPrompt };
|
|
404
|
+
}
|
|
405
|
+
var UI_REVIEW_AGENT_INSTRUCTIONS = `
|
|
406
|
+
## Self-review before returning UI (anti-slop)
|
|
407
|
+
|
|
408
|
+
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:
|
|
409
|
+
|
|
410
|
+
- **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 }}\`.
|
|
411
|
+
- **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.
|
|
412
|
+
- **Muted, sparse trends.** No colored up/down pill on every metric. Show a trend only when the change is the point.
|
|
413
|
+
- **Normal-weight values.** Metric numbers use \`font-normal\`, never \`font-bold\` at large sizes.
|
|
414
|
+
- **No card-in-card, no per-row dividers, no gradients on data surfaces.** Group with spacing/Sections; reserve gradients for chrome.
|
|
415
|
+
- **Compose from blocks.** Prefer \`MetricRow\` / \`MetricChartCard\` / \`DataTable\` / \`IntegrationCard\` over hand-assembled primitives.
|
|
416
|
+
|
|
417
|
+
If a check fails, fix it and re-read once more. Only return code that would pass clean.
|
|
418
|
+
`.trim();
|
|
419
|
+
|
|
420
|
+
// src/cli/ui-lint.ts
|
|
421
|
+
function parseArgs(argv) {
|
|
422
|
+
const parsed = { strict: false, json: false, files: [] };
|
|
423
|
+
for (let i = 0; i < argv.length; i++) {
|
|
424
|
+
const arg = argv[i];
|
|
425
|
+
switch (arg) {
|
|
426
|
+
case "--strict":
|
|
427
|
+
parsed.strict = true;
|
|
428
|
+
break;
|
|
429
|
+
case "--json":
|
|
430
|
+
parsed.json = true;
|
|
431
|
+
break;
|
|
432
|
+
case "--max-icons":
|
|
433
|
+
parsed.maxIcons = Number(argv[++i]);
|
|
434
|
+
break;
|
|
435
|
+
case "--max-row-dividers":
|
|
436
|
+
parsed.maxRowDividers = Number(argv[++i]);
|
|
437
|
+
break;
|
|
438
|
+
case "-h":
|
|
439
|
+
case "--help":
|
|
440
|
+
printUsage();
|
|
441
|
+
process.exit(0);
|
|
442
|
+
break;
|
|
443
|
+
default:
|
|
444
|
+
if (arg.startsWith("-")) {
|
|
445
|
+
process.stderr.write(`timbal-ui-lint: unknown flag ${arg}
|
|
446
|
+
`);
|
|
447
|
+
process.exit(2);
|
|
448
|
+
}
|
|
449
|
+
parsed.files.push(arg);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
return parsed;
|
|
453
|
+
}
|
|
454
|
+
function printUsage() {
|
|
455
|
+
process.stdout.write(
|
|
456
|
+
"Usage: timbal-ui-lint [--strict] [--max-icons N] [--max-row-dividers N] [--json] <file.tsx> [...]\n"
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
function main() {
|
|
460
|
+
const args = parseArgs(process.argv.slice(2));
|
|
461
|
+
if (args.files.length === 0) {
|
|
462
|
+
printUsage();
|
|
463
|
+
process.exit(2);
|
|
464
|
+
}
|
|
465
|
+
const reviewOptions = {
|
|
466
|
+
strict: args.strict,
|
|
467
|
+
maxIconsPerView: Number.isFinite(args.maxIcons) ? args.maxIcons : void 0,
|
|
468
|
+
maxRowDividers: Number.isFinite(args.maxRowDividers) ? args.maxRowDividers : void 0
|
|
469
|
+
};
|
|
470
|
+
const results = [];
|
|
471
|
+
for (const file of args.files) {
|
|
472
|
+
let source;
|
|
473
|
+
try {
|
|
474
|
+
source = readFileSync(file, "utf8");
|
|
475
|
+
} catch (e) {
|
|
476
|
+
results.push({
|
|
477
|
+
file,
|
|
478
|
+
passed: true,
|
|
479
|
+
report: "",
|
|
480
|
+
findings: [],
|
|
481
|
+
revisionPrompt: null,
|
|
482
|
+
error: e instanceof Error ? e.message : String(e)
|
|
483
|
+
});
|
|
484
|
+
continue;
|
|
485
|
+
}
|
|
486
|
+
const review = reviewGeneratedUi(source, reviewOptions);
|
|
487
|
+
results.push({
|
|
488
|
+
file,
|
|
489
|
+
passed: review.passed,
|
|
490
|
+
report: review.report,
|
|
491
|
+
findings: review.lint.findings,
|
|
492
|
+
revisionPrompt: review.revisionPrompt
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
const failed = results.filter((r) => !r.passed);
|
|
496
|
+
const ok = failed.length === 0;
|
|
497
|
+
if (args.json) {
|
|
498
|
+
process.stdout.write(
|
|
499
|
+
JSON.stringify(
|
|
500
|
+
{
|
|
501
|
+
ok,
|
|
502
|
+
files: results.map(({ revisionPrompt, ...rest }) => rest),
|
|
503
|
+
revisionPrompt: ok ? null : buildCombinedPrompt(failed)
|
|
504
|
+
},
|
|
505
|
+
null,
|
|
506
|
+
2
|
|
507
|
+
) + "\n"
|
|
508
|
+
);
|
|
509
|
+
process.exit(ok ? 0 : 1);
|
|
510
|
+
}
|
|
511
|
+
if (ok) {
|
|
512
|
+
process.stdout.write("Anti-slop review passed.\n");
|
|
513
|
+
process.exit(0);
|
|
514
|
+
}
|
|
515
|
+
for (const r of failed) {
|
|
516
|
+
process.stdout.write(`
|
|
517
|
+
=== ${r.file} ===
|
|
518
|
+
${r.report}
|
|
519
|
+
`);
|
|
520
|
+
}
|
|
521
|
+
process.stdout.write(`
|
|
522
|
+
${buildCombinedPrompt(failed)}
|
|
523
|
+
`);
|
|
524
|
+
process.exit(1);
|
|
525
|
+
}
|
|
526
|
+
function buildCombinedPrompt(failed) {
|
|
527
|
+
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.";
|
|
528
|
+
const perFile = failed.filter((r) => r.report).map((r) => `
|
|
529
|
+
--- ${r.file} ---
|
|
530
|
+
${r.report}`).join("\n");
|
|
531
|
+
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.";
|
|
532
|
+
return [header, perFile, "", rules].join("\n");
|
|
533
|
+
}
|
|
534
|
+
main();
|