-
Notifications
You must be signed in to change notification settings - Fork 9.7k
Expand file tree
/
Copy pathlint-artifact.ts
More file actions
999 lines (949 loc) Β· 45.3 KB
/
Copy pathlint-artifact.ts
File metadata and controls
999 lines (949 loc) Β· 45.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
/**
* Anti-slop linter for generated HTML artifacts.
*
* Runs grep-style checks against an artifact body and returns a list of
* structured findings. P0 findings indicate the artifact is regressing
* to AI-slop tropes (purple gradients, emoji feature icons, sans-serif
* display, invented metrics, lorem-style filler) and are surfaced back
* to the agent as a system message so it can self-correct on the next
* turn. P1/P2 findings are advisories.
*
* The linter is deliberately greppy: cheap, deterministic, and trivial
* to extend. It does NOT parse HTML β false positives are tolerable
* because each finding includes a snippet so the agent can verify.
*
* Wired into the artifact save flow (POST /api/artifacts/save) and
* exposed standalone at POST /api/artifacts/lint for the chat UI to
* surface badges next to each saved artifact.
*/
type LintSeverity = 'P0' | 'P1' | 'P2';
export type LintFinding = {
severity: LintSeverity;
id: string;
message: string;
fix: string;
snippet?: string;
};
type CssDeclaration = { prop: string; value: string };
type CssTokenScope = {
selectors: string[];
tokens: Map<string, string>;
isDefault: boolean;
themeKeys: Set<string>;
};
const PURPLE_HEXES = [
// Tailwind violet / purple β the original AI-slop palette.
'#a855f7', '#9333ea', '#7c3aed', '#6d28d9', '#581c87',
'#8b5cf6', '#a78bfa', '#c4b5fd', '#ddd6fe', '#ede9fe',
// Tailwind indigo β Refero's #1 reported AI tell. Common solid uses
// (button fill, accent badge), not just gradients, are flagged
// separately by `ai-default-indigo` below.
'#6366f1', '#4f46e5', '#4338ca', '#3730a3', '#312e81',
'#818cf8', '#a5b4fc', '#c7d2fe', '#e0e7ff', '#eef2ff',
];
// Blue / cyan stops used in the documented "blueβcyan two-stop trust
// gradient" cardinal sin. The purple-gradient rule above only catches
// gradients that contain a violet/indigo hex or the literal
// `purple`/`violet` keyword, so an artifact emitting
// `linear-gradient(90deg, #3b82f6, #06b6d4)` (or the keyword form
// `linear-gradient(90deg, blue, cyan)`) slipped past P0 even though
// `craft/anti-ai-slop.md` explicitly flags it. The `trust-gradient`
// rule below pairs these against each other to close the gap.
const TRUST_GRADIENT_BLUE_HEXES = [
// Tailwind blue 500β900 + 400/300/200.
'#3b82f6', '#2563eb', '#1d4ed8', '#1e40af', '#1e3a8a',
'#60a5fa', '#93c5fd', '#bfdbfe',
// Tailwind sky 400β700 β the same blueβcyan ramp under a different name.
'#0ea5e9', '#0284c7', '#0369a1', '#38bdf8', '#7dd3fc',
];
const TRUST_GRADIENT_CYAN_HEXES = [
// Tailwind cyan 500β900 + 400/300/200.
'#06b6d4', '#0891b2', '#0e7490', '#155e75', '#164e63',
'#22d3ee', '#67e8f9', '#a5f3fc',
];
// Subset of PURPLE_HEXES that constitute the canonical "default LLM
// accent" β even a single solid use is a tell. The DESIGN.md provides
// `var(--accent)`; if a brief truly needs indigo, the design system
// should encode it explicitly so we know it's intentional.
//
// Keep this in sync with the explicit list in `craft/anti-ai-slop.md`'s
// "Default Tailwind indigo as accent" cardinal-sin entry β the prompt
// contract documents the exact set the lint enforces.
const AI_DEFAULT_INDIGO = [
'#6366f1', '#4f46e5', '#4338ca', '#3730a3',
'#8b5cf6', '#7c3aed', '#a855f7',
];
const SLOP_EMOJI = [
'β¨', 'π', 'π―', 'β‘', 'π₯', 'π‘', 'π', 'π¨', 'π‘οΈ', 'π',
'πͺ', 'π', 'π', 'π', 'β
', 'β', 'π',
];
// Simple sentinel words for invented-metric copy. Catching every claim is
// hopeless; we look for the canonical AI-startup phrasings.
const INVENTED_METRIC_PATTERNS = [
/\b10Γ\s+(faster|better|easier)\b/i,
/\b100Γ\s+(faster|better)\b/i,
/\b99\.\d+%\s+uptime\b/i,
/\bzero[- ]downtime\b/i,
/\b3Γ\s+more\s+(productive|efficient)\b/i,
];
const FILLER_PATTERNS = [
/\bfeature\s+(one|two|three|1|2|3)\b/i,
/\blorem\s+ipsum\b/i,
/\bdolor\s+sit\s+amet\b/i,
/\bplaceholder\s+text\b/i,
/\bsample\s+content\b/i,
];
// Display-face check: an h1 / h2 / h3 element whose `font-family` lands on
// Inter / Roboto / Arial / -apple-system without an actual serif before it.
// We check the `<style>` block specifically; inline styles are checked too.
const DISPLAY_SANS_RE =
/(?:h1|h2|h3|\.h-?(?:hero|xl|lg|md))[^{}]*\{[^}]*font-family\s*:\s*["']?(?:Inter|Roboto|Arial|-apple-system|system-ui|SF\s+Pro)/i;
/**
* Run all checks against an HTML artifact body. Returns an array of
* findings. The checks are intentionally independent so adding a new
* one only means appending to this function.
*
* @param {string} html
* @returns {LintFinding[]}
*/
export function lintArtifact(rawHtml: unknown): LintFinding[] {
const out: LintFinding[] = [];
if (typeof rawHtml !== 'string' || rawHtml.length === 0) return out;
// Strip HTML comments before any pattern matching β comments often contain
// pedagogical examples ("paste a `<section class="slide">` here") that
// would otherwise fire false positives for the section / slide checks.
const html = rawHtml.replace(/<!--[\s\S]*?-->/g, '');
// ββ P0-1: purple gradient backgrounds βββββββββββββββββββββββββββββ
for (const hex of PURPLE_HEXES) {
const re = new RegExp(
`linear-gradient\\([^)]*${escapeRe(hex)}[^)]*\\)`,
'i',
);
const m = re.exec(html);
if (m) {
out.push({
severity: 'P0',
id: 'purple-gradient',
message: `Found a violet/purple gradient using ${hex} β anti-slop list says no.`,
fix: 'Replace the gradient with a flat surface (var(--bg) or var(--surface)) or use the active accent at a single intensity, not in a gradient.',
snippet: clip(m[0]),
});
break;
}
}
// Also catch the literal "purple"/"violet" keyword in a linear-gradient.
if (out.find((f) => f.id === 'purple-gradient') === undefined) {
const m = /linear-gradient\([^)]*\b(purple|violet)\b[^)]*\)/i.exec(html);
if (m) {
out.push({
severity: 'P0',
id: 'purple-gradient',
message: `Found a "${m[1]}" keyword inside a gradient β anti-slop.`,
fix: 'Remove the gradient or swap to a single solid color from the active design tokens.',
snippet: clip(m[0]),
});
}
}
// ββ P0-1c: blueβcyan "trust" two-stop gradient βββββββββββββββββββββ
// craft/anti-ai-slop.md documents three flavours of the two-stop
// "trust" gradient β purpleβblue, blueβcyan, indigoβpink. The first
// and third are caught by `purple-gradient` above because the
// relevant indigo/violet hex appears in PURPLE_HEXES, but a pure
// blueβcyan gradient has no overlap with that list and slipped
// past unflagged. Detect a `linear-gradient(...)` whose stop list
// contains both a blue token (hex or keyword) and a cyan token
// (hex or keyword). Skip if the purple-gradient rule already fired
// so we emit a single corrective signal per artifact.
if (out.find((f) => f.id === 'purple-gradient') === undefined) {
const tg = detectBlueCyanTrustGradient(html);
if (tg) {
out.push({
severity: 'P0',
id: 'trust-gradient',
message: `Found a blueβcyan two-stop "trust" gradient β anti-slop list says no.`,
fix: 'Replace the gradient with a flat surface (var(--bg) or var(--surface)) or use a single design-token color. Two-stop blueβcyan trust gradients are a SaaS hero clichΓ©.',
snippet: clip(tg),
});
}
}
// ββ P0-1b: solid AI-default indigo as accent ββββββββββββββββββββββ
// Even outside a gradient, a single use of #6366f1 et al. is the
// textbook LLM tell. We only fire if the existing purple-gradient
// check didn't already, since they overlap in spirit. Strip
// token-definition blocks first: a brief whose accent is
// intentionally indigo declares it as `--accent: #6366f1` inside
// a selector list containing `:root` (or another known global
// theme scope like `html` / bare `[data-theme="..."]`) and uses
// var(--accent) downstream. That is the design system speaking,
// not the model defaulting, and must not fire. Component-local
// variables (e.g. `.cta { --cta-bg: #6366f1; }`) stay in scope so
// the lint still catches indigo laundered through a local var.
if (out.find((f) => f.id === 'purple-gradient') === undefined) {
const htmlForIndigo = stripTokenBlocks(html);
for (const hex of AI_DEFAULT_INDIGO) {
const re = new RegExp(escapeRe(hex), 'i');
const m = re.exec(htmlForIndigo);
if (m) {
out.push({
severity: 'P0',
id: 'ai-default-indigo',
message: `Found a default LLM accent color (${hex}) β this is the most-reported AI design tell.`,
fix: 'Replace with var(--accent) from the active DESIGN.md. If the brief truly requires indigo, encode it as the design system\'s accent so it reads as intentional, not default.',
snippet: clip(m[0]),
});
break;
}
}
}
// ββ P0-2: emoji used as feature/UI icons ββββββββββββββββββββββββββ
for (const e of SLOP_EMOJI) {
if (html.includes(e)) {
// Only flag if it appears in a structural context β heading,
// button, list item β not in body prose.
const re = new RegExp(
`<(?:h[1-6]|button|li|span class="[^"]*icon[^"]*")[^>]*>[^<]*${escapeRe(e)}`,
'i',
);
const m = re.exec(html);
if (m) {
out.push({
severity: 'P0',
id: 'emoji-icon',
message: `Emoji "${e}" used as a UI icon β anti-slop list says SVG monoline only.`,
fix: 'Replace with a small inline SVG icon (1.6β1.8px stroke, currentColor) or remove the icon entirely.',
snippet: clip(m[0]),
});
break;
}
}
}
// ββ P0-3: rounded card with left-border accent ββββββββββββββββββββ
const leftAccentRe =
/\.[a-z-]+\s*\{[^}]*border-left\s*:\s*\d+px\s+solid\s+[^;]+;[^}]*border-radius\s*:\s*[1-9]/i;
const lam = leftAccentRe.exec(html);
if (lam) {
out.push({
severity: 'P0',
id: 'left-accent-card',
message: 'Rounded card with a coloured left border β the canonical AI-slop card pattern.',
fix: 'Drop either the border-radius (set 0px) or the border-left. Cards in the OD seed use hairline borders all-round, no left accent.',
snippet: clip(lam[0]),
});
}
// ββ P0-4: sans-serif display face βββββββββββββββββββββββββββββββββ
// Skill seeds bind --font-display to a serif. Catch the case where a
// generated artifact reverts this on h1/h2/h3 to system-sans.
const dm = DISPLAY_SANS_RE.exec(html);
if (dm) {
out.push({
severity: 'P0',
id: 'sans-display',
message: 'A heading rule uses Inter / Roboto / system-sans as the display face β not the serif the seed binds.',
fix: 'Use `font-family: var(--font-display)` on h1/h2/h3 and let the active design system pick the serif. Override only if the active direction is "tech / utility" or "modern minimal".',
snippet: clip(dm[0]),
});
}
// ββ P0-5: invented metric phrasing ββββββββββββββββββββββββββββββββ
for (const re of INVENTED_METRIC_PATTERNS) {
const m = re.exec(html);
if (m) {
out.push({
severity: 'P0',
id: 'invented-metric',
message: `Suspected invented metric: "${m[0]}". Anti-slop list says: no numbers without a real source.`,
fix: 'Either remove the claim or replace with a placeholder (β or a labelled stub) until the user supplies a real number.',
snippet: clip(m[0]),
});
break;
}
}
// ββ P0-6: filler / lorem text βββββββββββββββββββββββββββββββββββββ
for (const re of FILLER_PATTERNS) {
const m = re.exec(html);
if (m) {
out.push({
severity: 'P0',
id: 'filler-copy',
message: `Filler copy detected: "${m[0]}". Pages should ship with real, brief-derived copy.`,
fix: 'Replace with copy specific to the brief or delete the section entirely. An empty section is a design problem to solve with composition, not by inventing words.',
snippet: clip(m[0]),
});
break;
}
}
// ββ P0-7: scrollIntoView (breaks iframe preview) ββββββββββββββββββ
if (/\.scrollIntoView\s*\(/.test(html)) {
out.push({
severity: 'P0',
id: 'scroll-into-view',
message: 'Element.scrollIntoView() detected β yanks the host page when an iframe boundary is crossed.',
fix: 'Use `scrollTo({ left, top, behavior: "smooth" })` on the actual scroller (see simple-deck seed for the proven pattern).',
});
}
// ββ P1-0: ALL-CAPS without letter-spacing βββββββββββββββββββββββββ
// Refero's typography rules: any `text-transform: uppercase` rule
// must pair with `letter-spacing: >= 0.06em` (or an absolute px
// equivalent). Iterate every <style> block (artifacts often emit
// a reset block followed by a tokens/components block) and scan
// each CSS body for an uppercase declaration whose selector body
// is missing letter-spacing or sets it visibly too low.
// Token-aware tracking: collect per-scope `--name: value` declarations
// from global theme scopes once, then pass them to the tracking helper
// so a rule like `letter-spacing: var(--caps-tracking)` is judged by
// the token's literal value in every applicable theme instead of being
// treated as missing.
const tokenScopes = extractCssTokens(html);
outer: for (const styleBlock of html.matchAll(
/<style[^>]*>([\s\S]*?)<\/style>/gi,
)) {
// Strip CSS comments before structural matching: a `<style>` body
// such as `/* .eyebrow { text-transform: uppercase; } */` is
// commented-out by the browser but the rule-shaped regex below
// would otherwise match it and emit a P1 finding for CSS that has
// no rendered effect.
const css = (styleBlock[1] ?? '').replace(/\/\*[\s\S]*?\*\//g, '');
// Match a CSS rule body containing text-transform: uppercase.
// Capture the selector + body so we can inspect tracking. The body
// alternation is `[^{}]*` (not `[^}]*`) so the regex matches only
// innermost `selector { body }` rules. With `[^}]*`, an outer
// `@media (...) { .display { font-size: 48px; text-transform:
// uppercase; β¦ } }` matches as a single rule whose selector is the
// `@media (...)` wrapper and whose body begins with `.display {
// font-size: β¦` β so `parseDeclarations()` sees the first property
// as `.display { font-size`, not `font-size`, the same-rule
// font-size is lost, and `hasAdequateUppercaseTracking()` falls
// back to the lenient inherited-size path that accepts 1px
// tracking on a 48px heading. Restricting the body to `[^{}]*`
// makes the regex skip the wrapper and match the inner rule
// directly.
const upperRe = /([^{}]*)\{([^{}]*text-transform\s*:\s*uppercase[^{}]*)\}/gi;
let m;
while ((m = upperRe.exec(css)) !== null) {
const selector = (m[1] ?? '').trim();
const body = m[2] ?? '';
if (!hasAdequateUppercaseTracking(body, tokenScopes)) {
out.push({
severity: 'P1',
id: 'all-caps-no-tracking',
message: `Selector \`${selector.slice(0, 60)}\` sets text-transform: uppercase without sufficient letter-spacing (β₯0.06em).`,
fix: 'Add `letter-spacing: 0.08em` (typical) to the same rule. ALL CAPS without tracking looks cramped β Refero\'s typography rules call this out as a top-tier amateur tell.',
snippet: clip(`${selector} { ${body.trim()} }`),
});
break outer;
}
}
}
// ββ P1-0b: ALL-CAPS in inline style attributes ββββββββββββββββββββ
// The <style>-block scan above misses inline declarations such as
// `<span style="text-transform: uppercase">NEW</span>`, which the
// browser still renders ALL CAPS. craft/typography.md treats the
// tracking floor as having no exceptions, so the inline form runs
// through the same `hasAdequateUppercaseTracking` check used by the
// <style>-block branch β no separate threshold. Only fire if the
// <style>-block scan above didn't already produce this id, so the
// agent gets a single corrective signal per artifact.
if (out.find((f) => f.id === 'all-caps-no-tracking') === undefined) {
const inlineStyleRe = /(?:^|\s)style\s*=\s*(["'])([\s\S]*?)\1/gi;
let im;
while ((im = inlineStyleRe.exec(html)) !== null) {
const decl = im[2] ?? '';
if (!/text-transform\s*:\s*uppercase/i.test(decl)) continue;
if (!hasAdequateUppercaseTracking(decl, tokenScopes)) {
out.push({
severity: 'P1',
id: 'all-caps-no-tracking',
message:
'Inline style sets text-transform: uppercase without sufficient letter-spacing (β₯0.06em).',
fix: 'Add `letter-spacing: 0.08em` (typical) to the same inline style. ALL CAPS without tracking looks cramped β Refero\'s typography rules call this out as a top-tier amateur tell.',
snippet: clip(decl.trim()),
});
break;
}
}
}
// ββ P1-1: external image URLs (CDN / unsplash / placehold.co) βββββ
// Allow data: urls and same-origin paths.
const extImg =
/<img[^>]+src=["']https?:\/\/(?:images\.unsplash\.com|placehold\.co|placekitten\.com|via\.placeholder\.com|picsum\.photos|loremflickr\.com)/i.exec(
html,
);
if (extImg) {
out.push({
severity: 'P1',
id: 'external-image',
message: 'External placeholder image CDN detected β fragile, looks fake when it 404s.',
fix: 'Use the .ph-img placeholder class shipped in the seed templates instead.',
snippet: clip(extImg[0]),
});
}
// ββ P1-2: raw hex outside :root βββββββββββββββββββββββββββββββββββ
// Heuristic: count `#xxxxxx` occurrences inside the first <style> block,
// outside the `:root{...}` declaration. Many is suspicious.
const styleRe = /<style[^>]*>([\s\S]*?)<\/style>/i;
const styleMatch = styleRe.exec(html);
if (styleMatch) {
const css = styleMatch[1] ?? '';
const rootRe = /:root\s*\{[^}]*\}/g;
const cssWithoutRoot = css.replace(rootRe, '');
const hexes = cssWithoutRoot.match(/#[0-9a-fA-F]{3,8}\b/g) ?? [];
// Allow up to ~12 raw hex values outside :root. Device chrome
// (mobile-app frame: bezel gradient, side rails, status icons) has
// legitimate hardware-specific values in the 8β10 range; raise the
// threshold so seed templates pass without ceremony. More than ~12
// signals tokens weren't honoured by the agent's generation.
if (hexes.length > 12) {
out.push({
severity: 'P1',
id: 'raw-hex',
message: `${hexes.length} raw hex values found outside :root β design tokens probably not honoured.`,
fix: 'Move every color into the :root token block (--bg / --surface / --fg / --muted / --border / --accent) and reference via var(). Use color-mix() for derived tones.',
snippet: hexes.slice(0, 6).join(' '),
});
}
}
// ββ P1-3: too many accent uses in the rendered body βββββββββββββββ
// Approximation: count `var(--accent)` references that appear OUTSIDE
// the <style> block β i.e. inline styles in the rendered DOM, not the
// class system definitions. The seed's <style> block defines the
// accent on many class selectors that won't all render on one page;
// the body is what the user actually sees.
const styleStripped = html.replace(/<style[\s\S]*?<\/style>/gi, '');
const accentUsesInBody = (styleStripped.match(/var\(--accent\)/g) ?? []).length;
if (accentUsesInBody > 6) {
out.push({
severity: 'P1',
id: 'accent-overuse',
message: `var(--accent) used ${accentUsesInBody} times inline in the body β likely overused per screen.`,
fix: 'Cap accent usage at 2 visible uses per screen (one eyebrow + one CTA, OR one accent card + one tab). Demote the rest to var(--fg) or var(--muted).',
});
}
// ββ P2-1: missing comment-mode anchor on <section> ββββββββββββββββ
// Either `data-od-id` (web/mobile prototypes) or `data-screen-label`
// (decks) counts. Whichever the artifact uses, every <section> should
// carry one so the chat layer can target it.
const sections = html.match(/<section\b[^>]*>/gi) ?? [];
const tagged = sections.filter(
(s) => /data-od-id\s*=/.test(s) || /data-screen-label\s*=/.test(s),
).length;
if (sections.length > 0 && tagged < sections.length) {
out.push({
severity: 'P2',
id: 'missing-section-anchor',
message: `${sections.length - tagged} of ${sections.length} <section>s lack data-od-id (or data-screen-label).`,
fix: 'Add data-od-id="kebab-slug" (or data-screen-label="01 Cover" for slides) to every top-level <section> so comment mode can target it.',
});
}
// ββ P2-2: missing slide theme classes (deck specifically) ββββββββββ
// Triggered only if the artifact looks deck-shaped (has .slide).
if (/class\s*=\s*["'][^"']*\bslide\b/.test(html)) {
const slideMatches = html.match(/<section\s+class\s*=\s*["'][^"']*\bslide\b[^"']*["']/gi) ?? [];
const themed = slideMatches.filter((s) =>
/\b(light|dark|hero\s+light|hero\s+dark)\b/.test(s),
).length;
if (slideMatches.length > 0 && themed < slideMatches.length) {
out.push({
severity: 'P0',
id: 'slide-theme-missing',
message: `${slideMatches.length - themed} of ${slideMatches.length} slides lack a theme class (light / dark / hero light / hero dark).`,
fix: 'Every <section class="slide"> must include exactly one theme class. Audit your slide list and add light/dark/hero modifiers.',
});
}
// Theme rhythm: no 3+ same-theme in a row.
const themeSeq = slideMatches
.map((s) => {
if (/hero\s+dark/.test(s)) return 'HD';
if (/hero\s+light/.test(s)) return 'HL';
if (/\bdark\b/.test(s)) return 'D';
if (/\blight\b/.test(s)) return 'L';
return '?';
})
.filter((t) => t !== '?');
for (let i = 0; i < themeSeq.length - 2; i++) {
const a = themeSeq[i];
const isLight = (t: string | undefined) => t === 'L' || t === 'HL';
const isDark = (t: string | undefined) => t === 'D' || t === 'HD';
if (
(isLight(a) && isLight(themeSeq[i + 1]) && isLight(themeSeq[i + 2])) ||
(isDark(a) && isDark(themeSeq[i + 1]) && isDark(themeSeq[i + 2]))
) {
out.push({
severity: 'P1',
id: 'slide-rhythm',
message: `Three same-theme slides in a row at position ${i + 1}β${i + 3} β visual fatigue.`,
fix: 'Swap the middle slide to the opposite theme (light β dark, or dark β light). For 8+ slides, mix in at least one hero light AND one hero dark.',
});
break;
}
}
}
return out;
}
/**
* Format findings as a Markdown block ready to splice into a system
* reminder back to the agent. P0 findings appear first.
*
* @param {LintFinding[]} findings
* @returns {string}
*/
export function renderFindingsForAgent(findings: LintFinding[]): string {
if (findings.length === 0) return '';
const sorted = [...findings].sort((a, b) => severity(a) - severity(b));
const lines = [
'<artifact-lint>',
'The artifact you just produced has the following anti-slop / design-token issues.',
`${findings.filter((f) => f.severity === 'P0').length} P0 (must fix), ${findings.filter((f) => f.severity === 'P1').length} P1 (should fix), ${findings.filter((f) => f.severity === 'P2').length} P2 (nice to have).`,
'Re-emit a corrected `<artifact>` in your next turn β do not write a separate explanation; the user has the previous version already.',
'',
];
for (const f of sorted) {
lines.push(`**[${f.severity}] ${f.id}** β ${f.message}`);
lines.push(` Fix: ${f.fix}`);
if (f.snippet) lines.push(` Snippet: \`${f.snippet}\``);
lines.push('');
}
lines.push('</artifact-lint>');
return lines.join('\n');
}
function severity(f: LintFinding): number {
return f.severity === 'P0' ? 0 : f.severity === 'P1' ? 1 : 2;
}
function clip(s: string): string {
if (!s) return '';
const trimmed = s.replace(/\s+/g, ' ').trim();
return trimmed.length > 200 ? trimmed.slice(0, 197) + 'β¦' : trimmed;
}
function escapeRe(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
// Scan every `linear-gradient(...)` body for a blueβcyan two-stop
// trust gradient. Returns the first matching gradient text or `null`.
// The check accepts either Tailwind blue/sky/cyan hex stops or the
// literal `blue`/`cyan` keywords, so both
// `linear-gradient(90deg, #3b82f6, #06b6d4)` and
// `linear-gradient(90deg, blue, cyan)` fire P0.
function detectBlueCyanTrustGradient(html: string): string | null {
const re = /linear-gradient\([^)]*\)/gi;
let m;
while ((m = re.exec(html)) !== null) {
const grad = m[0].toLowerCase();
const hasBlue =
TRUST_GRADIENT_BLUE_HEXES.some((h) => grad.includes(h.toLowerCase())) ||
/\bblue\b/.test(grad);
const hasCyan =
TRUST_GRADIENT_CYAN_HEXES.some((h) => grad.includes(h.toLowerCase())) ||
/\bcyan\b/.test(grad);
if (hasBlue && hasCyan) return m[0];
}
return null;
}
// True when the declaration body has letter-spacing satisfying the
// craft rule: `letter-spacing >= 0.06em` of the element's own font.
//
// `em` maps directly to the 0.06 floor β it is relative to the
// element's own font-size, which is what the rule measures against.
//
// `rem` and `px` are absolute relative to the element: `rem` resolves
// against the root font-size (assumed 16px β the browser default and
// the value all OD seed templates use), so `0.06rem` on a 48px heading
// is `0.96px`, only `0.02em` of the element. Treating `rem` like `em`
// (the previous behaviour) accepts that as compliant when the rule
// it enforces is the per-element em floor; convert `rem` to absolute
// px and reuse the same px-vs-element-font-size resolution.
//
// px (and the converted-rem path) resolve in three steps:
// 1. If the same rule body declares `font-size` in `px` or `rem`
// (after `var()` resolution), convert it to absolute px and
// compare px tracking against `fs * 0.06` β exact translation
// of the em rule. `rem` font sizes resolve via the same root
// assumption used for tracking, so a `font-size: 3rem` heading
// enforces a 2.88px floor instead of the lenient body fallback.
// 2. If the rule explicitly declares a `font-size` in a unit we
// can't resolve (`em`, `%`, `calc(...)`, an unresolved var,
// etc.), refuse the lenient fallback: the heading might be
// arbitrarily large, in which case 1px tracking is well below
// 0.06em. Treat as missing tracking β the agent can either
// switch to `em` letter-spacing or declare an explicit px/rem
// font-size we can verify.
// 3. Otherwise (no font-size declared at all, font-size inherited),
// use a conservative `>= 1px` absolute fallback. That stays
// correct for the typical body-text default of 16px (1px / 16px
// β 0.0625em, just over the floor) and for any smaller label
// (1px / 14px β 0.071em, 1px / 12px β 0.083em).
//
// `scopes` (optional) is the array of per-scope token records
// harvested from global theme scopes elsewhere in the artifact (see
// `extractCssTokens`). Each record carries the scope's per-scope
// last-write-wins token map plus enough metadata to identify which
// themes the scope applies to. Per-theme effective maps are built
// here via `buildResolvedThemes` so simple `var(--name)` (and
// `var(--name, fallback)`) references in the body resolve to the
// value the browser would render in that theme β keeping values
// declared in the same scope paired together. References without a
// matching token but with an inline fallback (`var(--x, 0.08em)`)
// resolve to the fallback; unresolved references with no fallback
// stay in place so the existing "no numeric value" path returns
// false.
//
// When a token resolves to different values in different themes
// (e.g. `:root { --caps-tracking: 0.02em }` overridden by
// `[data-theme="dark"] { --caps-tracking: 0.08em }`), the helper is
// conservative: it walks every per-theme map produced by
// `buildResolvedThemes` and returns true only if EVERY theme satisfies
// the 0.06em floor. A theme-scoped override that lifts the value
// above the floor must not silently rescue a default value that
// renders below it. Crucially, theme maps preserve the scope-internal
// relationship between tokens, so a paired declaration such as
// `:root { --display-size: 16px; --caps-tracking: 1px }` is judged
// against (16px, 1px) β never against the impossible cross-theme
// pairing (48px, 1px) that an independent per-token cartesian would
// emit.
const ROOT_FONT_PX = 16;
function hasAdequateUppercaseTracking(body: string, scopes?: CssTokenScope[]): boolean {
const themes = buildResolvedThemes(scopes ?? []);
for (const themeMap of themes) {
const resolved = resolveCssVars(body, themeMap);
if (!isResolvedTrackingAdequate(resolved)) return false;
}
return true;
}
// Single-resolution tracking check. Parses the declaration list with
// exact property names (so token-name declarations such as
// `--letter-spacing: 0.08em` cannot satisfy the rule) and selects the
// LAST matching `letter-spacing` and `font-size` declarations to model
// CSS source-order cascade β `.eyebrow { letter-spacing: 0.08em;
// letter-spacing: 0.02em }` renders the noncompliant `0.02em` value,
// so the lint must judge against the last declaration, not the first.
function isResolvedTrackingAdequate(body: string): boolean {
const decls = parseDeclarations(body);
const ls = findLastDecl(decls, 'letter-spacing');
if (!ls) return false;
const lsMatch = /^(-?\d*\.?\d+)\s*(em|px|rem)\b/i.exec(ls.value);
if (!lsMatch) return false;
const valueText = lsMatch[1];
const unitText = lsMatch[2];
if (valueText == null || unitText == null) return false;
const v = parseFloat(valueText);
const unit = unitText.toLowerCase();
if (unit === 'em') return v >= 0.06;
const trackingPx = unit === 'rem' ? v * ROOT_FONT_PX : v;
const fsPx = resolveFontSizePx(decls);
if (fsPx != null) {
return fsPx > 0 && trackingPx >= fsPx * 0.06;
}
if (decls.some((d) => d.prop === 'font-size')) return false;
return trackingPx >= 1;
}
// Build per-theme effective token maps from the per-scope records
// produced by `extractCssTokens`. A "theme" is the default rendering
// (no theme attribute set) plus one entry per distinct theme-attribute
// selector seen across scopes. Default-applying scopes (whose selector
// list contains a bare `:root` / `html` / `body`) apply to every theme
// as a baseline; variant scopes apply only to the themes their
// selector targets. Within a single theme, scopes are applied in
// source order so the final value reflects the cascade the browser
// would render.
//
// Returned as an array β one map per theme. The lint passes only when
// every theme map satisfies the rule, so a default-theme value below
// the floor flags even if a variant overrides it above the floor (and
// vice versa). Building per-theme maps preserves the scope-internal
// relationship between tokens, so values declared together in the
// same scope (e.g. `--display-size` and `--caps-tracking` both on
// `:root`) stay paired during evaluation. The previous design merged
// values by token name across scopes and then took an independent
// per-token cartesian product, which generated impossible cross-theme
// pairings such as `(default-size, dark-track)` and emitted false
// positives on legitimate light/dark theme variants.
function buildResolvedThemes(scopes: CssTokenScope[]): Map<string, string>[] {
const themeKeys = new Set(['default']);
for (const scope of scopes) {
for (const k of scope.themeKeys) themeKeys.add(k);
}
const themes = new Map<string, Map<string, string>>();
for (const k of themeKeys) themes.set(k, new Map());
for (const scope of scopes) {
if (scope.isDefault) {
for (const map of themes.values()) {
for (const [k, v] of scope.tokens) map.set(k, v);
}
} else {
for (const themeKey of scope.themeKeys) {
const map = themes.get(themeKey);
if (map) {
for (const [k, v] of scope.tokens) map.set(k, v);
}
}
}
}
return Array.from(themes.values());
}
function isBareGlobalSelector(s: string): boolean {
return /^(?::root|html|body)$/.test(s);
}
function findLastDecl(decls: CssDeclaration[], prop: string): CssDeclaration | undefined {
for (let i = decls.length - 1; i >= 0; i--) {
const decl = decls[i];
if (decl && decl.prop === prop) return decl;
}
return undefined;
}
// Split a CSS declaration body into `{ prop, value }` entries, lowercasing
// the property name and skipping custom properties (`--name`). Used by
// the uppercase-tracking lint so substring matches on `letter-spacing`
// or `font-size` cannot collide with token-name declarations.
function parseDeclarations(body: string): CssDeclaration[] {
const out: CssDeclaration[] = [];
for (const raw of body.split(';')) {
const idx = raw.indexOf(':');
if (idx < 0) continue;
const prop = raw.slice(0, idx).trim().toLowerCase();
if (!prop || prop.startsWith('--')) continue;
const value = raw.slice(idx + 1).trim();
if (!value) continue;
out.push({ prop, value });
}
return out;
}
// Resolve a same-rule `font-size` declaration to absolute px. Returns
// the px value when font-size is declared in `px` or `rem` (rem maps
// via the root font-size assumption shared with tracking); returns
// `null` when font-size is absent OR present in an unresolvable unit
// (`em`, `%`, `calc(...)`, an unresolved `var(--...)`). The caller
// distinguishes those two `null` cases by re-checking the parsed
// declarations for an exact `font-size` property.
//
// Selects the LAST `font-size` declaration in source order so that a
// rule like `.display { font-size: 48px; font-size: 1em }` is judged
// against the noncompliant `1em` the browser actually renders, not the
// stale earlier `48px`. CSS cascade is last-write-wins on conflicting
// declarations within a single rule body.
function resolveFontSizePx(decls: CssDeclaration[]): number | null {
const fs = findLastDecl(decls, 'font-size');
if (!fs) return null;
const m = /^(-?\d*\.?\d+)\s*(px|rem)\b/i.exec(fs.value);
if (!m) return null;
const valueText = m[1];
const unitText = m[2];
if (valueText == null || unitText == null) return null;
const v = parseFloat(valueText);
const unit = unitText.toLowerCase();
return unit === 'rem' ? v * ROOT_FONT_PX : v;
}
// Collect CSS custom properties (`--name: value`) declared in global
// theme scopes (`:root`, `html`, theme-attribute selectors) from every
// `<style>` block in the artifact. Tokens declared on component
// selectors are intentionally ignored: the lint must still catch
// indigo / under-tracking laundered through a local var, and the
// tracking helper resolves only the global-scope tokens artifacts use
// to express design intent.
//
// Returns an array of per-scope records:
// `{ selectors, tokens, isDefault, themeKeys }`
// where `tokens` is the per-scope last-write-wins map of CSS custom
// properties, `selectors` lists the parsed selectors from the rule,
// `isDefault` is true if any selector is a bare global
// (`:root` / `html` / `body` without an attribute suffix), and
// `themeKeys` is the set of theme-attribute selector strings the rule
// targets. Per-theme effective maps are derived downstream from these
// records by `buildResolvedThemes`, which preserves the scope-internal
// relationship between values so a paired declaration like
// `:root { --display-size: 16px; --caps-tracking: 1px }` is judged
// as `(16px, 1px)` together, not against the impossible cross-theme
// pairing `(48px, 1px)` that an independent per-token cartesian over
// distinct values would emit.
//
// Within a single rule body, CSS cascade is last-write-wins: a block
// like `:root { --caps-tracking: 0.02em; --caps-tracking: 0.08em; }`
// renders the second value, and the first never reaches any element.
// Per-scope, we keep only the LAST value declared for each token
// name; cross-scope merging happens later in `buildResolvedThemes`,
// where the same source-order cascade is applied between scopes that
// target the same theme.
function extractCssTokens(html: string): CssTokenScope[] {
const scopes: CssTokenScope[] = [];
for (const styleBlock of html.matchAll(/<style[^>]*>([\s\S]*?)<\/style>/gi)) {
const css = (styleBlock[1] ?? '').replace(/\/\*[\s\S]*?\*\//g, '');
const ruleRe = /([^{}]*)\{([^{}]*)\}/g;
let m;
while ((m = ruleRe.exec(css)) !== null) {
const sel = (m[1] ?? '').trim();
if (!selectorListIsGlobalThemeScope(sel)) continue;
const selectors = sel.split(',').map((s) => s.trim()).filter(Boolean);
const isDefault = selectors.some(isBareGlobalSelector);
const themeKeys = new Set(
selectors.filter((s) => !isBareGlobalSelector(s)),
);
const body = m[2] ?? '';
const tokens = new Map();
for (const decl of body.split(';').map((d) => d.trim()).filter(Boolean)) {
const dm = /^(--[\w-]+)\s*:\s*(.+)$/.exec(decl);
if (dm) {
const tokenName = dm[1];
const tokenValue = dm[2];
if (tokenName != null && tokenValue != null) tokens.set(tokenName, tokenValue.trim());
}
}
if (tokens.size === 0) continue;
scopes.push({ selectors, tokens, isDefault, themeKeys });
}
}
return scopes;
}
// Replace simple `var(--name)` (and `var(--name, fallback)`) references
// in a CSS declaration body with the literal token value. Iterates a
// few times so a token whose value is itself another `var(--...)`
// resolves through one or two hops; bounded depth so a cyclic
// definition (`--a: var(--b); --b: var(--a)`) terminates instead of
// looping forever. Only one-level fallbacks are recognised β enough
// for the typography pattern this lint cares about, and keeps the
// regex linear-time on artifact-sized inputs.
const VAR_RESOLVE_MAX_DEPTH = 4;
function resolveCssVars(body: string, tokens: Map<string, string>): string {
let out = body;
for (let i = 0; i < VAR_RESOLVE_MAX_DEPTH; i++) {
const next = out.replace(
/var\(\s*(--[\w-]+)\s*(?:,\s*([^()]*))?\)/g,
(full: string, name: string, fallback: string | undefined) => {
const v = tokens.get(name);
if (v != null) return v;
if (fallback != null) return fallback.trim();
return full;
},
);
if (next === out) break;
out = next;
}
return out;
}
// Remove CSS rule blocks that look like design-token definitions.
// Operates only on CSS extracted from <style> blocks β running the
// rule-shaped regex against the full HTML string makes the first
// selector capture include leading text like `<style>`, which then
// fails the `:root` selector test.
//
// A rule is treated as a token block only when ALL THREE conditions hold:
// 1. every selector in the list is a global theme-scope selector
// (`:root`, `:root[data-theme="..."]`, `html`, `body`, or a bare
// attribute selector for a known global-theme switch β
// `data-theme`, `data-color-scheme`, `data-mode`). Selector lists
// that mix in a component selector β e.g.
// `:root, .cta { --cta-bg: #6366f1 }` β or that target an
// arbitrary component/state attribute like `[data-variant="primary"]`
// or `[aria-current="page"]` fail this test, so indigo laundered
// through a local var or rule still trips the lint.
// 2. its body is token-shaped: only CSS custom properties
// (`--name: value`), with a small allowlist for global-theme
// metadata such as `color-scheme` that legitimately accompanies
// tokens in `:root` and cannot smuggle a visible color.
// A non-token declaration on `:root` (e.g.
// `:root { background: #6366f1 }`) keeps the rule in scope so
// the indigo check fires.
// 3. no token in the body launders an indigo hex through a
// non-`--accent` name. The craft contract's escape hatch is to
// encode indigo as the active design system's `--accent` token;
// anything else (`:root { --primary: #6366f1 }`,
// `:root { --button-bg: #4f46e5 }`) is still the LLM-default
// color hidden behind an arbitrary token name and must stay in
// scope of the indigo scan.
function stripTokenBlocks(input: string): string {
return input.replace(
/(<style[^>]*>)([\s\S]*?)(<\/style>)/gi,
(_m: string, open: string, css: string, close: string) => `${open}${stripTokenBlocksFromCss(css)}${close}`,
);
}
function stripTokenBlocksFromCss(css: string): string {
// Strip CSS comments before any structural matching: a block like
// `:root { /* brand accent */ --accent: #6366f1; }` would otherwise
// produce a declaration fragment that begins with the comment,
// fail `isTokenShapedDeclaration`, and leave a legitimate token
// definition in scope of the indigo scan.
const cleaned = css.replace(/\/\*[\s\S]*?\*\//g, '');
// The body alternation is `[^{}]*` (not `[^}]*`) so the regex matches
// only innermost `selector { body }` rules. That lets us recognize
// global token blocks nested inside at-rule wrappers β e.g.
// `@media (prefers-color-scheme: dark) { :root { --accent: #6366f1 } }`
// β by matching the inner `:root { ... }` directly. The outer
// `@media` wrapper is preserved with the inner token block stripped,
// so the indigo scan no longer fires on legitimate responsive theme
// declarations.
return cleaned.replace(/([^{}]*)\{([^{}]*)\}/g, (full: string, selector: string, body: string) => {
const sel = (selector || '').trim();
if (!selectorListIsGlobalThemeScope(sel)) return full;
const decls = (body || '')
.split(';')
.map((d: string) => d.trim())
.filter(Boolean);
if (decls.length === 0) return full;
const tokenShaped = decls.every(isTokenShapedDeclaration);
if (!tokenShaped) return full;
// The `--accent` escape hatch is for `--accent` only. Any other
// global token whose value carries an AI-default indigo hex is
// still laundering the LLM-default color through an arbitrary
// name (`--primary: #6366f1`, `--button-bg: #4f46e5`, β¦). Keep
// the rule in scope so the indigo lint fires on the literal hex.
if (decls.some(declarationLaundersIndigo)) return full;
return '';
});
}
function declarationLaundersIndigo(decl: string): boolean {
const m = /^(--[\w-]+)\s*:\s*(.+)$/.exec(decl);
if (!m) return false;
const tokenName = m[1];
const tokenValue = m[2];
if (tokenName == null || tokenValue == null) return false;
if (tokenName.toLowerCase() === '--accent') return false;
const value = tokenValue.toLowerCase();
for (const hex of AI_DEFAULT_INDIGO) {
if (value.includes(hex.toLowerCase())) return true;
}
return false;
}
function isTokenShapedDeclaration(decl: string): boolean {
// CSS custom property β the canonical token shape.
if (/^--[\w-]+\s*:/.test(decl)) return true;
// Global-theme metadata that legitimately accompanies tokens in
// `:root` / `html` / `[data-theme="..."]` and whose values are
// keywords, so they cannot smuggle a hardcoded color.
if (/^color-scheme\s*:/i.test(decl)) return true;
return false;
}
function selectorListIsGlobalThemeScope(selector: string): boolean {
const parts = selector.split(',').map((s: string) => s.trim()).filter(Boolean);
if (parts.length === 0) return false;
return parts.every(isGlobalThemeScopeSelector);
}
// Attribute selectors β bare or attached to `:root`/`html`/`body` β
// are exempted only when the attribute is one of the known
// global-theme switches. A broader exemption would also strip
// arbitrary component/state attribute rules
// (e.g. `[data-variant="primary"] { --button-bg: #6366f1; }`,
// `:root[data-variant="primary"] { --button-bg: #6366f1; }`, or
// `html[aria-current="page"] { --nav-accent: #6366f1; }`), which
// is the exact component-local indigo laundering this lint is
// meant to catch.
const GLOBAL_THEME_ATTRIBUTES = new Set([
'data-theme',
'data-color-scheme',
'data-mode',
]);
function isGlobalThemeScopeSelector(s: string): boolean {
// :root / html / body, optionally suffixed with a single attribute
// selector. The bare form (no attribute) is always a global theme
// scope; the prefixed form is only a theme scope when the attribute
// names one of GLOBAL_THEME_ATTRIBUTES. A component/state attribute
// suffix (`:root[data-variant="primary"]`, `html[aria-current="page"]`)
// must keep the rule in scope of the indigo lint.
const tagAttr = /^(?::root|html|body)(?:\[([a-zA-Z-]+)(?:[*^$|~]?=[^\]]*)?\])?$/.exec(s);
if (tagAttr) {
const attrName = tagAttr[1];
if (!attrName) return true;
return GLOBAL_THEME_ATTRIBUTES.has(attrName.toLowerCase());
}
// Bare attribute selector restricted to known global-theme switches.
const bareAttr = /^\[([a-zA-Z-]+)(?:[*^$|~]?=[^\]]*)?\]$/.exec(s);
const bareAttrName = bareAttr?.[1];
if (bareAttrName && GLOBAL_THEME_ATTRIBUTES.has(bareAttrName.toLowerCase())) {
return true;
}
return false;
}