diff --git a/.changeset/preflight-full-validation.md b/.changeset/preflight-full-validation.md new file mode 100644 index 0000000..f6e9574 --- /dev/null +++ b/.changeset/preflight-full-validation.md @@ -0,0 +1,6 @@ +--- +'helixir': patch +'@helixir/core': patch +--- + +Enhance styling_preflight to run all 7 CSS validators in a single call — adds token fallback, scope, shorthand, color contrast, and specificity checks alongside existing shadow DOM and theme compatibility checks diff --git a/custom-elements.json b/custom-elements.json index 10130a2..03c827c 100644 --- a/custom-elements.json +++ b/custom-elements.json @@ -4164,6 +4164,36 @@ "kind": "javascript-module", "path": "packages/core/src/handlers/scope-checker.ts", "declarations": [ + { + "kind": "function", + "name": "checkCssScopeFromMeta", + "return": { + "type": { + "text": "ScopeCheckResult" + } + }, + "parameters": [ + { + "name": "css", + "type": { + "text": "string" + } + }, + { + "name": "tagName", + "type": { + "text": "string" + } + }, + { + "name": "cssProperties", + "type": { + "text": "Array<{ name: string }>" + } + } + ], + "description": "Core implementation accepting a pre-built set of known tokens.\nUsed by both the CEM-based entry point and the preflight (which already has metadata)." + }, { "kind": "function", "name": "checkCssScope", @@ -4191,10 +4221,19 @@ "text": "Cem" } } - ] + ], + "description": "CEM-based entry point — parses the CEM to extract known tokens,\nthen delegates to the core implementation." } ], "exports": [ + { + "kind": "js", + "name": "checkCssScopeFromMeta", + "declaration": { + "name": "checkCssScopeFromMeta", + "module": "packages/core/src/handlers/scope-checker.ts" + } + }, { "kind": "js", "name": "checkCssScope", @@ -4888,6 +4927,30 @@ "kind": "javascript-module", "path": "packages/core/src/handlers/token-fallback-checker.ts", "declarations": [ + { + "kind": "function", + "name": "checkTokenFallbacksFromMeta", + "return": { + "type": { + "text": "TokenFallbackResult" + } + }, + "parameters": [ + { + "name": "cssText", + "type": { + "text": "string" + } + }, + { + "name": "knownTokens", + "type": { + "text": "Set" + } + } + ], + "description": "Core implementation accepting a pre-built set of known tokens.\nUsed by both the CEM-based entry point and the preflight (which already has metadata)." + }, { "kind": "function", "name": "checkTokenFallbacks", @@ -4915,10 +4978,19 @@ "text": "Cem" } } - ] + ], + "description": "CEM-based entry point — parses the CEM to extract known tokens,\nthen delegates to the core implementation." } ], "exports": [ + { + "kind": "js", + "name": "checkTokenFallbacksFromMeta", + "declaration": { + "name": "checkTokenFallbacksFromMeta", + "module": "packages/core/src/handlers/token-fallback-checker.ts" + } + }, { "kind": "js", "name": "checkTokenFallbacks", diff --git a/packages/core/src/handlers/scope-checker.ts b/packages/core/src/handlers/scope-checker.ts index 2f4faf9..763c139 100644 --- a/packages/core/src/handlers/scope-checker.ts +++ b/packages/core/src/handlers/scope-checker.ts @@ -84,9 +84,16 @@ function parseCssBlocks(css: string): CssBlock[] { // ─── Main Entry Point ─────────────────────────────────────────────────────── -export function checkCssScope(css: string, tagName: string, cem: Cem): ScopeCheckResult { - const meta = parseCem(tagName, cem); - const knownTokens = new Set(meta.cssProperties.map((p) => p.name)); +/** + * Core implementation accepting a pre-built set of known tokens. + * Used by both the CEM-based entry point and the preflight (which already has metadata). + */ +export function checkCssScopeFromMeta( + css: string, + tagName: string, + cssProperties: Array<{ name: string }>, +): ScopeCheckResult { + const knownTokens = new Set(cssProperties.map((p) => p.name)); const blocks = parseCssBlocks(css); const issues: ScopeIssue[] = []; @@ -114,3 +121,12 @@ export function checkCssScope(css: string, tagName: string, cem: Cem): ScopeChec clean: issues.length === 0, }; } + +/** + * CEM-based entry point — parses the CEM to extract known tokens, + * then delegates to the core implementation. + */ +export function checkCssScope(css: string, tagName: string, cem: Cem): ScopeCheckResult { + const meta = parseCem(tagName, cem); + return checkCssScopeFromMeta(css, tagName, meta.cssProperties); +} diff --git a/packages/core/src/handlers/styling-preflight.ts b/packages/core/src/handlers/styling-preflight.ts index 98af318..eacf93f 100644 --- a/packages/core/src/handlers/styling-preflight.ts +++ b/packages/core/src/handlers/styling-preflight.ts @@ -16,7 +16,12 @@ import type { ComponentMetadata } from './cem.js'; import { resolveCssApi, type CssApiResolution } from './css-api-resolver.js'; import { checkShadowDomUsage } from './shadow-dom-checker.js'; import { checkThemeCompatibility } from './theme-checker.js'; +import { checkCssShorthand } from './shorthand-checker.js'; +import { checkColorContrast } from './color-contrast-checker.js'; +import { checkCssSpecificity } from './specificity-checker.js'; import { buildCssSnippet } from './styling-diagnostics.js'; +import { checkTokenFallbacksFromMeta } from './token-fallback-checker.js'; +import { checkCssScopeFromMeta } from './scope-checker.js'; // ─── Types ─────────────────────────────────────────────────────────────────── @@ -60,9 +65,10 @@ export function runStylingPreflight(input: PreflightInput): PreflightResult { // 1. Resolve CSS references against CEM const resolution = resolveCssApi(css, meta, html); - // 2. Run shadow DOM validation (if CSS is non-empty) + // 2. Run all CSS validators (if CSS is non-empty) if (css.trim()) { - try { + // Shadow DOM anti-patterns + safeRun(() => { const shadowResult = checkShadowDomUsage(css, meta.tagName, meta); for (const issue of shadowResult.issues) { issues.push({ @@ -73,12 +79,10 @@ export function runStylingPreflight(input: PreflightInput): PreflightResult { suggestion: issue.suggestion, }); } - } catch { - // Shadow DOM check failed — skip - } + }); - // 3. Run theme compatibility check - try { + // Theme compatibility (hardcoded colors, mixed token sources, dark mode shadows) + safeRun(() => { const themeResult = checkThemeCompatibility(css); for (const issue of themeResult.issues) { issues.push({ @@ -88,9 +92,74 @@ export function runStylingPreflight(input: PreflightInput): PreflightResult { line: issue.line, }); } - } catch { - // Theme check failed — skip - } + }); + + // Token fallbacks (var() without fallbacks, hardcoded colors on theme properties) + safeRun(() => { + const knownTokens = new Set(meta.cssProperties.map((p) => p.name)); + const fallbackResult = checkTokenFallbacksFromMeta(css, knownTokens); + for (const issue of fallbackResult.issues) { + issues.push({ + severity: 'warning', + category: 'tokenFallbacks', + message: issue.message, + line: issue.line, + }); + } + }); + + // Scope validation (component tokens on :root instead of host) + safeRun(() => { + const scopeResult = checkCssScopeFromMeta(css, meta.tagName, meta.cssProperties); + for (const issue of scopeResult.issues) { + issues.push({ + severity: 'warning', + category: 'scope', + message: issue.message, + line: issue.line, + }); + } + }); + + // Shorthand + var() risky combinations + safeRun(() => { + const shorthandResult = checkCssShorthand(css); + for (const issue of shorthandResult.issues) { + issues.push({ + severity: 'warning', + category: 'shorthand', + message: issue.message, + line: issue.line, + suggestion: issue.suggestion, + }); + } + }); + + // Color contrast issues (low-contrast pairs, mixed sources) + safeRun(() => { + const contrastResult = checkColorContrast(css); + for (const issue of contrastResult.issues) { + issues.push({ + severity: 'warning', + category: 'colorContrast', + message: issue.message, + line: issue.line, + }); + } + }); + + // CSS specificity anti-patterns (!important, ID selectors, deep nesting) + safeRun(() => { + const specResult = checkCssSpecificity(css); + for (const issue of specResult.issues) { + issues.push({ + severity: 'info', + category: 'specificity', + message: issue.message, + line: issue.line, + }); + } + }); } // 4. Build the component API summary @@ -118,6 +187,16 @@ export function runStylingPreflight(input: PreflightInput): PreflightResult { }; } +// ─── Safe Runner ───────────────────────────────────────────────────────────── + +function safeRun(fn: () => void): void { + try { + fn(); + } catch { + // Individual checker failed — skip and continue with other checks + } +} + // ─── Verdict Builder ──────────────────────────────────────────────────────── function buildVerdict(resolution: CssApiResolution, issues: PreflightIssue[]): string { diff --git a/packages/core/src/handlers/token-fallback-checker.ts b/packages/core/src/handlers/token-fallback-checker.ts index 593ccf6..097bc1a 100644 --- a/packages/core/src/handlers/token-fallback-checker.ts +++ b/packages/core/src/handlers/token-fallback-checker.ts @@ -171,14 +171,14 @@ function parseCssDeclarations(css: string): CssPropertyDecl[] { // ─── Main Entry Point ─────────────────────────────────────────────────────── -export function checkTokenFallbacks( +/** + * Core implementation accepting a pre-built set of known tokens. + * Used by both the CEM-based entry point and the preflight (which already has metadata). + */ +export function checkTokenFallbacksFromMeta( cssText: string, - tagName: string, - cem: Cem, + knownTokens: Set, ): TokenFallbackResult { - const meta = parseCem(tagName, cem); - const knownTokens = new Set(meta.cssProperties.map((p) => p.name)); - const declarations = parseCssDeclarations(cssText); const issues: TokenFallbackIssue[] = []; let totalVarCalls = 0; @@ -228,3 +228,17 @@ export function checkTokenFallbacks( clean: issues.length === 0, }; } + +/** + * CEM-based entry point — parses the CEM to extract known tokens, + * then delegates to the core implementation. + */ +export function checkTokenFallbacks( + cssText: string, + tagName: string, + cem: Cem, +): TokenFallbackResult { + const meta = parseCem(tagName, cem); + const knownTokens = new Set(meta.cssProperties.map((p) => p.name)); + return checkTokenFallbacksFromMeta(cssText, knownTokens); +} diff --git a/tests/handlers/styling-preflight.test.ts b/tests/handlers/styling-preflight.test.ts index 502d560..c68e3c2 100644 --- a/tests/handlers/styling-preflight.test.ts +++ b/tests/handlers/styling-preflight.test.ts @@ -187,6 +187,74 @@ describe('runStylingPreflight — bare component', () => { }); }); +// ─── Token Fallback Validation ─────────────────────────────────────────────── + +describe('runStylingPreflight — token fallback validation', () => { + it('catches var() without fallback on custom property declarations', () => { + const result = runStylingPreflight({ + css: 'hx-button { --hx-button-bg: var(--undefined-token); }', + meta: buttonMeta, + }); + expect(result.issues.some((i) => i.category === 'tokenFallbacks')).toBe(true); + }); + + it('allows known component tokens without fallback', () => { + const result = runStylingPreflight({ + css: 'hx-button { color: var(--hx-button-color); }', + meta: buttonMeta, + }); + expect(result.issues.filter((i) => i.category === 'tokenFallbacks')).toHaveLength(0); + }); +}); + +// ─── Scope Validation ──────────────────────────────────────────────────────── + +describe('runStylingPreflight — scope validation', () => { + it('catches component tokens on :root', () => { + const result = runStylingPreflight({ + css: ':root { --hx-button-bg: blue; }', + meta: buttonMeta, + }); + expect(result.issues.some((i) => i.category === 'scope')).toBe(true); + }); +}); + +// ─── Shorthand Validation ──────────────────────────────────────────────────── + +describe('runStylingPreflight — shorthand validation', () => { + it('catches risky shorthand + var() combinations', () => { + const result = runStylingPreflight({ + css: 'hx-button { border: 1px solid var(--hx-button-color); }', + meta: buttonMeta, + }); + expect(result.issues.some((i) => i.category === 'shorthand')).toBe(true); + }); +}); + +// ─── Color Contrast Validation ─────────────────────────────────────────────── + +describe('runStylingPreflight — color contrast validation', () => { + it('catches low-contrast color pairs', () => { + const result = runStylingPreflight({ + css: 'hx-button { color: #eeeeee; background: #ffffff; }', + meta: buttonMeta, + }); + expect(result.issues.some((i) => i.category === 'colorContrast')).toBe(true); + }); +}); + +// ─── Specificity Validation ────────────────────────────────────────────────── + +describe('runStylingPreflight — specificity validation', () => { + it('catches !important usage', () => { + const result = runStylingPreflight({ + css: 'hx-button { color: red !important; }', + meta: buttonMeta, + }); + expect(result.issues.some((i) => i.category === 'specificity')).toBe(true); + }); +}); + // ─── Overall Verdict ───────────────────────────────────────────────────────── describe('runStylingPreflight — verdict', () => { @@ -201,7 +269,7 @@ describe('runStylingPreflight — verdict', () => { it('reports pass verdict for clean CSS', () => { const result = runStylingPreflight({ - css: 'hx-button { --hx-button-bg: blue; }', + css: 'hx-button { --hx-button-bg: var(--theme-primary, blue); }', meta: buttonMeta, }); expect(result.verdict).toContain('pass');