Design System Validator Plugin
Design Log #151 — Design System Validator Plugin
Written for AI agents. See Log Methodology Note below for details.
Background
We want a build-time linting plugin that enforces UI conformity against a DESIGN.md specification. The plugin validates .jay-html files and their CSS against defined design tokens (colors, typography, spacing, rounded, components) and structural rules.
An initial spec proposed using Happy DOM + getComputedStyle() via a Vite transform hook. This design log evaluates that approach, identifies its problems, and proposes an alternative based on static CSS analysis with cascade resolution.
Related
- DL#145 — Pluggable jay-html validation (existing system)
- DL#147 — Jay-html validation rules catalog
- DESIGN.md spec
packages/plugins/a11y-validator/— Reference validation pluginpackages/plugins/seo-validator/— Reference validation plugin
Problems with the Happy DOM Approach
1. Happy DOM doesn't compute CSS
getComputedStyle() in Happy DOM returns inline styles or empty strings. It does not resolve stylesheets, cascade, specificity, CSS custom properties, or media queries. Resizing window.innerWidth won't trigger media query re-evaluation either — there is no layout engine.
To get real computed styles you'd need a full browser engine (Playwright/Puppeteer), which is too slow for a build-time linter.
2. Wrong integration point
Jay-html files go through the jay-html compiler, not Vite's transform hook. Jay already has a pluggable validation system (DL#145) with JayHtmlValidatorFn, parsed DOM tree, contract data, walkElements() utility, and agent-friendly error reporting.
3. Static analysis with cascade resolution covers the real use cases
Design token validation is primarily about checking that resolved CSS values match a known set. With postcss for parsing and @csstools/selector-specificity for specificity calculation, we can resolve the cascade statically — no browser needed.
Questions & Answers
Q1: The DESIGN.md spec uses {path.to.token} variable references (e.g., {colors.primary-60}, {rounded.md}). Should we support this syntax?
A1: Yes. The token parser must resolve {path.to.token} references to their final values before validation. This is required by the spec and used heavily in the components section.
Q2: Where should DESIGN.md files live? Project root only, or per-route?
A2: DESIGN.md lives alongside pages. If more than one exists, it takes effect on the route it is placed in and on all child routes, unless a child route also has its own DESIGN.md (which overrides). This lets different sections of a site have different design systems.
Q3: How do we handle exceptions — cases where a value intentionally breaks the design system (e.g., a one-off padding)?
A3: CSS comment directive /* design-system: allow */ on the declaration, or jay-design="allow" attribute on elements for inline styles. Same pattern as ESLint/Stylelint disable comments.
Q4: Can we validate CSS inside headless component inline templates (inside <jay:component-name> blocks)?
A4: Yes. Elements inside <jay:component-name> blocks are part of the DOM tree the validator receives. They are validated the same as any other elements — the walker doesn't distinguish.
Q5: Jay-html allows linking external CSS files via <link rel="stylesheet">. Should we parse and validate those too?
A5: Yes. Resolve paths relative to the jay-html file and parse alongside <style> blocks. Linked files participate in cascade resolution with source-order priority.
Q6: Can we support CSS cascade resolution without a browser engine? The original assessment said we can't, but with PostCSS + @csstools/selector-specificity we can.
A6: Yes. PostCSS parses CSS into rules with selectors and declarations. @csstools/selector-specificity computes specificity per selector. node-html-parser (already available) matches selectors to elements. Combine these to resolve which value wins per element — no browser needed.
Design
DESIGN.md Format
Following the DESIGN.md spec:
---
name: Onsko Clean Beauty
colors:
primary: '#2563eb'
primary-hover: '#1d4ed8'
secondary: '#64748b'
text: '#0f172a'
text-muted: '#64748b'
background: '#ffffff'
surface: '#f8fafc'
border: '#e2e8f0'
error: '#dc2626'
success: '#16a34a'
typography:
headline-lg:
fontFamily: Inter
fontSize: 2.5rem
fontWeight: 700
lineHeight: 1.2
headline-md:
fontFamily: Inter
fontSize: 2rem
fontWeight: 700
lineHeight: 1.3
body-md:
fontFamily: Inter
fontSize: 1rem
fontWeight: 400
lineHeight: 1.6
label-sm:
fontFamily: Inter
fontSize: 0.875rem
fontWeight: 500
lineHeight: 1.5
spacing:
xs: 0.25rem
sm: 0.5rem
md: 1rem
lg: 1.5rem
xl: 2rem
2xl: 3rem
3xl: 4rem
rounded:
none: 0
sm: 0.25rem
md: 0.5rem
lg: 0.75rem
full: 9999px
animations:
fade-in:
duration: 300ms
easing: cubic-bezier(0, 0, 0.2, 1)
slide-up:
duration: 500ms
easing: cubic-bezier(0.4, 0, 0.2, 1)
micro:
duration: 150ms
easing: ease-in-out
components:
# HTML elements — matched by CSS selector
button-primary:
backgroundColor: '{colors.primary}'
textColor: '{colors.background}'
typography: '{typography.label-sm}'
rounded: '{rounded.md}'
padding: '{spacing.sm} {spacing.lg}'
button-primary-hover:
backgroundColor: '{colors.primary-hover}'
card:
backgroundColor: '{colors.surface}'
rounded: '{rounded.lg}'
padding: '{spacing.lg}'
# Jay headless components — matched by <jay:component-name>
jay:login-indicator:
textColor: '{colors.text}'
typography: '{typography.label-sm}'
jay:cart-indicator:
textColor: '{colors.text}'
typography: '{typography.label-sm}'
jay:product-card:
backgroundColor: '{colors.surface}'
rounded: '{rounded.lg}'
padding: '{spacing.md}'
rules:
max-font-weights: 3
max-primary-buttons: 1
require-contrast-aa: true
---
# Onsko Design System
Brand guidelines and usage instructions...
DESIGN.md Scoping (Route Hierarchy)
DESIGN.md files are placed alongside pages:
src/pages/
DESIGN.md # applies to all routes
page.jay-html
products/
DESIGN.md # overrides for /products and children
page.jay-html
[[category]]/
page.jay-html # inherits products/DESIGN.md
admin/
DESIGN.md # separate design system for /admin
page.jay-html
Resolution: walk up from the page's directory to the project root, use the first DESIGN.md found. This mirrors how CSS cascades — closest wins.
Exception Mechanism
Sometimes a value intentionally breaks the design system. Similar to jay-script="allow" for script tags, use a CSS comment directive:
.hero-banner {
padding: 7.5rem 0; /* design-system: allow */
}
And for inline styles on elements:
<div style="margin-top: 7.5rem" jay-design="allow"></div>
The validator skips any declaration or element marked with these directives. This is the same pattern used by ESLint (eslint-disable), Stylelint (stylelint-disable), and Prettier (prettier-ignore).
Plugin Architecture
Standard Jay validation plugin (DL#145 pattern):
packages/plugins/design-system-validator/
lib/
validators/
design-tokens.ts # Token conformance (colors, spacing, rounded, typography)
design-components.ts # Component conformance
design-structure.ts # Structural rules (max weights, primary buttons)
design-contrast.ts # WCAG AA color contrast
parse-design-md.ts # DESIGN.md parser + token resolution
css-cascade.ts # CSS cascade resolver (postcss + selector-specificity)
token-matcher.ts # CSS value → token matching
plugin.yaml
package.json
plugin.yaml:
name: design-system
validators:
- name: design-tokens
handler: validateTokens
description: Validates CSS values against DESIGN.md tokens
- name: design-components
handler: validateComponents
description: Validates component styles against DESIGN.md component specs
- name: design-structure
handler: validateStructure
description: Enforces structural design rules
- name: design-contrast
handler: validateContrast
description: Checks WCAG AA color contrast compliance
CSS Cascade Resolution (The Engine)
Instead of a browser engine, build a lightweight cascade resolver using existing libraries:
Libraries:
postcss— Parse CSS into AST with rules, selectors, and declarationspostcss-selector-parser— Parse selectors into AST nodes@csstools/selector-specificity— Compute specificity frompostcss-selector-parserAST nodesnode-html-parser— Already available in validation context; supportsquerySelectorAllfor selector matching
Algorithm:
1. Collect all CSS sources:
- <style> blocks in the jay-html
- Linked CSS files (<link rel="stylesheet">)
- Inline style="" attributes on elements
2. Parse CSS into rules:
For each CSS source → postcss.parse() → walk rules → (selector, declarations[])
For each selector → postcss-selector-parser → AST → selectorSpecificity()
3. For each element in the jay-html DOM:
a. Find all matching CSS rules (use node-html-parser's selector matching)
b. Compute specificity for each matching rule (via @csstools/selector-specificity)
c. Sort by: (source order for same specificity, specificity for different)
d. Apply cascade: later/higher-specificity wins, inline styles win all
e. Result: resolved property→value map for this element
4. Validate resolved values against design tokens
What this handles:
- Multiple selectors targeting the same element (cascade)
- Class, ID, attribute, and pseudo-class specificity
- Source order tiebreaking
- Inline style override
!importantdeclarations- Media query blocks (each breakpoint validated independently)
What this does NOT handle (acceptable limitations):
- Inherited values from parent elements (e.g.,
colorinheriting through the tree) — would require walking up the DOM for each inheritable property; possible as a future enhancement calc(),min(),max()expressions — flag as "cannot validate statically"- Values set by JavaScript at runtime — out of scope
- CSS custom properties defined outside the validated files — flag as unresolvable
Validation Rules
1. Token conformance (design-tokens validator)
For each element's resolved CSS values:
- Colors (
color,background-color,border-color,outline-color, etc.) — flag hardcoded values not in the token map; suggest the closest token - Spacing (
padding,margin,gap,top,right,bottom,left) — check values against spacing scale - Rounded (
border-radius) — check against rounded tokens - Typography (
font-size,font-weight,line-height,letter-spacing,font-family) — check combinations against typography tokens - Animations (
transition-duration,animation-duration,transition-timing-function,animation-timing-function) — check duration and easing values against animation presets. Named presets define composites of duration + easing; individual properties are validated against the union of all preset values for that property - Reduced motion — if any
transitionoranimationdeclaration exists, warn if no@media (prefers-reduced-motion)block is present in the same CSS source. This is a page-level check, not per-element
CSS custom property references (var(--name)) are checked for existence in the token map but not resolved further.
2. Component conformance (design-components validator)
Elements matching component selectors (defined in DESIGN.md components section) are validated as a composite — all specified properties must match the component spec simultaneously.
Two kinds of component targets:
- HTML components (e.g.,
button-primary,card) — matched by CSS class or selector against DOM elements - Jay headless components (e.g.,
jay:login-indicator,jay:product-card) — matched by the<jay:component-name>tag. The validator checks the resolved styles on the inline template root element(s) inside the<jay:...>block
The jay: prefix in the components section maps directly to jay-html headless component tags. This lets the design system define style expectations for any headless component — page-level instances, nested instances, plugin components.
3. Structural rules (design-structure validator)
Uses the parsed DOM tree:
- Max font weights: Collect unique
font-weightvalues across the page, warn if exceedingrules.max-font-weights - Max primary buttons: Count distinct primary action buttons by (ref, text content) pairs — the same button appearing multiple times (same ref, same text) counts as one
- Custom structural rules: Extensible for project-specific checks
4. Contrast checking (design-contrast validator)
For elements where both foreground color and background color are statically determinable:
- Compute WCAG 2.1 relative luminance for each color
- Calculate contrast ratio
- Flag pairs below 4.5:1 (AA normal text) or 3:1 (AA large text)
- Skip elements where colors are dynamic bindings or inherited from unknown ancestors
5. Responsive breakpoint validation
Parse media query blocks in the CSS. For each breakpoint:
- Run the same token/component/structural validation on the rules within that media query
- Report findings grouped by breakpoint
- No visual/layout checking — purely token conformance per breakpoint
External CSS Files
Jay-html supports <link rel="stylesheet" href="...">. The validator resolves these paths relative to the jay-html file and parses them alongside <style> blocks. All rules from linked files participate in cascade resolution with lower priority than inline <style> blocks (per CSS source order).
Integration with Existing Validators
The design-system validator complements existing validators:
- a11y-validator → structural accessibility (alt text, ARIA, form labels)
- seo-validator → SEO metadata and semantics
- design-system-validator → visual conformity to design tokens and component specs
Designer Role Guide
The plugin should include an agent-kit designer guide (agent-kit/designer/design-system.md) that:
- Explains how DESIGN.md works — tokens,
{references}, components section - Shows how to use tokens in CSS (via custom properties or direct values)
- Lists the validation errors the designer will encounter and how to fix them
- Explains the exception mechanism (
/* design-system: allow */)
Example validation errors the guide should document:
⚠ Hardcoded color #ff0000 not in design system
Suggestion: Use token {colors.error} ("#dc2626") or add to DESIGN.md
⚠ Padding "13px" not in spacing scale
Suggestion: Use {spacing.md} ("1rem") or {spacing.lg} ("1.5rem"),
or add /* design-system: allow */ to exempt this value
⚠ border-radius "10px" not in rounded scale
Suggestion: Use {rounded.lg} ("0.75rem") or {rounded.full} ("9999px")
⚠ <jay:product-card> inline template: backgroundColor does not match
component spec. Expected "{colors.surface}" (#f8fafc), found "#ffffff"
Suggestion: Update background-color to match DESIGN.md
jay:product-card component definition
⚠ 4 unique font-weight values found (max: 3)
Suggestion: Reduce to 3 font-weight values from the typography tokens
⚠ Contrast ratio 2.8:1 below WCAG AA (4.5:1) for text "{colors.text-muted}"
on background "{colors.surface}"
Suggestion: Darken text color or lighten background
The guide should be concise — the validation errors themselves are the primary teaching tool, with the guide providing the mental model for why tokens matter.
Implementation Plan
Phase 1: Token parser + basic CSS validation (no cascade)
parse-design-md.ts— parse YAML frontmatter, resolve{path.to.token}references, DESIGN.md route-scoping resolutiontoken-matcher.ts— match CSS values against token scales (color normalization, unit conversion)design-tokensvalidator — parse<style>blocks, validate values against tokens, support/* design-system: allow */exceptions- Register as Jay validation plugin
Phase 2: Cascade resolver
css-cascade.ts— parse CSS withpostcss, compute specificity with@csstools/selector-specificity, resolve cascade per element- Support linked external CSS files
- Support inline
style=""attributes withjay-design="allow"exceptions - Update token validator to use resolved cascade values instead of raw declarations
Phase 3: Component + structural validation
design-componentsvalidator — composite component spec matchingdesign-structurevalidator — font weight count, primary button count (by ref+text identity)
Phase 4: Contrast + responsive
design-contrastvalidator — WCAG AA contrast ratio on static color pairs- Responsive breakpoint validation — per-media-query token conformance
Phase 5: Designer guide + AIditor add-menu
agent-kit/designer/design-system.md— tokens, usage, validation errors, exceptions- AIditor add-menu integration — surface DESIGN.md tokens as add-menu items so the AI Designer can browse and apply tokens directly from the visual editor. Parse the project's DESIGN.md at agent-kit generation time and emit add-menu entries for color palettes, typography presets, spacing scale, and component specs. This makes the design system discoverable inside the AIditor without the designer needing to read the raw DESIGN.md file.
Trade-offs
| Approach | Pros | Cons |
|---|---|---|
| Static CSS + cascade resolver (this design) | Fast, deterministic, works in CI, handles cascade | No inheritance; complex expressions skipped |
| Happy DOM computed styles | Theoretically checks cascade | Doesn't work — Happy DOM doesn't compute CSS |
| Playwright/browser rendering | Real computed styles, real media queries | Slow, requires running server, flaky in CI |
| Stylelint custom rules | Mature CSS linting ecosystem | No jay-html structure awareness, no contracts |
Verification Criteria
- Plugin loads via standard
plugin.yamlregistration jay-stack validateruns design-system rules alongside a11y and seo validators- DESIGN.md scoping resolves correctly (child route inherits, override replaces)
- Hardcoded colors in CSS produce warnings with closest token suggestion
- Spacing/rounded values outside the scale produce warnings
/* design-system: allow */andjay-design="allow"suppress findings- Cascade resolver correctly determines winning value when multiple selectors match
- Component conformance validates composite specs from DESIGN.md components section
- Primary button count uses (ref, text) identity — duplicates don't count
- Contrast violations flagged on statically determinable color pairs
- Media query blocks validated independently per breakpoint
Implementation Results
Phase 1–5: Initial implementation (prior to this log entry)
All phases implemented and working. 85 tests passing across 7 test files.
Bug Fix: CSS discovery from <head> (DL#154-related)
Problem: extractCssSources(ctx.body, filePath) searched for <style> and <link> elements only inside ctx.body (the <body> element). In real jay-html files, CSS lives in <head>. The validator never found any CSS.
Root cause: The validator context passes body as an HTMLElement (the <body> tag), but CSS is declared in <head>. Tests masked this because they passed parse(html) (the document root) as body.
Two-part fix:
Validator interface — Add
css?: stringtoJayHtmlValidationContextincompiler-shared. The jay-html parser already extracts all CSS (from<head><style>blocks and linked<link>stylesheets) intoparsed.css. Pass it through the context so validators don't need to re-discover CSS from the DOM.Validators — Use
ctx.csswhen available instead ofextractCssSources. RemovefindDocumentRoot(the interim parent-traversal fix). KeepextractCssSourcesas fallback for backward compatibility.
Test fix: All makeContext helpers changed from body: parse(html) to body: root.querySelector('body') || root to match real-world usage.
Bug Fix: background shorthand color validation
Problem: The background CSS shorthand was not in COLOR_PROPERTIES, so background: #fff was never validated. Simply adding background to COLOR_PROPERTIES is insufficient because compound values like multi-layer backgrounds contain embedded colors mixed with gradients:
/* Single layer, simple — works with naive approach */
background: #0f172a;
/* Multi-layer with fallback — naive approach misses #0f172a */
background: radial-gradient(circle at 20% 30%, #4f46e5 0%, transparent 40%), #0f172a;
/* Color + image — naive approach gets the compound string */
background: #fff url('image.jpg') center/cover no-repeat;
Fix: Do not add background to COLOR_PROPERTIES. Instead:
Add
extractBackgroundColors(value): string[]totoken-matcher.ts— splits on top-level commas (respecting parenthesized nesting), identifies standalone color values (hex or rgb/rgba not inside a function), returns them for individual validation.In
validateElementStyles, add a separatebackgroundshorthand check that extracts colors and validates each one against the color token set.
Colors inside gradient functions (e.g., #4f46e5 in radial-gradient(circle at 20%, #4f46e5 0%, ...)) are not extracted — they are gradient stops, not background colors.
CSS custom property resolution
Problem: Values like var(--radius-md) were compared literally against resolved spec values like 8px, producing false positives when the var resolves to the correct value.
Fix: The cascade resolver now collects :root custom property declarations during CSS parsing and resolves var() references in resolved styles. This eliminated ~3000 false positives in the test project. Unresolvable vars (from external stylesheets) are left as var(...) and still auto-pass through token matchers.
Component findings: grouping and element identification
Problem: Component spec mismatches were reported as individual findings per property, making it hard to see which element was being flagged or what component spec was referenced.
Fixes:
Grouped mismatches — Multiple property mismatches for the same component+element are grouped into a single numbered finding instead of separate warnings.
DESIGN.md path notation — Component findings use
DESIGN.md components.btn-ctadotted path to disambiguate from Jay headless components.Element descriptions — Findings include tag name, CSS classes, and first 2-3 words of text content:
<button class="btn btn-cta" > "shop now".Raw token references — Component spec values show both the original DESIGN.md reference and resolved value:
{colors.primary} (#2563eb).
Token findings: element context
Problem: Token findings like font-size value "24px" not in typography tokens didn't identify which element was affected.
Fix: Each finding now includes an element hint with tag, class, and text: <h1 class="policy-title" > "Refund Policy" — font-size value "28px" not in typography tokens.
Validation output: per-file grouping with deduped suggestions
Problem: Every finding repeated the same suggestion text and DESIGN.md path. With hundreds of findings, the output was dominated by repeated boilerplate.
Fixes:
Suggestions simplified — Token matchers return short suggestions (
Use a DESIGN.md spacing token) instead of listing all available tokens. The DESIGN.md placeholder is replaced with the actual path (src/pages/DESIGN.md).Per-file grouping — The stack-cli renderer groups findings by file within each validator section. The file path appears once, all findings are listed, then unique suggestions are printed once at the bottom.
Suggestion deduplication — Identical suggestions within a file group are shown only once.
Deviations
Breakpoint validation
Added breakpoints section to DESIGN.md frontmatter:
breakpoints:
mobile: 600px
tablet: 768px
desktop: 1024px
Two features:
Named labels — Media query findings use the breakpoint name instead of raw CSS:
[tablet]instead of[(max-width: 768px)].Non-standard breakpoint detection —
@media (max-width: 750px)flagged if not in the defined breakpoints. Onlymax-widthqueries are checked;prefers-reduced-motion,prefers-color-scheme, etc. are left alone.
Parser changes: breakpoints added to RawDesignMd, DesignTokens, and parseDesignMd(). Agent-kit guide updated with breakpoint documentation.
Performance: selector cache
matchesSelector was called once per element per CSS rule — 235K calls for a single page, taking 3.3s. Replaced with buildSelectorCache: run querySelectorAll once per unique selector, store results in a Map<string, Set<HTMLElement>>. Per-element lookup becomes Set.has() — O(1). Token validator went from 3,301ms to 11ms (~300x speedup).
Deviations
- The original design did not anticipate that
ctx.bodywould be only the<body>element (not the full document). The validator interface now carries extracted CSS directly rather than re-parsing it from the DOM. backgroundshorthand was not listed in the original design's color properties list. It requires special extraction logic rather than simple property-set membership.- CSS custom property resolution was not in the original design (listed as a limitation). Added because the test project uses CSS vars extensively and produces many false positives without resolution.
- The validation output format was redesigned for readability: per-file grouping, element identification, and suggestion deduplication were not in the original design.
- Breakpoint validation was not in the original design. Added to support named breakpoint labels and non-standard breakpoint detection.
Add-menu: per-token granularity
The generateDesignSystemReferences handler (runs during jay-stack agent-kit) was rewritten to produce individual add-menu items per design token instead of one item per category.
Before: One "Color palette" item listing all colors in its prompt. One "Typography presets" item, etc.
After: Each color, typography preset, spacing value, rounded value, breakpoint, animation, and component gets its own add-menu entry. This lets the AIditor present a browsable catalog of tokens.
Structure:
- Category — derived from DESIGN.md
namefield, or directory-based for page-level files (e.g., "Design System (products)") - Sub-category — one per token type: Colors, Typography, Spacing, Rounded, Breakpoints, Animations, Components
- Items — one per token. Title includes the value (e.g., "primary (#2563eb)"). Prompt is a targeted instruction for that specific token.
Multiple DESIGN.md files produce items under separate categories, with deduplication by ID.
Log Methodology Note
Note: These design logs are written primarily for AI agents as part of the Design Log methodology and made accessible here for human readers. The language and structure are optimized for machine consumption — expect precise, specification-style prose rather than narrative documentation.