Skip to content

Commit 0b3c559

Browse files
committed
Merge branch 'dev'
2 parents 6e3a5b0 + a0a1ce3 commit 0b3c559

File tree

9 files changed

+188
-32
lines changed

9 files changed

+188
-32
lines changed
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@anthropic/helixir-core': minor
3+
---
4+
5+
Add :root scope token detection to shadow DOM checker and suggest-fix pipeline
6+
7+
Detects component CSS custom properties set on :root (which have no effect through Shadow DOM)
8+
and suggests moving them to the host element. Wired into both styling_preflight and
9+
css-file-validator inline fix generation.

packages/core/src/handlers/css-file-validator.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -307,7 +307,7 @@ function mapIssueToFixInput(
307307
case 'shadowDom':
308308
return {
309309
type: 'shadow-dom',
310-
issue: 'descendant-piercing',
310+
issue: issue.message.includes(':root') ? 'root-scope-token' : 'descendant-piercing',
311311
original,
312312
tagName,
313313
partNames,

packages/core/src/handlers/shadow-dom-checker.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -451,6 +451,49 @@ function checkMisspelledTokens(lines: string[], meta: ComponentMetadata): Shadow
451451
return issues;
452452
}
453453

454+
function checkRootScopeTokens(lines: string[], meta: ComponentMetadata): ShadowDomIssue[] {
455+
const issues: ShadowDomIssue[] = [];
456+
const validTokens = new Set(meta.cssProperties.map((p) => p.name));
457+
if (validTokens.size === 0) return issues;
458+
459+
// Find :root blocks and check for component tokens inside them
460+
const text = lines.join('\n');
461+
const rootBlockPattern = /:root\s*\{([^}]*)}/g;
462+
let blockMatch: RegExpExecArray | null;
463+
464+
while ((blockMatch = rootBlockPattern.exec(text)) !== null) {
465+
const blockContent = blockMatch[1] ?? '';
466+
const blockStartOffset = blockMatch.index;
467+
const linesBeforeBlock = text.slice(0, blockStartOffset).split('\n').length;
468+
469+
// Find each token declaration inside this :root block
470+
const tokenPattern = /(--[\w-]+)\s*:/g;
471+
let tokenMatch: RegExpExecArray | null;
472+
473+
while ((tokenMatch = tokenPattern.exec(blockContent)) !== null) {
474+
const tokenName = tokenMatch[1] ?? '';
475+
if (validTokens.has(tokenName)) {
476+
// Calculate line number: lines before block + lines within block content before this token
477+
const contentBefore = blockContent.slice(0, tokenMatch.index);
478+
const extraLines = (contentBefore.match(/\n/g) ?? []).length;
479+
const line = linesBeforeBlock + extraLines;
480+
481+
issues.push({
482+
line,
483+
column: 1,
484+
severity: 'error',
485+
rule: 'no-root-scope-token',
486+
message: `Component token "${tokenName}" set on :root has no effect through Shadow DOM.`,
487+
suggestion: `Set it on the host element instead: ${meta.tagName} { ${tokenName}: value; }`,
488+
code: `:root { ${tokenName}: ...; }`,
489+
});
490+
}
491+
}
492+
}
493+
494+
return issues;
495+
}
496+
454497
// ─── Helpers ─────────────────────────────────────────────────────────────────
455498

