From 0f0831ace04df233fe7fbaf9af8e5de3933a1d6f Mon Sep 17 00:00:00 2001 From: Jake Strawn Date: Sat, 21 Mar 2026 02:43:07 -0400 Subject: [PATCH 1/3] fix: buildCssSnippet self-referential var() bug + slot styling guidance + font inheritance warnings - Fix buildCssSnippet generating circular --token: var(--token) CSS - Use CEM default values or smart property-name heuristics for placeholder values - Add slot styling section showing correct light DOM CSS patterns - Add font vs layout inheritance anti-pattern warning for slotted content Co-Authored-By: Claude Opus 4.6 --- .../core/src/handlers/styling-diagnostics.ts | 55 +++++++++++++++-- tests/handlers/styling-diagnostics.test.ts | 59 ++++++++++++++++++- 2 files changed, 107 insertions(+), 7 deletions(-) diff --git a/packages/core/src/handlers/styling-diagnostics.ts b/packages/core/src/handlers/styling-diagnostics.ts index 47edb3a..2542022 100644 --- a/packages/core/src/handlers/styling-diagnostics.ts +++ b/packages/core/src/handlers/styling-diagnostics.ts @@ -188,6 +188,16 @@ export function buildAntiPatterns(meta: ComponentMetadata): AntiPatternWarning[] }); } + // Warn about font vs layout inheritance through slots + if (meta.slots.length > 0) { + warnings.push({ + pattern: `${tag}::slotted(div) { margin: 10px; } /* expecting layout to inherit */`, + explanation: + 'Slotted content inherits font styles (color, font-size, line-height) from the shadow DOM, but layout properties (margin, padding, display, width) must be set in light DOM CSS — they do not inherit through the shadow boundary.', + correctApproach: `Style layout in light DOM CSS: ${tag} > div { margin: 10px; }. Font properties like color and font-size will inherit from the component's shadow DOM automatically.`, + }); + } + return warnings; } @@ -200,13 +210,14 @@ export function buildCssSnippet(meta: ComponentMetadata): string { const lines: string[] = []; const tag = meta.tagName; - // Token customization section + // Token customization section — show how to OVERRIDE custom properties if (meta.cssProperties.length > 0) { - lines.push(`/* Token customization */`); + lines.push(`/* Token customization — override on the component host */`); lines.push(`${tag} {`); for (const prop of meta.cssProperties.slice(0, 5)) { - const defaultVal = prop.description ? `/* ${prop.description} */` : ''; - lines.push(` ${prop.name}: var(${prop.name}) ${defaultVal};`.trimEnd()); + const value = prop.default ?? guessDefaultValue(prop.name); + const comment = prop.description ? ` /* ${prop.description} */` : ''; + lines.push(` ${prop.name}: ${value};${comment}`); } lines.push(`}`); } @@ -223,6 +234,25 @@ export function buildCssSnippet(meta: ComponentMetadata): string { } } + // Slot styling section — show how to style slotted content in light DOM + if (meta.slots.length > 0) { + lines.push(''); + lines.push(`/* Slot styling — target slotted elements in light DOM CSS */`); + const hasDefaultSlot = meta.slots.some((s) => s.name === ''); + const namedSlots = meta.slots.filter((s) => s.name !== ''); + + if (hasDefaultSlot) { + lines.push(`${tag} > * { /* styles for default slot content */ }`); + } + for (const slot of namedSlots.slice(0, 3)) { + const desc = slot.description ? ` /* ${slot.description} */` : ''; + lines.push(`${tag} [slot="${slot.name}"] { ${desc.trim()} }`); + } + lines.push(`/* Note: slotted content inherits font styles (color, font-size)`); + lines.push(` from the shadow DOM, but layout (margin, padding, display)`); + lines.push(` must be set here in light DOM CSS. */`); + } + if (lines.length === 0) { lines.push(`/* ${tag} exposes no CSS customization points in its CEM. */`); lines.push(`/* Check the component documentation for styling options. */`); @@ -231,6 +261,23 @@ export function buildCssSnippet(meta: ComponentMetadata): string { return lines.join('\n'); } +/** + * Guesses a sensible placeholder value based on CSS property name patterns. + * Used when the CEM doesn't specify a default value. + */ +function guessDefaultValue(propName: string): string { + const lower = propName.toLowerCase(); + if (/color|bg|background/.test(lower)) return '#value'; + if (/size|font/.test(lower)) return '1rem'; + if (/radius/.test(lower)) return '4px'; + if (/spacing|padding|margin|gap/.test(lower)) return '1rem'; + if (/shadow/.test(lower)) return '0 1px 2px rgba(0,0,0,.1)'; + if (/weight/.test(lower)) return '400'; + if (/width|height/.test(lower)) return 'auto'; + if (/opacity/.test(lower)) return '1'; + return '#value'; +} + // ─── Main Entry Point ──────────────────────────────────────────────────────── /** diff --git a/tests/handlers/styling-diagnostics.test.ts b/tests/handlers/styling-diagnostics.test.ts index f59f59e..906554a 100644 --- a/tests/handlers/styling-diagnostics.test.ts +++ b/tests/handlers/styling-diagnostics.test.ts @@ -215,6 +215,19 @@ describe('buildAntiPatterns', () => { ); expect(partWarning).toBeUndefined(); }); + + it('includes font inheritance warning when slots exist', () => { + const warnings = buildAntiPatterns(buttonMeta); + const inheritWarning = warnings.find((w) => w.explanation.includes('inherit')); + expect(inheritWarning).toBeDefined(); + expect(inheritWarning!.explanation).toContain('font'); + }); + + it('skips font inheritance warning when no slots', () => { + const warnings = buildAntiPatterns(bareComponent); + const inheritWarning = warnings.find((w) => w.explanation.includes('inherit')); + expect(inheritWarning).toBeUndefined(); + }); }); describe('buildCssSnippet', () => { @@ -224,12 +237,55 @@ describe('buildCssSnippet', () => { expect(snippet).toContain('--my-button-bg'); }); + it('does NOT generate self-referential var() in token customization', () => { + const snippet = buildCssSnippet(buttonMeta); + // Should NOT contain --my-button-bg: var(--my-button-bg) — that's circular + expect(snippet).not.toMatch(/--my-button-bg:\s*var\(--my-button-bg\)/); + // Should contain a direct value assignment + expect(snippet).toMatch(/--my-button-bg:\s*[^v]/); + }); + + it('uses CEM default value when available', () => { + const metaWithDefaults: ComponentMetadata = { + ...bareComponent, + cssProperties: [ + { name: '--my-card-bg', description: 'Background', default: '#ffffff' }, + { name: '--my-card-radius', description: 'Radius', default: '4px' }, + ], + }; + const snippet = buildCssSnippet(metaWithDefaults); + expect(snippet).toContain('#ffffff'); + expect(snippet).toContain('4px'); + }); + it('includes part customization for components with CSS parts', () => { const snippet = buildCssSnippet(buttonMeta); expect(snippet).toContain('Part-based customization'); expect(snippet).toContain('::part(base)'); }); + it('includes slot styling section when component has slots', () => { + const snippet = buildCssSnippet(buttonMeta); + expect(snippet).toContain('Slot styling'); + expect(snippet).toContain('light DOM'); + }); + + it('includes named slot selector example', () => { + const snippet = buildCssSnippet(buttonMeta); + expect(snippet).toContain('[slot="prefix"]'); + }); + + it('includes default slot child selector example', () => { + const snippet = buildCssSnippet(buttonMeta); + // Default slot: style children of the host + expect(snippet).toContain('my-button >'); + }); + + it('skips slot section for components without slots', () => { + const snippet = buildCssSnippet(bareComponent); + expect(snippet).not.toContain('Slot styling'); + }); + it('generates fallback message for bare components', () => { const snippet = buildCssSnippet(bareComponent); expect(snippet).toContain('no CSS customization'); @@ -248,9 +304,6 @@ describe('buildCssSnippet', () => { })), }; const snippet = buildCssSnippet(manyProps); - // Should have at most 5 properties listed (each appears twice: decl + var) - const propMatches = snippet.match(/--prop-/g); - expect(propMatches!.length).toBeLessThanOrEqual(10); // Should not contain --prop-5 through --prop-9 expect(snippet).not.toContain('--prop-5'); // Should have at most 3 parts listed From 0192413feca6f19d5a2fd5bc2b3bebd105608d32 Mon Sep 17 00:00:00 2001 From: Jake Strawn Date: Sat, 21 Mar 2026 02:43:23 -0400 Subject: [PATCH 2/3] style: format benchmark fixture JSON files Co-Authored-By: Claude Opus 4.6 --- .../benchmark-results/latest-benchmark.json | 58 +++++++++---------- 1 file changed, 29 insertions(+), 29 deletions(-) diff --git a/tests/__fixtures__/benchmark-results/latest-benchmark.json b/tests/__fixtures__/benchmark-results/latest-benchmark.json index 1af9afe..7f45518 100644 --- a/tests/__fixtures__/benchmark-results/latest-benchmark.json +++ b/tests/__fixtures__/benchmark-results/latest-benchmark.json @@ -1,5 +1,5 @@ { - "timestamp": "2026-03-21T06:20:17.944Z", + "timestamp": "2026-03-21T06:42:44.161Z", "scorecards": { "material": { "library": "material", @@ -187,7 +187,7 @@ "heuristic": 426, "untested": 747 }, - "timestamp": "2026-03-21T06:20:17.942Z" + "timestamp": "2026-03-21T06:42:44.159Z" }, "spectrum": { "library": "spectrum", @@ -375,7 +375,7 @@ "heuristic": 404, "untested": 601 }, - "timestamp": "2026-03-21T06:20:17.942Z" + "timestamp": "2026-03-21T06:42:44.159Z" }, "vaadin": { "library": "vaadin", @@ -563,7 +563,7 @@ "heuristic": 439, "untested": 717 }, - "timestamp": "2026-03-21T06:20:17.942Z" + "timestamp": "2026-03-21T06:42:44.159Z" }, "fluentui": { "library": "fluentui", @@ -751,7 +751,7 @@ "heuristic": 139, "untested": 293 }, - "timestamp": "2026-03-21T06:20:17.942Z" + "timestamp": "2026-03-21T06:42:44.159Z" }, "carbon": { "library": "carbon", @@ -939,7 +939,7 @@ "heuristic": 397, "untested": 793 }, - "timestamp": "2026-03-21T06:20:17.942Z" + "timestamp": "2026-03-21T06:42:44.159Z" }, "ui5": { "library": "ui5", @@ -1127,7 +1127,7 @@ "heuristic": 582, "untested": 1304 }, - "timestamp": "2026-03-21T06:20:17.943Z" + "timestamp": "2026-03-21T06:42:44.160Z" }, "calcite": { "library": "calcite", @@ -1315,7 +1315,7 @@ "heuristic": 318, "untested": 848 }, - "timestamp": "2026-03-21T06:20:17.943Z" + "timestamp": "2026-03-21T06:42:44.160Z" }, "porsche": { "library": "porsche", @@ -1503,7 +1503,7 @@ "heuristic": 304, "untested": 702 }, - "timestamp": "2026-03-21T06:20:17.943Z" + "timestamp": "2026-03-21T06:42:44.160Z" }, "ionic": { "library": "ionic", @@ -1691,7 +1691,7 @@ "heuristic": 331, "untested": 744 }, - "timestamp": "2026-03-21T06:20:17.944Z" + "timestamp": "2026-03-21T06:42:44.161Z" }, "wired": { "library": "wired", @@ -1879,7 +1879,7 @@ "heuristic": 78, "untested": 208 }, - "timestamp": "2026-03-21T06:20:17.944Z" + "timestamp": "2026-03-21T06:42:44.161Z" }, "elix": { "library": "elix", @@ -2067,7 +2067,7 @@ "heuristic": 148, "untested": 740 }, - "timestamp": "2026-03-21T06:20:17.944Z" + "timestamp": "2026-03-21T06:42:44.161Z" }, "helix": { "library": "helix", @@ -2255,7 +2255,7 @@ "heuristic": 440, "untested": 555 }, - "timestamp": "2026-03-21T06:20:17.944Z" + "timestamp": "2026-03-21T06:42:44.161Z" } }, "comparisonTable": { @@ -3278,66 +3278,66 @@ "API Surface Quality", "Event Architecture" ], - "timestamp": "2026-03-21T06:20:17.944Z" + "timestamp": "2026-03-21T06:42:44.161Z" }, "performance": { - "totalMs": 2907, + "totalMs": 2262, "phases": [ { "name": "load-libraries", - "durationMs": 131 + "durationMs": 168 }, { "name": "score-all-libraries", - "durationMs": 2773 + "durationMs": 2091 }, { "name": "score-material", - "durationMs": 263 + "durationMs": 247 }, { "name": "score-spectrum", - "durationMs": 153 + "durationMs": 105 }, { "name": "score-vaadin", - "durationMs": 861 + "durationMs": 640 }, { "name": "score-fluentui", - "durationMs": 74 + "durationMs": 34 }, { "name": "score-carbon", - "durationMs": 185 + "durationMs": 139 }, { "name": "score-ui5", - "durationMs": 301 + "durationMs": 192 }, { "name": "score-calcite", - "durationMs": 232 + "durationMs": 262 }, { "name": "score-porsche", - "durationMs": 190 + "durationMs": 74 }, { "name": "score-ionic", - "durationMs": 125 + "durationMs": 68 }, { "name": "score-wired", - "durationMs": 34 + "durationMs": 20 }, { "name": "score-elix", - "durationMs": 295 + "durationMs": 169 }, { "name": "score-helix", - "durationMs": 60 + "durationMs": 141 }, { "name": "generate-scorecards", From bd07aff46ad44c59e98662f52ac2e388b448b194 Mon Sep 17 00:00:00 2001 From: Jake Strawn Date: Sat, 21 Mar 2026 02:52:47 -0400 Subject: [PATCH 3/3] chore: add changeset for CSS snippet fix Co-Authored-By: Claude Opus 4.6 --- .changeset/fix-css-snippet-slot-guidance.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/fix-css-snippet-slot-guidance.md diff --git a/.changeset/fix-css-snippet-slot-guidance.md b/.changeset/fix-css-snippet-slot-guidance.md new file mode 100644 index 0000000..c9ee1f2 --- /dev/null +++ b/.changeset/fix-css-snippet-slot-guidance.md @@ -0,0 +1,6 @@ +--- +'helixir': patch +'@helixir/core': patch +--- + +fix: buildCssSnippet self-referential var() bug, add slot styling guidance and font inheritance warnings