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 plugin
  • packages/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 declarations
  • postcss-selector-parser — Parse selectors into AST nodes
  • @csstools/selector-specificity — Compute specificity from postcss-selector-parser AST nodes
  • node-html-parser — Already available in validation context; supports querySelectorAll for 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
  • !important declarations
  • Media query blocks (each breakpoint validated independently)

What this does NOT handle (acceptable limitations):

  • Inherited values from parent elements (e.g., color inheriting 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 transition or animation declaration 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-weight values across the page, warn if exceeding rules.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:

  1. Explains how DESIGN.md works — tokens, {references}, components section
  2. Shows how to use tokens in CSS (via custom properties or direct values)
  3. Lists the validation errors the designer will encounter and how to fix them
  4. 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)

  1. parse-design-md.ts — parse YAML frontmatter, resolve {path.to.token} references, DESIGN.md route-scoping resolution
  2. token-matcher.ts — match CSS values against token scales (color normalization, unit conversion)
  3. design-tokens validator — parse <style> blocks, validate values against tokens, support /* design-system: allow */ exceptions
  4. Register as Jay validation plugin

Phase 2: Cascade resolver

  1. css-cascade.ts — parse CSS with postcss, compute specificity with @csstools/selector-specificity, resolve cascade per element
  2. Support linked external CSS files
  3. Support inline style="" attributes with jay-design="allow" exceptions
  4. Update token validator to use resolved cascade values instead of raw declarations

Phase 3: Component + structural validation

  1. design-components validator — composite component spec matching
  2. design-structure validator — font weight count, primary button count (by ref+text identity)

Phase 4: Contrast + responsive

  1. design-contrast validator — WCAG AA contrast ratio on static color pairs
  2. Responsive breakpoint validation — per-media-query token conformance

Phase 5: Designer guide + AIditor add-menu

  1. agent-kit/designer/design-system.md — tokens, usage, validation errors, exceptions
  2. 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

  1. Plugin loads via standard plugin.yaml registration
  2. jay-stack validate runs design-system rules alongside a11y and seo validators
  3. DESIGN.md scoping resolves correctly (child route inherits, override replaces)
  4. Hardcoded colors in CSS produce warnings with closest token suggestion
  5. Spacing/rounded values outside the scale produce warnings
  6. /* design-system: allow */ and jay-design="allow" suppress findings
  7. Cascade resolver correctly determines winning value when multiple selectors match
  8. Component conformance validates composite specs from DESIGN.md components section
  9. Primary button count uses (ref, text) identity — duplicates don't count
  10. Contrast violations flagged on statically determinable color pairs
  11. 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:

  1. Validator interface — Add css?: string to JayHtmlValidationContext in compiler-shared. The jay-html parser already extracts all CSS (from <head> <style> blocks and linked <link> stylesheets) into parsed.css. Pass it through the context so validators don't need to re-discover CSS from the DOM.

  2. Validators — Use ctx.css when available instead of extractCssSources. Remove findDocumentRoot (the interim parent-traversal fix). Keep extractCssSources as 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:

  1. Add extractBackgroundColors(value): string[] to token-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.

  2. In validateElementStyles, add a separate background shorthand 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:

  1. Grouped mismatches — Multiple property mismatches for the same component+element are grouped into a single numbered finding instead of separate warnings.

  2. DESIGN.md path notation — Component findings use DESIGN.md components.btn-cta dotted path to disambiguate from Jay headless components.

  3. Element descriptions — Findings include tag name, CSS classes, and first 2-3 words of text content: <button class="btn btn-cta" > "shop now".

  4. 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:

  1. 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).

  2. 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.

  3. 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:

  1. Named labels — Media query findings use the breakpoint name instead of raw CSS: [tablet] instead of [(max-width: 768px)].

  2. Non-standard breakpoint detection@media (max-width: 750px) flagged if not in the defined breakpoints. Only max-width queries 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.body would 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.
  • background shorthand 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 name field, 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.