456499
function escapeRegex(str: string): string {
@@ -502,6 +545,7 @@ export function checkShadowDomUsage(
502545
if (meta) {
503546
issues.push(...checkUnknownParts(lines, meta));
504547
issues.push(...checkMisspelledTokens(lines, meta));
548+
issues.push(...checkRootScopeTokens(lines, meta));
505549
}
506550

507551
issues.sort((a, b) => a.line - b.line || a.column - b.column);

packages/core/src/handlers/styling-preflight.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,7 @@ function extractPropertyFromMessage(message: string): string | undefined {
323323
}
324324

325325
function detectShadowDomIssueType(message: string): string {
326+
if (message.includes(':root')) return 'root-scope-token';
326327
if (message.includes('descendant') || message.includes('child')) return 'descendant-piercing';
327328
if (message.includes('::part') && message.includes('class')) return 'part-structural';
328329
if (message.includes('/deep/') || message.includes('>>>')) return 'deprecated-deep';

packages/core/src/handlers/suggest-fix.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,22 @@ function fixShadowDom(input: SuggestFixInput): FixSuggestion {
157157
};
158158
}
159159

160+
if (input.issue === 'root-scope-token') {
161+
const tag = tagName ?? 'the-element';
162+
// Extract token name from the original CSS
163+
const tokenMatch = original.match(/(--[\w-]+)\s*:/);
164+
const token = tokenMatch?.[1] ?? '--component-token';
165+
const valueMatch = original.match(/:\s*([^;]+)/);
166+
const value = valueMatch?.[1]?.trim() ?? 'value';
167+
168+
return {
169+
original,
170+
suggestion: `${tag} { ${token}: ${value}; }`,
171+
explanation: `Component tokens set on :root have no effect through Shadow DOM boundaries. The token "${token}" must be set on the component's host element (<${tag}>) for the shadow root to inherit it.`,
172+
severity: 'error',
173+
};
174+
}
175+
160176
if (input.issue === 'external-host') {
161177
const tag = tagName ?? 'the-element';
162178
return {

tests/__fixtures__/benchmark-results/latest-benchmark.json

Lines changed: 31 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"timestamp": "2026-03-21T08:59:37.296Z",
2+
"timestamp": "2026-03-21T09:09:08.872Z",
33
"scorecards": {
44
"material": {
55
"library": "material",
@@ -187,7 +187,7 @@
187187
"heuristic": 426,
188188
"untested": 747
189189
},
190-
"timestamp": "2026-03-21T08:59:37.291Z"
190+
"timestamp": "2026-03-21T09:09:08.869Z"
191191
},
192192
"spectrum": {
193193
"library": "spectrum",
@@ -375,7 +375,7 @@
375375
"heuristic": 404,
376376
"untested": 601
377377
},
378-
"timestamp": "2026-03-21T08:59:37.292Z"
378+
"timestamp": "2026-03-21T09:09:08.869Z"
379379
},
380380
"vaadin": {
381381
"library": "vaadin",
@@ -563,7 +563,7 @@
563563
"heuristic": 439,
564564
"untested": 717
565565
},
566-
"timestamp": "2026-03-21T08:59:37.292Z"
566+
"timestamp": "2026-03-21T09:09:08.870Z"
567567
},
568568
"fluentui": {
569569
"library": "fluentui",
@@ -751,7 +751,7 @@
751751
"heuristic": 139,
752752
"untested": 293
753753
},
754-
"timestamp": "2026-03-21T08:59:37.292Z"
754+
"timestamp": "2026-03-21T09:09:08.870Z"
755755
},
756756
"carbon": {
757757
"library": "carbon",
@@ -939,7 +939,7 @@
939939
"heuristic": 397,
940940
"untested": 793
941941
},
942-
"timestamp": "2026-03-21T08:59:37.292Z"
942+
"timestamp": "2026-03-21T09:09:08.870Z"
943943
},
944944
"ui5": {
945945
"library": "ui5",
@@ -1127,7 +1127,7 @@
11271127
"heuristic": 582,
11281128
"untested": 1304
11291129
},
1130-
"timestamp": "2026-03-21T08:59:37.293Z"
1130+
"timestamp": "2026-03-21T09:09:08.870Z"
11311131
},
11321132
"calcite": {
11331133
"library": "calcite",
@@ -1315,7 +1315,7 @@
13151315
"heuristic": 318,
13161316
"untested": 848
13171317
},
1318-
"timestamp": "2026-03-21T08:59:37.294Z"
1318+
"timestamp": "2026-03-21T09:09:08.871Z"
13191319
},
13201320
"porsche": {
13211321
"library": "porsche",
@@ -1503,7 +1503,7 @@
15031503
"heuristic": 304,
15041504
"untested": 702
15051505
},
1506-
"timestamp": "2026-03-21T08:59:37.294Z"
1506+
"timestamp": "2026-03-21T09:09:08.871Z"
15071507
},
15081508
"ionic": {
15091509
"library": "ionic",
@@ -1691,7 +1691,7 @@
16911691
"heuristic": 331,
16921692
"untested": 744
16931693
},
1694-
"timestamp": "2026-03-21T08:59:37.294Z"
1694+
"timestamp": "2026-03-21T09:09:08.871Z"
16951695
},
16961696
"wired": {
16971697
"library": "wired",
@@ -1879,7 +1879,7 @@
18791879
"heuristic": 78,
18801880
"untested": 208
18811881
},
1882-
"timestamp": "2026-03-21T08:59:37.294Z"
1882+
"timestamp": "2026-03-21T09:09:08.871Z"
18831883
},
18841884
"elix": {
18851885
"library": "elix",
@@ -2067,7 +2067,7 @@
20672067
"heuristic": 148,
20682068
"untested": 740
20692069
},
2070-
"timestamp": "2026-03-21T08:59:37.295Z"
2070+
"timestamp": "2026-03-21T09:09:08.871Z"
20712071
},
20722072
"helix": {
20732073
"library": "helix",
@@ -2255,7 +2255,7 @@
22552255
"heuristic": 440,
22562256
"untested": 555
22572257
},
2258-
"timestamp": "2026-03-21T08:59:37.295Z"
2258+
"timestamp": "2026-03-21T09:09:08.872Z"
22592259
}
22602260
},
22612261
"comparisonTable": {
@@ -3278,74 +3278,74 @@
32783278
"API Surface Quality",
32793279
"Event Architecture"
32803280
],
3281-
"timestamp": "2026-03-21T08:59:37.296Z"
3281+
"timestamp": "2026-03-21T09:09:08.872Z"
32823282
},
32833283
"performance": {
3284-
"totalMs": 2876,
3284+
"totalMs": 2442,
32853285
"phases": [
32863286
{
32873287
"name": "load-libraries",
3288-
"durationMs": 143
3288+
"durationMs": 151
32893289
},
32903290
{
32913291
"name": "score-all-libraries",
3292-
"durationMs": 2727
3292+
"durationMs": 2288
32933293
},
32943294
{
32953295
"name": "score-material",
3296-
"durationMs": 178
3296+
"durationMs": 220
32973297
},
32983298
{
32993299
"name": "score-spectrum",
3300-
"durationMs": 148
3300+
"durationMs": 162
33013301
},
33023302
{
33033303
"name": "score-vaadin",
3304-
"durationMs": 916
3304+
"durationMs": 696
33053305
},
33063306
{
33073307
"name": "score-fluentui",
3308-
"durationMs": 38
3308+
"durationMs": 52
33093309
},
33103310
{
33113311
"name": "score-carbon",
3312-
"durationMs": 132
3312+
"durationMs": 105
33133313
},
33143314
{
33153315
"name": "score-ui5",
3316-
"durationMs": 225
3316+
"durationMs": 180
33173317
},
33183318
{
33193319
"name": "score-calcite",
3320-
"durationMs": 578
3320+
"durationMs": 257
33213321
},
33223322
{
33233323
"name": "score-porsche",
3324-
"durationMs": 62
3324+
"durationMs": 73
33253325
},
33263326
{
33273327
"name": "score-ionic",
3328-
"durationMs": 119
3328+
"durationMs": 60
33293329
},
33303330
{
33313331
"name": "score-wired",
3332-
"durationMs": 24
3332+
"durationMs": 46
33333333
},
33343334
{
33353335
"name": "score-elix",
3336-
"durationMs": 236
3336+
"durationMs": 372
33373337
},
33383338
{
33393339
"name": "score-helix",
3340-
"durationMs": 71
3340+
"durationMs": 65
33413341
},
33423342
{
33433343
"name": "generate-scorecards",
3344-
"durationMs": 5
3344+
"durationMs": 3
33453345
},
33463346
{
33473347
"name": "generate-comparison",
3348-
"durationMs": 1
3348+
"durationMs": 0
33493349
}
33503350
],
33513351
"withinGate": true

tests/handlers/shadow-dom-checker.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,51 @@ describe('checkShadowDomUsage', () => {
346346
});
347347
});
348348

349+
describe(':root scope token detection', () => {
350+
it('detects component tokens set on :root', () => {
351+
const css = `:root { --my-button-bg: blue; }`;
352+
const result = checkShadowDomUsage(css, 'my-button', buttonMeta);
353+
expect(result.clean).toBe(false);
354+
expect(result.issues).toHaveLength(1);
355+
expect(result.issues[0]!.rule).toBe('no-root-scope-token');
356+
expect(result.issues[0]!.severity).toBe('error');
357+
expect(result.issues[0]!.message).toContain(':root');
358+
expect(result.issues[0]!.suggestion).toContain('my-button');
359+
});
360+
361+
it('detects multiple component tokens on :root', () => {
362+
const css = `:root {\n --my-button-bg: blue;\n --my-button-color: white;\n}`;
363+
const result = checkShadowDomUsage(css, 'my-button', buttonMeta);
364+
expect(result.issues.filter((i) => i.rule === 'no-root-scope-token')).toHaveLength(2);
365+
});
366+
367+
it('ignores non-component tokens on :root', () => {
368+
const css = `:root { --global-font-size: 16px; }`;
369+
const result = checkShadowDomUsage(css, 'my-button', buttonMeta);
370+
const rootIssues = result.issues.filter((i) => i.rule === 'no-root-scope-token');
371+
expect(rootIssues).toHaveLength(0);
372+
});
373+
374+
it('detects tokens in :root with var() usage', () => {
375+
const css = `:root { --my-button-bg: var(--theme-primary); }`;
376+
const result = checkShadowDomUsage(css, 'my-button', buttonMeta);
377+
expect(result.issues.some((i) => i.rule === 'no-root-scope-token')).toBe(true);
378+
});
379+
380+
it('skips :root check when no metadata provided', () => {
381+
const css = `:root { --my-button-bg: blue; }`;
382+
const result = checkShadowDomUsage(css, 'my-button');
383+
const rootIssues = result.issues.filter((i) => i.rule === 'no-root-scope-token');
384+
expect(rootIssues).toHaveLength(0);
385+
});
386+
387+
it('detects :root tokens in multi-block CSS', () => {
388+
const css = `my-button { color: red; }\n:root { --my-button-bg: blue; }\n.container { display: flex; }`;
389+
const result = checkShadowDomUsage(css, 'my-button', buttonMeta);
390+
expect(result.issues.some((i) => i.rule === 'no-root-scope-token')).toBe(true);
391+
});
392+
});
393+
349394
describe('result structure', () => {
350395
it('includes tagName when provided', () => {
351396
const result = checkShadowDomUsage('', 'my-button');

0 commit comments

Comments
 (0)