From 9500820870ba3b4cfa9990233930aff8b43a4ed5 Mon Sep 17 00:00:00 2001 From: Pascal Charbonneau Date: Fri, 15 May 2026 09:58:35 -0400 Subject: [PATCH 01/17] Add CSS style authoring contract plan --- .../repo.ecosystem.css_style_authoring.md | 71 +++++++++++ .spec/planning/css_style_authoring/README.md | 46 +++++++ ...s-authoring-surface-and-parser-boundary.md | 117 +++++++++++++++++ ...elector-matching-and-cascade-resolution.md | 115 +++++++++++++++++ ...translation-and-canonical-style-mapping.md | 118 ++++++++++++++++++ ...piler-iur-tooling-and-runtime-alignment.md | 117 +++++++++++++++++ .spec/specs/dsl_iur_symbiosis.spec.md | 7 ++ .spec/specs/unified-iur/theming.spec.md | 13 ++ .spec/specs/unified-ui/compiler.spec.md | 17 +++ .spec/specs/unified-ui/dsl.spec.md | 29 +++++ .spec/specs/unified-ui/theming.spec.md | 25 ++++ 11 files changed, 675 insertions(+) create mode 100644 .spec/decisions/architecture/repo.ecosystem.css_style_authoring.md create mode 100644 .spec/planning/css_style_authoring/README.md create mode 100644 .spec/planning/css_style_authoring/phase-01-css-authoring-surface-and-parser-boundary.md create mode 100644 .spec/planning/css_style_authoring/phase-02-selector-matching-and-cascade-resolution.md create mode 100644 .spec/planning/css_style_authoring/phase-03-declaration-translation-and-canonical-style-mapping.md create mode 100644 .spec/planning/css_style_authoring/phase-04-compiler-iur-tooling-and-runtime-alignment.md diff --git a/.spec/decisions/architecture/repo.ecosystem.css_style_authoring.md b/.spec/decisions/architecture/repo.ecosystem.css_style_authoring.md new file mode 100644 index 00000000..14c32913 --- /dev/null +++ b/.spec/decisions/architecture/repo.ecosystem.css_style_authoring.md @@ -0,0 +1,71 @@ +--- +id: repo.ecosystem.css_style_authoring +status: accepted +date: 2026-05-15 +affects: + - ecosystem.dsl_iur_symbiosis + - unified_ui.dsl + - unified_ui.theming + - unified_ui.compiler + - unified_iur.theming +--- + +# CSS Stylesheet Blocks Lower Into Canonical Style Data + +## Context + +The current styling and theming model is canonical: authors declare style and +theme intent in `unified_ui`, the compiler resolves that intent, and runtimes +consume renderer-independent `unified_iur` style and theme data. This keeps the +ecosystem portable across web, desktop, terminal, and other runtime targets. + +Authors also expect CSS as a familiar authoring language, especially for class, +state, selector, and declaration-based styling. The ecosystem can support that +authoring workflow without making browser CSS the canonical interchange +contract. The boundary needs to accept real CSS syntax, recover like CSS +parsers do, translate supported style meaning into the existing canonical style +model, and ignore unsupported concepts without losing deterministic diagnostics. + +## Decision + +1. `unified_ui` may expose CSS stylesheet blocks as an authored styling + convenience inside the DSL. +2. CSS stylesheet blocks are parsed through a CSS Syntax-compatible stylesheet + parser with stylesheet-level error recovery rather than through ad-hoc + string parsing. +3. Supported CSS selectors and declarations are lowered into canonical style + and theme data before canonical `unified_iur` output is emitted. +4. Raw CSS is not a new `unified_iur` interchange format. Runtimes continue to + consume canonical style and theme data, although renderers may realize that + canonical data through native CSS, classes, terminal attributes, desktop + drawing primitives, or other runtime-native mechanisms. +5. The initial selector model should match authored nodes through canonical + identity, portable classes, widget or component kinds, explicitly supported + structural selectors, and supported state pseudo-classes. +6. For supported rules, cascade resolution follows CSS specificity and source + order, then participates in the existing canonical style precedence where + theme defaults and referenced component styles are weaker than CSS-derived + rules, and explicit local style declarations remain strongest. +7. Unsupported at-rules, selectors, properties, values, units, functions, and + unsafe external-resource features are ignored with diagnostics. Ignoring an + unsupported CSS construct shall not make the whole CSS block invalid when + the CSS parser can recover and continue. +8. Accepting actual CSS syntax is not a promise of full browser CSS semantic + equivalence. Only CSS concepts with an explicit canonical style meaning are + translated into the ecosystem contract. + +## Consequences + +- CSS authoring becomes a front-end authoring convenience for canonical style + data, not a renderer-specific escape hatch. +- `unified_ui` needs parser, selector matching, cascade, declaration + translation, diagnostics, and inspection behavior for CSS stylesheet blocks. +- `unified_iur` needs to represent CSS-derived results as ordinary canonical + style and theme data, with optional renderer-independent provenance or + diagnostics metadata for tooling. +- Runtime libraries do not need to understand authored CSS blocks directly, but + they must continue to realize the canonical style data that results from CSS + lowering. +- The implementation plan should phase parser boundaries first, then selector + and cascade behavior, then declaration translation, and finally compiler, + IUR, tooling, and runtime alignment. diff --git a/.spec/planning/css_style_authoring/README.md b/.spec/planning/css_style_authoring/README.md new file mode 100644 index 00000000..46bd9e40 --- /dev/null +++ b/.spec/planning/css_style_authoring/README.md @@ -0,0 +1,46 @@ +# CSS Style Authoring Planning + +This directory contains the phased implementation plan for CSS stylesheet +authoring in `UnifiedUi`, canonical style lowering into `UnifiedIUR`, and +tooling/runtime alignment for CSS-derived canonical style data. + +The plan aligns to: +- [CSS Style Authoring ADR](../../decisions/architecture/repo.ecosystem.css_style_authoring.md) +- [DSL and IUR Symbiosis](../../specs/dsl_iur_symbiosis.spec.md) +- [UnifiedUi DSL](../../specs/unified-ui/dsl.spec.md) +- [Unified UI Theming](../../specs/unified-ui/theming.spec.md) +- [UnifiedUi Compiler](../../specs/unified-ui/compiler.spec.md) +- [UnifiedIUR Theming](../../specs/unified-iur/theming.spec.md) + +## Phase Files + +1. [Phase 1 - CSS Authoring Surface and Parser Boundary](./phase-01-css-authoring-surface-and-parser-boundary.md): implement the authored CSS block shape, parser adapter, source model, and recoverable syntax diagnostics. +2. [Phase 2 - Selector Matching and Cascade Resolution](./phase-02-selector-matching-and-cascade-resolution.md): implement the supported selector subset, authored-node matching, specificity, source order, and style precedence behavior. +3. [Phase 3 - Declaration Translation and Canonical Style Mapping](./phase-03-declaration-translation-and-canonical-style-mapping.md): map supported CSS declarations, values, units, shorthands, and state-scoped rules into canonical style concepts while ignoring unsupported constructs with diagnostics. +4. [Phase 4 - Compiler, IUR, Tooling, and Runtime Alignment](./phase-04-compiler-iur-tooling-and-runtime-alignment.md): integrate CSS lowering into compiler output, IUR representation, inspection/export tooling, examples, and runtime realization checks. + +## Numbering + +- Phases: `N` +- Sections: `N.M` +- Tasks: `N.M.K` +- Subtasks: `N.M.K.L` + +Every phase, section, task, and subtask uses Markdown checkboxes. Every phase, +section, and task starts with a short description paragraph. Each phase ends +with an integration-testing section. + +## Shared Assumptions and Defaults + +- CSS stylesheet blocks are an authored DSL convenience, not a raw CSS runtime + interchange format. +- CSS input is parsed through a CSS Syntax-compatible stylesheet parser with + standard recovery behavior. +- Accepting actual CSS syntax does not imply full browser CSS semantic + equivalence. +- Unsupported selectors, at-rules, declarations, values, units, functions, and + unsafe external-resource features are ignored with deterministic diagnostics. +- Supported CSS rules lower into canonical style and theme data before + `UnifiedIUR` output is emitted. +- Runtime packages remain native UI libraries and consume canonical style data + rather than authored CSS blocks. diff --git a/.spec/planning/css_style_authoring/phase-01-css-authoring-surface-and-parser-boundary.md b/.spec/planning/css_style_authoring/phase-01-css-authoring-surface-and-parser-boundary.md new file mode 100644 index 00000000..94da9578 --- /dev/null +++ b/.spec/planning/css_style_authoring/phase-01-css-authoring-surface-and-parser-boundary.md @@ -0,0 +1,117 @@ +# Phase 1 - CSS Authoring Surface and Parser Boundary + +Back to index: [README](./README.md) + +## Relevant Shared APIs / Interfaces + +- `UnifiedUi.Dsl` +- `UnifiedUi.Dsl.Node` +- `UnifiedUi.Dsl.Sections.Themes` +- `UnifiedUi.Style` +- `UnifiedUi.Compiler` +- `UnifiedUi.Tooling` +- CSS parser adapter module to be introduced under `UnifiedUi` + +## Relevant Assumptions / Defaults + +- The authored API may use a `css` stylesheet block or an equivalent named DSL + section, but the block contents remain CSS stylesheet text. +- Multiple authored CSS blocks are allowed and preserve deterministic source + order. +- Class selectors use portable authored class metadata; a class name is not + treated as proof that a runtime stylesheet already exists. +- Parser selection is an implementation detail behind a small internal adapter. +- Parser recovery diagnostics are surfaced without treating every recoverable + CSS syntax issue as a fatal DSL error. + +[ ] 1 Phase 1 - CSS Authoring Surface and Parser Boundary + Define the authored CSS stylesheet block, parser adapter, source-order model, + and recoverable diagnostics needed before selector matching or style lowering + can be implemented. + + [ ] 1.1 Section - Authored CSS Block Shape + Establish the DSL syntax and source metadata for authored CSS stylesheet + blocks while keeping existing style and theme authoring intact. + + [ ] 1.1.1 Task - Define CSS block placement and identity + Specify where CSS blocks may appear and how they are named, ordered, and + associated with authored modules or fragments. + + [ ] 1.1.1.1 Subtask - Define whether the primary authoring surface is a top-level `css` block, a theming-section entry, or both. + [ ] 1.1.1.2 Subtask - Define source names, optional block ids, and deterministic ordering for multiple CSS blocks in one authored module. + [ ] 1.1.1.3 Subtask - Define how CSS blocks compose with existing theme definitions, `style_refs`, local `style` values, and widget props. + [ ] 1.1.1.4 Subtask - Reject CSS block placement that would make style meaning depend on renderer-specific module loading. + + [ ] 1.1.2 Task - Define portable class and identity authoring + Clarify how CSS selectors can target authored nodes without treating + browser class attributes as the only canonical selector mechanism. + + [ ] 1.1.2.1 Subtask - Define the canonical source of selector identity for `#id` rules using authored stable node identity. + [ ] 1.1.2.2 Subtask - Define class metadata normalization for `.class` selectors, including whitespace handling and deterministic class ordering. + [ ] 1.1.2.3 Subtask - Document that authored `class` values are portable selector metadata and optional runtime hooks, not automatic stylesheet loading. + [ ] 1.1.2.4 Subtask - Preserve current explicit style and theme props while adding CSS block authoring as an additional style source. + + [ ] 1.2 Section - CSS Parser Adapter and Syntax Recovery + Introduce a parser boundary that accepts real CSS stylesheet text and + normalizes parser output into an internal shape used by later phases. + + [ ] 1.2.1 Task - Select and isolate the CSS parser strategy + Evaluate parser options and hide the selected implementation behind a + small adapter so the DSL and compiler do not depend on parser-specific + data structures. + + [ ] 1.2.1.1 Subtask - Evaluate available Elixir, Erlang, NIF, or port-based CSS parser options against CSS Syntax stylesheet parsing and recovery needs. + [ ] 1.2.1.2 Subtask - Define the parser adapter input and output contract, including source spans, rule order, declaration order, and recoverable error reporting. + [ ] 1.2.1.3 Subtask - Add a fallback parser-selection decision record if no dependency satisfies the required CSS Syntax-compatible behavior. + + [ ] 1.2.2 Task - Normalize parsed stylesheet rules + Convert parser output into a deterministic internal representation that + later phases can match, cascade, translate, and inspect. + + [ ] 1.2.2.1 Subtask - Normalize style rules, selector lists, declaration names, declaration values, importance flags, source spans, and source order. + [ ] 1.2.2.2 Subtask - Preserve recoverable parser diagnostics with enough source context for author-facing messages. + [ ] 1.2.2.3 Subtask - Normalize unsupported at-rules into ignored diagnostic entries rather than dropping them silently. + [ ] 1.2.2.4 Subtask - Ensure comments and insignificant whitespace do not affect deterministic parser output. + + [ ] 1.3 Section - Authoring Diagnostics and Inspection + Make CSS block behavior visible to authors before any canonical style + lowering changes runtime output. + + [ ] 1.3.1 Task - Add CSS authoring diagnostics + Define the diagnostic categories and severity rules for parse recovery + and ignored CSS constructs. + + [ ] 1.3.1.1 Subtask - Add diagnostics for parser recovery, malformed declaration values, ignored at-rules, ignored selectors, and ignored properties. + [ ] 1.3.1.2 Subtask - Distinguish fatal parser failures from recoverable CSS issues that can continue through style lowering. + [ ] 1.3.1.3 Subtask - Include source block id, selector text, declaration name, and source span where available. + [ ] 1.3.1.4 Subtask - Keep diagnostic ordering deterministic across equivalent authored modules. + + [ ] 1.3.2 Task - Expose parsed CSS inspection output + Let developers inspect CSS blocks and parser diagnostics without running + a renderer or relying on runtime stylesheet output. + + [ ] 1.3.2.1 Subtask - Extend inspection output to list authored CSS blocks, normalized rule count, declaration count, ignored construct count, and recoverable parse diagnostics. + [ ] 1.3.2.2 Subtask - Extend export output with deterministic CSS block metadata suitable for review diffs. + [ ] 1.3.2.3 Subtask - Add examples that show valid CSS, recoverable parser issues, and ignored unsupported at-rules. + + [ ] 1.4 Section - Phase 1 Integration Tests + Validate authored CSS block parsing, ordering, diagnostics, and inspection + before selector matching and canonical style lowering are introduced. + + [ ] 1.4.1 Task - CSS block authoring scenarios + Verify the DSL accepts valid CSS stylesheet blocks and preserves + deterministic source metadata. + + [ ] 1.4.1.1 Subtask - Verify a module with one CSS block parses valid selector and declaration syntax successfully. + [ ] 1.4.1.2 Subtask - Verify a module with multiple CSS blocks preserves deterministic block and rule source order. + [ ] 1.4.1.3 Subtask - Verify CSS block authoring composes with existing style, theme, and widget declarations without changing their public syntax. + [ ] 1.4.1.4 Subtask - Verify malformed block placement fails with an actionable DSL diagnostic. + + [ ] 1.4.2 Task - Parser recovery and inspection scenarios + Verify CSS parser recovery and inspection output are deterministic and + useful before lowering is implemented. + + [ ] 1.4.2.1 Subtask - Verify recoverable CSS syntax errors produce diagnostics while preserving later valid rules. + [ ] 1.4.2.2 Subtask - Verify unsupported at-rules are represented as ignored diagnostics with source context. + [ ] 1.4.2.3 Subtask - Verify comments and whitespace do not change normalized parser output. + [ ] 1.4.2.4 Subtask - Verify inspection and export output list CSS block metadata and diagnostics in deterministic order. diff --git a/.spec/planning/css_style_authoring/phase-02-selector-matching-and-cascade-resolution.md b/.spec/planning/css_style_authoring/phase-02-selector-matching-and-cascade-resolution.md new file mode 100644 index 00000000..7e4cd556 --- /dev/null +++ b/.spec/planning/css_style_authoring/phase-02-selector-matching-and-cascade-resolution.md @@ -0,0 +1,115 @@ +# Phase 2 - Selector Matching and Cascade Resolution + +Back to index: [README](./README.md) + +## Relevant Shared APIs / Interfaces + +- `UnifiedUi.Dsl.Node` +- `UnifiedUi.Compiler` +- `UnifiedUi.Style` +- CSS selector representation from Phase 1 +- CSS diagnostic representation from Phase 1 + +## Relevant Assumptions / Defaults + +- Supported selectors start with stable ids, portable classes, widget or + component kinds, explicitly supported structural selectors, and canonical + state pseudo-classes. +- Unsupported selectors are ignored with diagnostics rather than interpreted + approximately. +- Pseudo-elements, media queries, container queries, animation selectors, and + browser-only dynamic states are not canonical until explicitly represented. +- Cascade behavior is deterministic and reviewable across equivalent authored + modules. + +[ ] 2 Phase 2 - Selector Matching and Cascade Resolution + Implement the supported selector subset, authored-node matching, specificity, + source order, and canonical style precedence behavior for CSS-derived rules. + + [ ] 2.1 Section - Supported Selector Model + Define the exact selector subset that can participate in canonical style + lowering and the diagnostics used when selectors are ignored. + + [ ] 2.1.1 Task - Define simple selectors and combinations + Establish the portable selector forms that can be matched against the + authored DSL tree without requiring browser DOM semantics. + + [ ] 2.1.1.1 Subtask - Support `#id` selectors against authored stable node identity. + [ ] 2.1.1.2 Subtask - Support `.class` selectors against normalized portable class metadata. + [ ] 2.1.1.3 Subtask - Support widget and component kind selectors using canonical authored kind names. + [ ] 2.1.1.4 Subtask - Support comma-separated selector lists while preserving per-selector diagnostics and source order. + + [ ] 2.1.2 Task - Define structural and state selector boundaries + Add only the structural and pseudo-class behavior that has portable + canonical meaning across the ecosystem. + + [ ] 2.1.2.1 Subtask - Decide whether descendant and child combinators are supported in the initial implementation. + [ ] 2.1.2.2 Subtask - Map supported pseudo-classes such as focused, disabled, selected, and active-like canonical states to state-scoped style variants. + [ ] 2.1.2.3 Subtask - Ignore unsupported pseudo-classes and pseudo-elements with diagnostics. + [ ] 2.1.2.4 Subtask - Ignore selectors that depend on browser DOM structure unavailable in authored canonical nodes. + + [ ] 2.2 Section - Authored-Node Matching Engine + Implement deterministic selector matching against the authored DSL tree + before style data is lowered into canonical IUR nodes. + + [ ] 2.2.1 Task - Build canonical selector match inputs + Derive the node metadata needed for selector matching from authored + widgets, layouts, layers, classes, identity, and state declarations. + + [ ] 2.2.1.1 Subtask - Build a traversal index for authored node identity, parentage, component kind, classes, and authored state declarations. + [ ] 2.2.1.2 Subtask - Preserve node source metadata so selector-match diagnostics can point to authored targets. + [ ] 2.2.1.3 Subtask - Ensure generated or compiler-expanded nodes have deterministic selector-match identities when they are eligible for CSS-derived styling. + + [ ] 2.2.2 Task - Implement selector matching and ignored-selector diagnostics + Match supported selector forms exactly and reject unsupported forms + without guessing or silently applying broad rules. + + [ ] 2.2.2.1 Subtask - Match ids, classes, kind selectors, selector lists, and supported structural selectors against the traversal index. + [ ] 2.2.2.2 Subtask - Emit diagnostics for selectors that are syntactically valid CSS but unsupported by canonical selector semantics. + [ ] 2.2.2.3 Subtask - Emit diagnostics for supported selectors that match no authored nodes when configured to do so by tooling or validation mode. + [ ] 2.2.2.4 Subtask - Keep selector match ordering deterministic by node traversal order and CSS source order. + + [ ] 2.3 Section - Cascade, Specificity, and Style Source Precedence + Resolve competing CSS-derived declarations using CSS ordering rules and + then merge them into the existing canonical style precedence model. + + [ ] 2.3.1 Task - Implement specificity and CSS source order + Apply CSS-style cascade behavior for the supported selector and + declaration subset. + + [ ] 2.3.1.1 Subtask - Compute selector specificity for supported id, class, pseudo-class, and kind selectors. + [ ] 2.3.1.2 Subtask - Resolve conflicts by specificity and source order within the CSS-derived style source. + [ ] 2.3.1.3 Subtask - Decide how `!important` is represented, ignored, or diagnosed in the canonical style model. + [ ] 2.3.1.4 Subtask - Preserve deterministic conflict provenance for inspection output. + + [ ] 2.3.2 Task - Merge CSS-derived results with canonical style sources + Integrate CSS-derived styles with themes, component styles, `style_refs`, + local `style` values, and direct widget props. + + [ ] 2.3.2.1 Subtask - Implement precedence where theme defaults and referenced component styles are weaker than CSS-derived rules. + [ ] 2.3.2.2 Subtask - Preserve explicit local style declarations as stronger than CSS-derived rules. + [ ] 2.3.2.3 Subtask - Define how CSS-derived state-scoped rules merge with existing variant and state style declarations. + [ ] 2.3.2.4 Subtask - Emit diagnostics or provenance for overwritten CSS-derived declarations when inspection needs to explain final style values. + + [ ] 2.4 Section - Phase 2 Integration Tests + Validate selector matching, ignored-selector behavior, specificity, + source-order resolution, and source precedence before declaration + translation is expanded. + + [ ] 2.4.1 Task - Selector matching scenarios + Verify supported selectors target the expected authored nodes and + unsupported selectors are ignored deterministically. + + [ ] 2.4.1.1 Subtask - Verify `#id`, `.class`, kind, selector-list, and supported structural selectors match the expected authored nodes. + [ ] 2.4.1.2 Subtask - Verify supported state pseudo-classes produce state-scoped match output. + [ ] 2.4.1.3 Subtask - Verify unsupported pseudo-elements, browser-only selectors, and unsupported combinators are ignored with diagnostics. + [ ] 2.4.1.4 Subtask - Verify no-match selector diagnostics are deterministic when enabled. + + [ ] 2.4.2 Task - Cascade and precedence scenarios + Verify CSS-derived conflicts and canonical style-source conflicts resolve + predictably. + + [ ] 2.4.2.1 Subtask - Verify specificity outranks source order for supported selectors. + [ ] 2.4.2.2 Subtask - Verify later source order wins when specificity ties. + [ ] 2.4.2.3 Subtask - Verify CSS-derived rules outrank theme defaults and component style references. + [ ] 2.4.2.4 Subtask - Verify explicit local style declarations outrank CSS-derived rules. diff --git a/.spec/planning/css_style_authoring/phase-03-declaration-translation-and-canonical-style-mapping.md b/.spec/planning/css_style_authoring/phase-03-declaration-translation-and-canonical-style-mapping.md new file mode 100644 index 00000000..4fa50f44 --- /dev/null +++ b/.spec/planning/css_style_authoring/phase-03-declaration-translation-and-canonical-style-mapping.md @@ -0,0 +1,118 @@ +# Phase 3 - Declaration Translation and Canonical Style Mapping + +Back to index: [README](./README.md) + +## Relevant Shared APIs / Interfaces + +- `UnifiedUi.Style` +- `UnifiedIUR.Style` +- `UnifiedIUR.Theme` +- CSS declaration representation from Phase 1 +- CSS cascade result representation from Phase 2 + +## Relevant Assumptions / Defaults + +- Declaration support is driven by explicit canonical style fields, not by + browser property coverage. +- Unsupported declarations are ignored with diagnostics and do not become raw + runtime CSS. +- Values normalize into canonical colors, text style attributes, spacing, + sizing, layout hints, borders, radii, opacity, and state-scoped variants only + where those concepts already have or receive canonical representation. +- External resources such as `url()` are ignored until a safe canonical asset + contract exists. + +[ ] 3 Phase 3 - Declaration Translation and Canonical Style Mapping + Translate supported CSS declarations, values, units, shorthands, and + state-scoped rules into canonical style concepts while preserving diagnostics + for ignored or unsafe CSS features. + + [ ] 3.1 Section - Property Coverage and Canonical Mapping + Define a reviewed property map from CSS declaration names to canonical style + fields so support expands deliberately and remains renderer-independent. + + [ ] 3.1.1 Task - Map foundational visual properties + Implement mappings for common color, text, spacing, border, radius, and + opacity properties that correspond to canonical style concepts. + + [ ] 3.1.1.1 Subtask - Map `color`, `background-color`, and supported border colors to canonical named, indexed, RGB, or semantic color values. + [ ] 3.1.1.2 Subtask - Map supported font weight, font style, text decoration, and opacity declarations to canonical style attributes. + [ ] 3.1.1.3 Subtask - Map supported spacing, gap, padding, margin, border width, and radius declarations where canonical layout and style models can represent them. + [ ] 3.1.1.4 Subtask - Ignore unsupported visual properties with declaration-level diagnostics. + + [ ] 3.1.2 Task - Map layout and display-adjacent properties conservatively + Translate only layout-related CSS that has a clear canonical equivalent + and leave browser layout models outside the initial canonical contract. + + [ ] 3.1.2.1 Subtask - Identify which dimensions, alignment, overflow, and display declarations map to existing canonical layout or display-system fields. + [ ] 3.1.2.2 Subtask - Ignore CSS layout systems without canonical representation, such as unrestricted flex, grid, container-query, and position behavior. + [ ] 3.1.2.3 Subtask - Add diagnostics that point authors to canonical layout DSL constructs when CSS declarations are not the right authoring surface. + [ ] 3.1.2.4 Subtask - Keep translated layout-adjacent declarations deterministic across renderer targets. + + [ ] 3.2 Section - Value Normalization and Shorthand Expansion + Normalize CSS values into canonical value types and expand supported + shorthands without carrying parser-specific tokens into later compiler + passes. + + [ ] 3.2.1 Task - Normalize colors, units, keywords, and functions + Translate supported value forms into canonical values and ignore values + that cannot be represented portably. + + [ ] 3.2.1.1 Subtask - Normalize CSS named colors, hex colors, `rgb()`, `rgba()`, and supported semantic token references into canonical color values. + [ ] 3.2.1.2 Subtask - Normalize supported lengths, percentages, zero values, and unitless values into canonical unit structures. + [ ] 3.2.1.3 Subtask - Normalize supported keywords such as `none`, `solid`, `bold`, `italic`, and canonical state keywords. + [ ] 3.2.1.4 Subtask - Ignore unsupported functions, calculations, variables, and external-resource values with diagnostics. + + [ ] 3.2.2 Task - Expand supported shorthands + Support high-value shorthand declarations only when expansion is + unambiguous and every expanded field has canonical meaning. + + [ ] 3.2.2.1 Subtask - Expand supported box shorthands such as padding, margin, border width, border color, and border radius. + [ ] 3.2.2.2 Subtask - Expand supported text decoration and font shorthands only where canonical style fields can represent the result. + [ ] 3.2.2.3 Subtask - Emit diagnostics for partially supported shorthands rather than applying incomplete or misleading style output. + [ ] 3.2.2.4 Subtask - Ensure longhand declarations override shorthand-derived values according to cascade result ordering. + + [ ] 3.3 Section - State, Variant, Safety, and Unsupported Feature Handling + Preserve portable CSS-derived style meaning while preventing unsupported or + unsafe browser-specific features from entering the canonical contract. + + [ ] 3.3.1 Task - Lower state-scoped declarations + Convert rules matched through supported pseudo-classes into canonical + state or variant style data. + + [ ] 3.3.1.1 Subtask - Map supported state pseudo-class matches into canonical focused, disabled, selected, active-like, or emphasis-oriented state styles. + [ ] 3.3.1.2 Subtask - Define how state-scoped CSS-derived styles merge with existing component variants and state styles. + [ ] 3.3.1.3 Subtask - Preserve state-style provenance for inspection output. + [ ] 3.3.1.4 Subtask - Ignore state selectors that have no canonical state representation with diagnostics. + + [ ] 3.3.2 Task - Enforce unsupported and unsafe feature policy + Keep CSS lowering loss-tolerant and safe by ignoring concepts that have no + canonical style meaning or safe asset boundary. + + [ ] 3.3.2.1 Subtask - Ignore `@import`, external `url()` resources, remote fonts, animations, transitions, custom property definitions, and browser-only effects unless a later canonical contract supports them. + [ ] 3.3.2.2 Subtask - Emit diagnostics that distinguish unsupported property, unsupported value, unsupported unit, unsafe external resource, and ignored at-rule cases. + [ ] 3.3.2.3 Subtask - Ensure ignored declarations do not affect cascade resolution for supported declarations. + [ ] 3.3.2.4 Subtask - Keep unsupported-feature diagnostics stable enough for tests and review tooling. + + [ ] 3.4 Section - Phase 3 Integration Tests + Validate declaration translation, value normalization, shorthand behavior, + state-style lowering, and unsupported-feature diagnostics against canonical + style output. + + [ ] 3.4.1 Task - Supported declaration translation scenarios + Verify common CSS declarations produce the expected canonical style and + theme data. + + [ ] 3.4.1.1 Subtask - Verify colors, text attributes, opacity, spacing, border, and radius declarations lower into canonical style fields. + [ ] 3.4.1.2 Subtask - Verify supported values normalize consistently across equivalent CSS spellings. + [ ] 3.4.1.3 Subtask - Verify supported shorthands expand deterministically and respect longhand override behavior. + [ ] 3.4.1.4 Subtask - Verify state pseudo-class rules lower into canonical state-scoped style data. + + [ ] 3.4.2 Task - Unsupported and unsafe declaration scenarios + Verify unsupported CSS remains loss-tolerant and visible through + diagnostics without becoming raw runtime CSS. + + [ ] 3.4.2.1 Subtask - Verify unsupported properties and unsupported values are ignored with declaration-level diagnostics. + [ ] 3.4.2.2 Subtask - Verify unsafe external-resource values such as `url()` are ignored with safety diagnostics. + [ ] 3.4.2.3 Subtask - Verify unsupported shorthands do not partially mutate canonical style output. + [ ] 3.4.2.4 Subtask - Verify ignored declarations do not affect supported declaration cascade results. diff --git a/.spec/planning/css_style_authoring/phase-04-compiler-iur-tooling-and-runtime-alignment.md b/.spec/planning/css_style_authoring/phase-04-compiler-iur-tooling-and-runtime-alignment.md new file mode 100644 index 00000000..81893190 --- /dev/null +++ b/.spec/planning/css_style_authoring/phase-04-compiler-iur-tooling-and-runtime-alignment.md @@ -0,0 +1,117 @@ +# Phase 4 - Compiler, IUR, Tooling, and Runtime Alignment + +Back to index: [README](./README.md) + +## Relevant Shared APIs / Interfaces + +- `UnifiedUi.Compiler` +- `UnifiedUi.Tooling` +- `UnifiedIUR.Element` +- `UnifiedIUR.Style` +- `UnifiedIUR.Theme` +- `UnifiedIUR.Inspect` +- `LiveUi.Style` +- Runtime IUR renderers + +## Relevant Assumptions / Defaults + +- `UnifiedIUR` receives canonical style and theme data, not raw authored CSS. +- CSS-derived provenance and diagnostics are renderer-independent tooling data + and may be omitted from runtime rendering when not needed. +- Runtime packages continue to own native style realization and may use CSS, + classes, terminal attributes, desktop drawing primitives, or other native + mechanisms to realize canonical style data. +- Examples should demonstrate CSS authoring and canonical output without + implying full browser CSS coverage. + +[ ] 4 Phase 4 - Compiler, IUR, Tooling, and Runtime Alignment + Integrate CSS-derived styles into deterministic compiler output, canonical + IUR representation, author tooling, examples, and runtime realization checks. + + [ ] 4.1 Section - Compiler Pass Integration and Deterministic Output + Place CSS parsing, matching, cascade, and declaration lowering into the + compiler pipeline without destabilizing existing style and theme behavior. + + [ ] 4.1.1 Task - Integrate CSS lowering into the compiler pipeline + Choose the compiler pass boundaries and data flow for CSS-derived style + output. + + [ ] 4.1.1.1 Subtask - Insert CSS parser, selector matching, cascade, and declaration translation before final canonical IUR style attachment emission. + [ ] 4.1.1.2 Subtask - Ensure CSS-derived style data participates in the documented style precedence model. + [ ] 4.1.1.3 Subtask - Preserve deterministic output for equivalent authored modules, including generated node identities and source-order ties. + [ ] 4.1.1.4 Subtask - Keep renderer-specific CSS output out of the `unified_ui` compiler. + + [ ] 4.1.2 Task - Preserve existing style and theme compatibility + Verify CSS authoring is additive and does not break existing explicit + style, theme, variant, and class behavior. + + [ ] 4.1.2.1 Subtask - Maintain current theme defaults, component variants, style references, local style values, and direct widget prop behavior. + [ ] 4.1.2.2 Subtask - Ensure current authored `class` behavior remains available for runtime hooks and selector metadata. + [ ] 4.1.2.3 Subtask - Add migration guidance that positions CSS blocks as optional authoring input rather than a replacement for canonical style APIs. + [ ] 4.1.2.4 Subtask - Verify existing compiler fixtures and examples remain stable unless they intentionally add CSS blocks. + + [ ] 4.2 Section - UnifiedIUR Representation and Provenance + Represent CSS-derived results as canonical style and theme data while + preserving optional provenance for debugging and inspection. + + [ ] 4.2.1 Task - Add or confirm canonical style attachment support + Ensure IUR elements and theme structures can carry the effective style + values produced by CSS lowering. + + [ ] 4.2.1.1 Subtask - Confirm existing IUR style and theme structures can represent the supported CSS declaration map. + [ ] 4.2.1.2 Subtask - Add canonical style fields only when the style concept is portable and needed beyond CSS authoring. + [ ] 4.2.1.3 Subtask - Avoid adding raw CSS text as a required IUR field or renderer input. + [ ] 4.2.1.4 Subtask - Preserve renderer-independent diagnostics or provenance metadata only where tooling needs to explain CSS-derived output. + + [ ] 4.2.2 Task - Align IUR normalization and inspection + Make CSS-derived style data deterministic, inspectable, and safe for + runtime renderer consumption. + + [ ] 4.2.2.1 Subtask - Normalize CSS-derived style maps with the same ordering and value normalization rules as hand-authored canonical styles. + [ ] 4.2.2.2 Subtask - Expose provenance that can identify source block, selector, declaration, and cascade reason for selected style values. + [ ] 4.2.2.3 Subtask - Ensure IUR inspection shows canonical style data as canonical data, not as browser CSS. + [ ] 4.2.2.4 Subtask - Ensure IUR validation rejects raw CSS interchange fields outside optional tooling metadata. + + [ ] 4.3 Section - Tooling, Examples, Documentation, and Runtime Alignment + Complete the author-facing and runtime-facing surfaces needed to make CSS + style authoring understandable and portable. + + [ ] 4.3.1 Task - Update tooling and examples + Document and demonstrate CSS authoring, canonical lowering, diagnostics, + and caveats through maintained examples and inspect/export tools. + + [ ] 4.3.1.1 Subtask - Add examples showing CSS class, id, kind, and state selectors lowering into canonical style data. + [ ] 4.3.1.2 Subtask - Add examples showing unsupported CSS ignored with diagnostics and without raw CSS passthrough. + [ ] 4.3.1.3 Subtask - Update `mix unified_ui.inspect`, `mix unified_ui.export`, and validation output to surface CSS block summaries and lowering diagnostics. + [ ] 4.3.1.4 Subtask - Update documentation to state the caveat that accepted CSS syntax does not mean full browser CSS semantic equivalence. + + [ ] 4.3.2 Task - Verify runtime renderer realization boundaries + Check that runtimes consume the resulting canonical style data through + their existing native rendering boundaries. + + [ ] 4.3.2.1 Subtask - Verify `live_ui` realizes CSS-derived canonical style values through its native style and class mechanisms without requiring authored CSS blocks at render time. + [ ] 4.3.2.2 Subtask - Define parity checks for `elm_ui`, `desktop_ui`, and `terminal_ui` based on canonical style values rather than raw CSS. + [ ] 4.3.2.3 Subtask - Document terminal and desktop degradation expectations for style concepts that cannot be visually identical to browser CSS. + [ ] 4.3.2.4 Subtask - Ensure runtime-specific stylesheet loading remains a runtime concern and is not required by canonical CSS block lowering. + + [ ] 4.4 Section - Phase 4 Integration Tests + Validate end-to-end CSS authoring from DSL input through canonical IUR and + runtime-facing realization checks. + + [ ] 4.4.1 Task - End-to-end compiler and IUR scenarios + Verify CSS stylesheet blocks produce deterministic canonical output and + diagnostics through the full compiler path. + + [ ] 4.4.1.1 Subtask - Verify a CSS-authored module compiles into canonical IUR style attachments with no raw CSS runtime field. + [ ] 4.4.1.2 Subtask - Verify CSS-derived style values merge correctly with theme defaults, style references, local styles, variants, and direct props. + [ ] 4.4.1.3 Subtask - Verify inspection explains source block, selector, declaration, cascade reason, and ignored constructs for representative CSS-derived values. + [ ] 4.4.1.4 Subtask - Verify canonical IUR validation accepts CSS-derived canonical style data and rejects required raw CSS interchange data. + + [ ] 4.4.2 Task - Tooling and runtime boundary scenarios + Verify author tooling and runtime renderers observe the canonical + boundary established by the ADR and specs. + + [ ] 4.4.2.1 Subtask - Verify inspect/export/validate commands report CSS authoring summaries and diagnostics deterministically. + [ ] 4.4.2.2 Subtask - Verify maintained examples demonstrate supported selectors, supported declarations, ignored unsupported CSS, and the semantic-equivalence caveat. + [ ] 4.4.2.3 Subtask - Verify `live_ui` renders representative CSS-derived canonical style output through native style realization without needing the authored CSS block. + [ ] 4.4.2.4 Subtask - Verify cross-runtime parity checks compare canonical style meaning and documented degradation rather than raw CSS output. diff --git a/.spec/specs/dsl_iur_symbiosis.spec.md b/.spec/specs/dsl_iur_symbiosis.spec.md index 9658b18d..4d7b575e 100644 --- a/.spec/specs/dsl_iur_symbiosis.spec.md +++ b/.spec/specs/dsl_iur_symbiosis.spec.md @@ -13,6 +13,7 @@ surface: - .spec/specs/dsl_iur_symbiosis.spec.md decisions: - repo.ecosystem.contract_model + - repo.ecosystem.css_style_authoring ``` ## Requirements @@ -47,6 +48,11 @@ decisions: statement: Introducing or changing canonical DSL surface that affects rendering semantics shall be accompanied by the corresponding `unified_iur` representation change, and vice versa when the IUR addition is intended for canonical DSL authoring. priority: must stability: stable + +- id: ecosystem.dsl_iur_symbiosis.css_blocks_lower_to_canonical_style + statement: CSS stylesheet blocks authored in `unified_ui` shall lower into canonical `unified_iur` style and theme data, diagnostics, or both, and shall not establish raw CSS as an alternate canonical interchange contract. + priority: must + stability: stable ``` ## Exceptions @@ -73,4 +79,5 @@ decisions: - ecosystem.dsl_iur_symbiosis.layering_and_theming_stay_in_dsl - ecosystem.dsl_iur_symbiosis.dsl_compiles_to_iur - ecosystem.dsl_iur_symbiosis.bilateral_change_rule + - ecosystem.dsl_iur_symbiosis.css_blocks_lower_to_canonical_style ``` diff --git a/.spec/specs/unified-iur/theming.spec.md b/.spec/specs/unified-iur/theming.spec.md index 220e20f7..eb93b0c2 100644 --- a/.spec/specs/unified-iur/theming.spec.md +++ b/.spec/specs/unified-iur/theming.spec.md @@ -19,6 +19,7 @@ surface: - .spec/specs/unified-ui/theming.spec.md decisions: - repo.ecosystem.contract_model + - repo.ecosystem.css_style_authoring ``` ## Requirements @@ -53,6 +54,16 @@ decisions: statement: `unified_iur` shall represent theme-level defaults together with local style inheritance, merging, and override behavior so renderers can reconstruct effective widget styling. priority: must stability: stable + +- id: unified_iur.theming.css_derived_style_representation + statement: `unified_iur` shall represent style results that originated from authored CSS stylesheet blocks as ordinary canonical style and theme data, optionally retaining renderer-independent provenance or diagnostics metadata for tooling. + priority: must + stability: stable + +- id: unified_iur.theming.css_not_raw_interchange + statement: `unified_iur` shall not require runtime libraries to consume raw authored CSS, and unsupported or renderer-specific CSS constructs shall not become canonical styling obligations unless they have explicit canonical representations. + priority: must + stability: stable ``` ## Verification @@ -67,4 +78,6 @@ decisions: - unified_iur.theming.semantic_roles - unified_iur.theming.component_variants - unified_iur.theming.inheritance_and_overrides + - unified_iur.theming.css_derived_style_representation + - unified_iur.theming.css_not_raw_interchange ``` diff --git a/.spec/specs/unified-ui/compiler.spec.md b/.spec/specs/unified-ui/compiler.spec.md index 1bf8f949..768537b2 100644 --- a/.spec/specs/unified-ui/compiler.spec.md +++ b/.spec/specs/unified-ui/compiler.spec.md @@ -23,6 +23,7 @@ decisions: - repo.ecosystem.contract_model - repo.ecosystem.canonical_navigation_boundary - repo.ecosystem.canonical_widget_component_expansion + - repo.ecosystem.css_style_authoring ``` ## Requirements @@ -77,6 +78,16 @@ decisions: statement: The compiler shall lower expanded widget-component selection, row activation, step navigation, submit, change, send, panel dismiss, and inline action intents into renderer-independent `UnifiedIUR.Interaction` descriptors with portable payload mappings. priority: must stability: stable + +- id: unified_ui.compiler.css_stylesheet_lowering + statement: The compiler shall lower supported CSS stylesheet rules into deterministic canonical style and theme attachments on affected canonical IUR nodes before emitting `unified_iur` output. + priority: must + stability: stable + +- id: unified_ui.compiler.css_lowering_diagnostics + statement: The compiler and inspection surface shall expose deterministic diagnostics or provenance for CSS parse recovery, unsupported CSS constructs, selector matching, cascade resolution, and declarations ignored during canonical style lowering. + priority: must + stability: stable ``` ## Scenarios @@ -94,6 +105,8 @@ decisions: - unified_ui.compiler.navigation_modal_stack_lowering - unified_ui.compiler.widget_component_lowering - unified_ui.compiler.widget_component_interaction_lowering + - unified_ui.compiler.css_stylesheet_lowering + - unified_ui.compiler.css_lowering_diagnostics given: - A developer authors a screen module with widgets, layout, style, theme, and interaction declarations when: @@ -113,6 +126,8 @@ decisions: - unified_ui.compiler.navigation_modal_stack_lowering - unified_ui.compiler.widget_component_lowering - unified_ui.compiler.widget_component_interaction_lowering + - unified_ui.compiler.css_stylesheet_lowering + - unified_ui.compiler.css_lowering_diagnostics given: - A developer needs to understand what canonical output a DSL module produces when: @@ -154,6 +169,8 @@ decisions: - unified_ui.compiler.navigation_modal_stack_lowering - unified_ui.compiler.widget_component_lowering - unified_ui.compiler.widget_component_interaction_lowering + - unified_ui.compiler.css_stylesheet_lowering + - unified_ui.compiler.css_lowering_diagnostics - unified_ui.compiler.compile_screen_to_iur - unified_ui.compiler.inspect_compiled_artifact - unified_ui.compiler.compile_stacked_modal_navigation diff --git a/.spec/specs/unified-ui/dsl.spec.md b/.spec/specs/unified-ui/dsl.spec.md index 3270e568..424e543b 100644 --- a/.spec/specs/unified-ui/dsl.spec.md +++ b/.spec/specs/unified-ui/dsl.spec.md @@ -21,6 +21,7 @@ surface: - .spec/specs/unified-ui/dsl.spec.md decisions: - repo.ecosystem.contract_model + - repo.ecosystem.css_style_authoring ``` ## Requirements @@ -55,6 +56,16 @@ decisions: statement: The DSL shall be extensible in a way that allows new canonical widgets, layouts, style attributes, and interaction descriptors to be added without changing the authored module model for existing users. priority: must stability: stable + +- id: unified_ui.dsl.css_stylesheet_blocks + statement: The DSL shall support authored CSS stylesheet blocks as a styling and theming authoring surface that accepts CSS stylesheet text for canonical style lowering. + priority: must + stability: stable + +- id: unified_ui.dsl.css_authoring_diagnostics + statement: DSL validation shall report CSS parse recovery, ignored unsupported selectors, ignored unsupported at-rules, and ignored unsupported declarations without requiring recoverable CSS authoring issues to invalidate the entire authored module. + priority: must + stability: stable ``` ## Scenarios @@ -89,6 +100,21 @@ decisions: - The module is compiled by the package DSL then: - The authoring surface rejects that declaration because canonical interaction meaning must stay renderer-independent + +- id: unified_ui.dsl.author_css_stylesheet_block + covers: + - unified_ui.dsl.spark_style_authoring_surface + - unified_ui.dsl.styling_and_theming + - unified_ui.dsl.compile_time_validation + - unified_ui.dsl.authoring_extensibility + - unified_ui.dsl.css_stylesheet_blocks + - unified_ui.dsl.css_authoring_diagnostics + given: + - A developer wants to style authored widgets with familiar CSS selector and declaration syntax + when: + - The developer authors a CSS stylesheet block inside a `UnifiedUi` DSL module + then: + - The DSL accepts the stylesheet as authoring input and reports any recoverable unsupported CSS concepts as diagnostics for canonical style lowering ``` ## Verification @@ -103,6 +129,9 @@ decisions: - unified_ui.dsl.interaction_binding - unified_ui.dsl.compile_time_validation - unified_ui.dsl.authoring_extensibility + - unified_ui.dsl.css_stylesheet_blocks + - unified_ui.dsl.css_authoring_diagnostics - unified_ui.dsl.author_single_screen - unified_ui.dsl.reject_renderer_specific_callbacks + - unified_ui.dsl.author_css_stylesheet_block ``` diff --git a/.spec/specs/unified-ui/theming.spec.md b/.spec/specs/unified-ui/theming.spec.md index 87dc010a..e2243fdf 100644 --- a/.spec/specs/unified-ui/theming.spec.md +++ b/.spec/specs/unified-ui/theming.spec.md @@ -19,6 +19,7 @@ surface: - .spec/specs/unified-iur/theming.spec.md decisions: - repo.ecosystem.contract_model + - repo.ecosystem.css_style_authoring ``` ## Requirements @@ -53,6 +54,26 @@ decisions: statement: The canonical `unified_ui` DSL shall support theme-level defaults together with local style inheritance, merging, and override behavior so authored widgets can refine shared theme values. priority: must stability: stable + +- id: unified_ui.theming.css_block_style_authoring + statement: The canonical `unified_ui` DSL shall support CSS stylesheet blocks as a style-authoring convenience that translates supported CSS declarations into canonical style values, theme component styles, and state-scoped variants. + priority: must + stability: stable + +- id: unified_ui.theming.css_selector_matching + statement: CSS stylesheet rules shall match authored nodes only through supported canonical selectors such as stable ids, portable classes, widget or component kinds, explicitly supported structural selectors, and supported state pseudo-classes. + priority: must + stability: stable + +- id: unified_ui.theming.css_cascade_precedence + statement: CSS-derived style resolution shall honor specificity and source order for supported rules, then merge with existing theme and style precedence so explicit local style declarations outrank CSS-derived values. + priority: must + stability: stable + +- id: unified_ui.theming.css_authoring_caveat + statement: Accepting CSS stylesheet text shall not imply full browser CSS semantic equivalence; unsupported selectors, at-rules, properties, values, units, functions, and unsafe external-resource features shall be ignored with diagnostics instead of being emitted as raw runtime CSS. + priority: must + stability: stable ``` ## Verification @@ -67,4 +88,8 @@ decisions: - unified_ui.theming.semantic_roles - unified_ui.theming.component_variants - unified_ui.theming.inheritance_and_overrides + - unified_ui.theming.css_block_style_authoring + - unified_ui.theming.css_selector_matching + - unified_ui.theming.css_cascade_precedence + - unified_ui.theming.css_authoring_caveat ``` From 6aa063fe71113456eaf939a57c137218f2f3e746 Mon Sep 17 00:00:00 2001 From: Pascal Charbonneau Date: Fri, 15 May 2026 10:03:24 -0400 Subject: [PATCH 02/17] Implement CSS authoring block surface --- ...s-authoring-surface-and-parser-boundary.md | 22 +++--- packages/unified-ui/lib/unified_ui/css.ex | 66 +++++++++++++++++ .../lib/unified_ui/css/stylesheet.ex | 65 +++++++++++++++++ .../lib/unified_ui/dsl/entities/theme.ex | 18 ++++- .../lib/unified_ui/dsl/entity_schema.ex | 2 + .../unified-ui/lib/unified_ui/dsl/node.ex | 6 ++ packages/unified-ui/lib/unified_ui/info.ex | 2 + .../test/unified_ui/css_authoring_test.exs | 72 +++++++++++++++++++ 8 files changed, 241 insertions(+), 12 deletions(-) create mode 100644 packages/unified-ui/lib/unified_ui/css.ex create mode 100644 packages/unified-ui/lib/unified_ui/css/stylesheet.ex create mode 100644 packages/unified-ui/test/unified_ui/css_authoring_test.exs diff --git a/.spec/planning/css_style_authoring/phase-01-css-authoring-surface-and-parser-boundary.md b/.spec/planning/css_style_authoring/phase-01-css-authoring-surface-and-parser-boundary.md index 94da9578..7ffcbf85 100644 --- a/.spec/planning/css_style_authoring/phase-01-css-authoring-surface-and-parser-boundary.md +++ b/.spec/planning/css_style_authoring/phase-01-css-authoring-surface-and-parser-boundary.md @@ -29,27 +29,27 @@ Back to index: [README](./README.md) and recoverable diagnostics needed before selector matching or style lowering can be implemented. - [ ] 1.1 Section - Authored CSS Block Shape + [x] 1.1 Section - Authored CSS Block Shape Establish the DSL syntax and source metadata for authored CSS stylesheet blocks while keeping existing style and theme authoring intact. - [ ] 1.1.1 Task - Define CSS block placement and identity + [x] 1.1.1 Task - Define CSS block placement and identity Specify where CSS blocks may appear and how they are named, ordered, and associated with authored modules or fragments. - [ ] 1.1.1.1 Subtask - Define whether the primary authoring surface is a top-level `css` block, a theming-section entry, or both. - [ ] 1.1.1.2 Subtask - Define source names, optional block ids, and deterministic ordering for multiple CSS blocks in one authored module. - [ ] 1.1.1.3 Subtask - Define how CSS blocks compose with existing theme definitions, `style_refs`, local `style` values, and widget props. - [ ] 1.1.1.4 Subtask - Reject CSS block placement that would make style meaning depend on renderer-specific module loading. + [x] 1.1.1.1 Subtask - Define whether the primary authoring surface is a top-level `css` block, a theming-section entry, or both. + [x] 1.1.1.2 Subtask - Define source names, optional block ids, and deterministic ordering for multiple CSS blocks in one authored module. + [x] 1.1.1.3 Subtask - Define how CSS blocks compose with existing theme definitions, `style_refs`, local `style` values, and widget props. + [x] 1.1.1.4 Subtask - Reject CSS block placement that would make style meaning depend on renderer-specific module loading. - [ ] 1.1.2 Task - Define portable class and identity authoring + [x] 1.1.2 Task - Define portable class and identity authoring Clarify how CSS selectors can target authored nodes without treating browser class attributes as the only canonical selector mechanism. - [ ] 1.1.2.1 Subtask - Define the canonical source of selector identity for `#id` rules using authored stable node identity. - [ ] 1.1.2.2 Subtask - Define class metadata normalization for `.class` selectors, including whitespace handling and deterministic class ordering. - [ ] 1.1.2.3 Subtask - Document that authored `class` values are portable selector metadata and optional runtime hooks, not automatic stylesheet loading. - [ ] 1.1.2.4 Subtask - Preserve current explicit style and theme props while adding CSS block authoring as an additional style source. + [x] 1.1.2.1 Subtask - Define the canonical source of selector identity for `#id` rules using authored stable node identity. + [x] 1.1.2.2 Subtask - Define class metadata normalization for `.class` selectors, including whitespace handling and deterministic class ordering. + [x] 1.1.2.3 Subtask - Document that authored `class` values are portable selector metadata and optional runtime hooks, not automatic stylesheet loading. + [x] 1.1.2.4 Subtask - Preserve current explicit style and theme props while adding CSS block authoring as an additional style source. [ ] 1.2 Section - CSS Parser Adapter and Syntax Recovery Introduce a parser boundary that accepts real CSS stylesheet text and diff --git a/packages/unified-ui/lib/unified_ui/css.ex b/packages/unified-ui/lib/unified_ui/css.ex new file mode 100644 index 00000000..4a1ae682 --- /dev/null +++ b/packages/unified-ui/lib/unified_ui/css.ex @@ -0,0 +1,66 @@ +defmodule UnifiedUi.Css do + @moduledoc """ + Authored CSS stylesheet block helpers for `UnifiedUi`. + + CSS blocks are an authoring input. Later compiler passes parse and lower + supported CSS meaning into canonical style data. + """ + + alias Spark.Dsl.Extension + alias UnifiedUi.Css.Stylesheet + + @spec stylesheets(module()) :: [Stylesheet.t()] + def stylesheets(module) when is_atom(module) do + module + |> Extension.get_entities([:themes]) + |> Enum.filter(&match?(%Stylesheet{}, &1)) + |> Enum.with_index() + |> Enum.map(fn {stylesheet, order} -> + stylesheet + |> Stylesheet.new() + |> Map.put(:order, order) + end) + end + + @spec module_summary(module()) :: map() + def module_summary(module) when is_atom(module) do + stylesheets = stylesheets(module) + + %{ + count: length(stylesheets), + blocks: Enum.map(stylesheets, &Stylesheet.summary/1) + } + end + + @spec normalize_classes(String.t() | nil, [String.t()] | nil) :: [String.t()] + def normalize_classes(class, classes \\ []) + + def normalize_classes(class, classes) do + [split_class_string(class) | List.wrap(classes)] + |> List.flatten() + |> Enum.map(&normalize_class/1) + |> Enum.reject(&(&1 in [nil, ""])) + |> Enum.uniq() + end + + @spec class_string(String.t() | nil, [String.t()] | nil) :: String.t() | nil + def class_string(class, classes \\ []) do + case normalize_classes(class, classes) do + [] -> nil + normalized -> Enum.join(normalized, " ") + end + end + + defp split_class_string(nil), do: [] + + defp split_class_string(class) when is_binary(class) do + class + |> String.split(~r/\s+/, trim: true) + end + + defp split_class_string(class), do: [class] + + defp normalize_class(class) when is_atom(class), do: Atom.to_string(class) + defp normalize_class(class) when is_binary(class), do: String.trim(class) + defp normalize_class(class), do: class |> to_string() |> String.trim() +end diff --git a/packages/unified-ui/lib/unified_ui/css/stylesheet.ex b/packages/unified-ui/lib/unified_ui/css/stylesheet.ex new file mode 100644 index 00000000..78eca44c --- /dev/null +++ b/packages/unified-ui/lib/unified_ui/css/stylesheet.ex @@ -0,0 +1,65 @@ +defmodule UnifiedUi.Css.Stylesheet do + @moduledoc """ + Authored CSS stylesheet block declared in the `UnifiedUi` DSL. + """ + + @type t :: %__MODULE__{ + __identifier__: atom() | nil, + id: atom() | nil, + source: String.t(), + authored_ref: [atom()] | nil, + summary: String.t() | nil, + order: non_neg_integer() | nil + } + + defstruct __identifier__: nil, + id: nil, + source: "", + authored_ref: nil, + summary: nil, + order: nil + + @spec new(keyword() | map() | t()) :: t() + def new(%__MODULE__{} = stylesheet), do: normalize(stylesheet) + def new(stylesheet) when is_list(stylesheet), do: stylesheet |> Enum.into(%{}) |> new() + + def new(stylesheet) when is_map(stylesheet) do + %__MODULE__{ + __identifier__: fetch(stylesheet, :__identifier__), + id: fetch(stylesheet, :id), + source: fetch(stylesheet, :source, ""), + authored_ref: fetch(stylesheet, :authored_ref), + summary: fetch(stylesheet, :summary), + order: fetch(stylesheet, :order) + } + |> normalize() + end + + @spec summary(t()) :: map() + def summary(%__MODULE__{} = stylesheet) do + %{ + id: stylesheet.id, + authored_ref: stylesheet.authored_ref, + summary: stylesheet.summary, + order: stylesheet.order, + source_bytes: byte_size(stylesheet.source || "") + } + |> Enum.reject(fn {_key, value} -> value in [nil, []] end) + |> Map.new() + end + + defp normalize(%__MODULE__{} = stylesheet) do + %__MODULE__{ + stylesheet + | source: stylesheet.source || "", + authored_ref: normalize_authored_ref(stylesheet.authored_ref) + } + end + + defp normalize_authored_ref(nil), do: nil + defp normalize_authored_ref(value), do: List.wrap(value) + + defp fetch(source, key, default \\ nil) do + Map.get(source, key, Map.get(source, Atom.to_string(key), default)) + end +end diff --git a/packages/unified-ui/lib/unified_ui/dsl/entities/theme.ex b/packages/unified-ui/lib/unified_ui/dsl/entities/theme.ex index b68bb2f3..eedb2388 100644 --- a/packages/unified-ui/lib/unified_ui/dsl/entities/theme.ex +++ b/packages/unified-ui/lib/unified_ui/dsl/entities/theme.ex @@ -1,11 +1,12 @@ defmodule UnifiedUi.Dsl.Entities.Theme do @moduledoc false + alias UnifiedUi.Css.Stylesheet alias UnifiedUi.Theme @spec entities() :: [Spark.Dsl.Entity.t()] def entities do - [theme_entity()] + [theme_entity(), css_entity()] end defp theme_entity do @@ -89,4 +90,19 @@ defmodule UnifiedUi.Dsl.Entities.Theme do ] } end + + defp css_entity do + %Spark.Dsl.Entity{ + name: :css, + target: Stylesheet, + args: [:id], + identifier: :id, + schema: [ + id: [type: :atom, required: true], + source: [type: :string, required: true], + authored_ref: [type: {:list, :atom}, required: false], + summary: [type: :string, required: false] + ] + } + end end diff --git a/packages/unified-ui/lib/unified_ui/dsl/entity_schema.ex b/packages/unified-ui/lib/unified_ui/dsl/entity_schema.ex index 6c48b1cc..6b1ef358 100644 --- a/packages/unified-ui/lib/unified_ui/dsl/entity_schema.ex +++ b/packages/unified-ui/lib/unified_ui/dsl/entity_schema.ex @@ -7,6 +7,8 @@ defmodule UnifiedUi.Dsl.EntitySchema do authored_ref: [type: {:list, :atom}, required: false], annotations: [type: :keyword_list, required: false, default: []], tags: [type: {:list, :atom}, required: false, default: []], + class: [type: :string, required: false], + classes: [type: {:list, :string}, required: false, default: []], variant: [type: :atom, required: false], tone: [type: :atom, required: false], theme_ref: [type: :atom, required: false], diff --git a/packages/unified-ui/lib/unified_ui/dsl/node.ex b/packages/unified-ui/lib/unified_ui/dsl/node.ex index e6962442..a562234a 100644 --- a/packages/unified-ui/lib/unified_ui/dsl/node.ex +++ b/packages/unified-ui/lib/unified_ui/dsl/node.ex @@ -38,6 +38,8 @@ defmodule UnifiedUi.Dsl.Node do authored_ref: [atom()] | nil, annotations: keyword(), tags: [atom()], + class: String.t() | nil, + classes: [String.t()], variant: atom() | nil, tone: atom() | nil, theme_ref: atom() | nil, @@ -201,6 +203,8 @@ defmodule UnifiedUi.Dsl.Node do authored_ref: nil, annotations: [], tags: [], + class: nil, + classes: [], variant: nil, tone: nil, theme_ref: nil, @@ -356,6 +360,8 @@ defmodule UnifiedUi.Dsl.Node do family: node.family, kind: node.kind, annotations: node.annotations, + class: UnifiedUi.Css.class_string(node.class, node.classes), + classes: UnifiedUi.Css.normalize_classes(node.class, node.classes), value: node.value, label: node.label, target: node.target, diff --git a/packages/unified-ui/lib/unified_ui/info.ex b/packages/unified-ui/lib/unified_ui/info.ex index 8c42b0b1..c5ee4660 100644 --- a/packages/unified-ui/lib/unified_ui/info.ex +++ b/packages/unified-ui/lib/unified_ui/info.ex @@ -4,6 +4,7 @@ defmodule UnifiedUi.Info do """ alias Spark.Dsl.Extension + alias UnifiedUi.Css alias UnifiedUi.Dsl.Node alias UnifiedUi.Examples alias UnifiedUi.Reference @@ -54,6 +55,7 @@ defmodule UnifiedUi.Info do identity: section_options(module, :identity), composition: section_options(module, :composition), themes: section_options(module, :themes), + css: Css.module_summary(module), signals: section_options(module, :signals), theme_catalog: Theme.module_summary(module), signal_catalog: Signals.module_summary(module), diff --git a/packages/unified-ui/test/unified_ui/css_authoring_test.exs b/packages/unified-ui/test/unified_ui/css_authoring_test.exs new file mode 100644 index 00000000..b5f3de76 --- /dev/null +++ b/packages/unified-ui/test/unified_ui/css_authoring_test.exs @@ -0,0 +1,72 @@ +defmodule UnifiedUi.CssAuthoringTest do + use ExUnit.Case, async: true + + alias UnifiedUi.{Css, Info} + + defmodule CssAuthoredWorkspace do + use UnifiedUi.Dsl + + identity do + id(:css_authored_workspace) + title("CSS Authored Workspace") + end + + themes do + css :workspace_styles do + source(""" + #shell { color: white; } + .hero-card { background-color: black; } + """) + + authored_ref([:tests, :css_authoring]) + summary("Workspace-local CSS authoring block") + end + + css :overrides do + source(".cta.primary { font-weight: bold; }") + end + end + + composition do + root(:css_authored_workspace_root) + mode(:screen) + + box :shell do + class("hero-card") + classes(["workspace-panel"]) + + button :cta do + label("Continue") + class("cta primary") + classes(["portable-action"]) + end + end + end + end + + test "registers authored CSS stylesheet blocks in deterministic source order" do + assert [ + %{id: :workspace_styles, order: 0, source_bytes: first_bytes}, + %{id: :overrides, order: 1, source_bytes: second_bytes} + ] = Css.module_summary(CssAuthoredWorkspace).blocks + + assert first_bytes > 0 + assert second_bytes > 0 + + assert Enum.map(Css.stylesheets(CssAuthoredWorkspace), & &1.id) == [ + :workspace_styles, + :overrides + ] + end + + test "normalizes portable class metadata on authored nodes" do + [shell] = Info.composition_summary(CssAuthoredWorkspace) + [cta] = shell.children + + assert shell.class == "hero-card workspace-panel" + assert shell.classes == ["hero-card", "workspace-panel"] + + assert cta.class == "cta primary portable-action" + assert cta.classes == ["cta", "primary", "portable-action"] + end +end From 7b9ae8e6fa0c97570d7e487a3761962320cb4606 Mon Sep 17 00:00:00 2001 From: Pascal Charbonneau Date: Fri, 15 May 2026 10:09:13 -0400 Subject: [PATCH 03/17] Add CSS parser adapter --- ...s-authoring-surface-and-parser-boundary.md | 20 +- packages/unified-ui/lib/unified_ui/css.ex | 8 + .../unified-ui/lib/unified_ui/css/parser.ex | 225 ++++++++++++++++++ packages/unified-ui/mix.exs | 1 + .../test/unified_ui/css_authoring_test.exs | 44 ++++ 5 files changed, 288 insertions(+), 10 deletions(-) create mode 100644 packages/unified-ui/lib/unified_ui/css/parser.ex diff --git a/.spec/planning/css_style_authoring/phase-01-css-authoring-surface-and-parser-boundary.md b/.spec/planning/css_style_authoring/phase-01-css-authoring-surface-and-parser-boundary.md index 7ffcbf85..81ec1b8c 100644 --- a/.spec/planning/css_style_authoring/phase-01-css-authoring-surface-and-parser-boundary.md +++ b/.spec/planning/css_style_authoring/phase-01-css-authoring-surface-and-parser-boundary.md @@ -51,27 +51,27 @@ Back to index: [README](./README.md) [x] 1.1.2.3 Subtask - Document that authored `class` values are portable selector metadata and optional runtime hooks, not automatic stylesheet loading. [x] 1.1.2.4 Subtask - Preserve current explicit style and theme props while adding CSS block authoring as an additional style source. - [ ] 1.2 Section - CSS Parser Adapter and Syntax Recovery + [x] 1.2 Section - CSS Parser Adapter and Syntax Recovery Introduce a parser boundary that accepts real CSS stylesheet text and normalizes parser output into an internal shape used by later phases. - [ ] 1.2.1 Task - Select and isolate the CSS parser strategy + [x] 1.2.1 Task - Select and isolate the CSS parser strategy Evaluate parser options and hide the selected implementation behind a small adapter so the DSL and compiler do not depend on parser-specific data structures. - [ ] 1.2.1.1 Subtask - Evaluate available Elixir, Erlang, NIF, or port-based CSS parser options against CSS Syntax stylesheet parsing and recovery needs. - [ ] 1.2.1.2 Subtask - Define the parser adapter input and output contract, including source spans, rule order, declaration order, and recoverable error reporting. - [ ] 1.2.1.3 Subtask - Add a fallback parser-selection decision record if no dependency satisfies the required CSS Syntax-compatible behavior. + [x] 1.2.1.1 Subtask - Evaluate available Elixir, Erlang, NIF, or port-based CSS parser options against CSS Syntax stylesheet parsing and recovery needs. + [x] 1.2.1.2 Subtask - Define the parser adapter input and output contract, including source spans, rule order, declaration order, and recoverable error reporting. + [x] 1.2.1.3 Subtask - Add a fallback parser-selection decision record if no dependency satisfies the required CSS Syntax-compatible behavior. - [ ] 1.2.2 Task - Normalize parsed stylesheet rules + [x] 1.2.2 Task - Normalize parsed stylesheet rules Convert parser output into a deterministic internal representation that later phases can match, cascade, translate, and inspect. - [ ] 1.2.2.1 Subtask - Normalize style rules, selector lists, declaration names, declaration values, importance flags, source spans, and source order. - [ ] 1.2.2.2 Subtask - Preserve recoverable parser diagnostics with enough source context for author-facing messages. - [ ] 1.2.2.3 Subtask - Normalize unsupported at-rules into ignored diagnostic entries rather than dropping them silently. - [ ] 1.2.2.4 Subtask - Ensure comments and insignificant whitespace do not affect deterministic parser output. + [x] 1.2.2.1 Subtask - Normalize style rules, selector lists, declaration names, declaration values, importance flags, source spans, and source order. + [x] 1.2.2.2 Subtask - Preserve recoverable parser diagnostics with enough source context for author-facing messages. + [x] 1.2.2.3 Subtask - Normalize unsupported at-rules into ignored diagnostic entries rather than dropping them silently. + [x] 1.2.2.4 Subtask - Ensure comments and insignificant whitespace do not affect deterministic parser output. [ ] 1.3 Section - Authoring Diagnostics and Inspection Make CSS block behavior visible to authors before any canonical style diff --git a/packages/unified-ui/lib/unified_ui/css.ex b/packages/unified-ui/lib/unified_ui/css.ex index 4a1ae682..48bec342 100644 --- a/packages/unified-ui/lib/unified_ui/css.ex +++ b/packages/unified-ui/lib/unified_ui/css.ex @@ -7,6 +7,7 @@ defmodule UnifiedUi.Css do """ alias Spark.Dsl.Extension + alias UnifiedUi.Css.Parser alias UnifiedUi.Css.Stylesheet @spec stylesheets(module()) :: [Stylesheet.t()] @@ -32,6 +33,13 @@ defmodule UnifiedUi.Css do } end + @spec parse_module(module()) :: [Parser.parsed_stylesheet()] + def parse_module(module) when is_atom(module) do + module + |> stylesheets() + |> Parser.parse_all() + end + @spec normalize_classes(String.t() | nil, [String.t()] | nil) :: [String.t()] def normalize_classes(class, classes \\ []) diff --git a/packages/unified-ui/lib/unified_ui/css/parser.ex b/packages/unified-ui/lib/unified_ui/css/parser.ex new file mode 100644 index 00000000..fd9dd32b --- /dev/null +++ b/packages/unified-ui/lib/unified_ui/css/parser.ex @@ -0,0 +1,225 @@ +defmodule UnifiedUi.Css.Parser do + @moduledoc """ + CSS parser adapter used by the authored CSS style pipeline. + + The adapter keeps parser-specific structs out of the DSL and compiler. It + currently uses `CSSerpent` for stylesheet parsing and normalizes the parsed + output into deterministic rule, declaration, and diagnostic maps. + """ + + alias UnifiedUi.Css.Stylesheet + + @type diagnostic :: %{ + kind: atom(), + severity: :info | :warning | :error, + message: String.t(), + source: map() + } + + @type declaration :: %{ + property: String.t(), + value: String.t(), + important?: boolean(), + source_order: non_neg_integer() + } + + @type rule :: %{ + type: :style, + selector_text: String.t(), + selectors: [String.t()], + declarations: [declaration()], + source_order: non_neg_integer(), + raw: String.t() | nil + } + + @type parsed_stylesheet :: %{ + parser: :csserpent, + block_id: atom() | nil, + source_order: non_neg_integer() | nil, + rules: [rule()], + diagnostics: [diagnostic()], + summary: map() + } + + @spec parse(Stylesheet.t()) :: parsed_stylesheet() + def parse(%Stylesheet{} = stylesheet) do + source = stylesheet.source || "" + rules = CSSerpent.parse(source, stylesheet.id) + + {normalized_rules, diagnostics} = + rules + |> Enum.with_index() + |> Enum.reduce({[], parser_recovery_diagnostics(source, stylesheet)}, fn {rule, index}, + {rules, + diagnostics} -> + case normalize_rule(rule, index, stylesheet) do + {:ok, normalized_rule} -> + {[normalized_rule | rules], diagnostics} + + {:ignored, diagnostic} -> + {rules, [diagnostic | diagnostics]} + end + end) + + normalized_rules = Enum.reverse(normalized_rules) + diagnostics = Enum.reverse(diagnostics) + + %{ + parser: :csserpent, + block_id: stylesheet.id, + source_order: stylesheet.order, + rules: normalized_rules, + diagnostics: diagnostics, + summary: summary(normalized_rules, diagnostics) + } + end + + @spec parse_all([Stylesheet.t()]) :: [parsed_stylesheet()] + def parse_all(stylesheets) when is_list(stylesheets), do: Enum.map(stylesheets, &parse/1) + + defp normalize_rule(%{identifier: identifier}, index, stylesheet) + when is_binary(identifier) do + {:ignored, + diagnostic(:ignored_at_rule, stylesheet, %{ + source_order: index, + at_rule: identifier, + message: "Ignored unsupported CSS at-rule #{identifier}" + })} + end + + defp normalize_rule(%{selector: selector, props: props, raw: raw}, index, _stylesheet) + when is_binary(selector) do + declarations = + props + |> List.wrap() + |> Enum.with_index() + |> Enum.map(fn {prop, declaration_index} -> + normalize_declaration(prop, declaration_index) + end) + |> Enum.reject(&is_nil/1) + + {:ok, + %{ + type: :style, + selector_text: String.trim(selector), + selectors: split_selectors(selector), + declarations: declarations, + source_order: index, + raw: raw + }} + end + + defp normalize_rule(rule, index, stylesheet) do + {:ignored, + diagnostic(:ignored_rule, stylesheet, %{ + source_order: index, + rule: inspect(rule), + message: "Ignored CSS rule without supported style-rule shape" + })} + end + + defp normalize_declaration(%{property: property, value: value}, index) + when is_binary(property) and is_binary(value) do + {value, important?} = split_important(value) + + %{ + property: property |> String.trim() |> String.downcase(), + value: value, + important?: important?, + source_order: index + } + end + + defp normalize_declaration(_other, _index), do: nil + + defp split_selectors(selector) do + selector + |> String.split(",", trim: true) + |> Enum.map(&String.trim/1) + |> Enum.reject(&(&1 == "")) + end + + defp split_important(value) do + value = String.trim(value) + + if String.match?(value, ~r/!\s*important\z/i) do + {value |> String.replace(~r/!\s*important\z/i, "") |> String.trim(), true} + else + {value, false} + end + end + + defp parser_recovery_diagnostics(source, stylesheet) do + [] + |> maybe_unbalanced_brace_diagnostic(source, stylesheet, "{", "}") + |> maybe_empty_source_diagnostic(source, stylesheet) + end + + defp maybe_unbalanced_brace_diagnostic(diagnostics, source, stylesheet, open, close) do + opens = source |> String.graphemes() |> Enum.count(&(&1 == open)) + closes = source |> String.graphemes() |> Enum.count(&(&1 == close)) + + if opens == closes do + diagnostics + else + [ + diagnostic(:parse_recovery, stylesheet, %{ + message: "CSS parser recovered from unbalanced stylesheet braces", + opens: opens, + closes: closes + }) + | diagnostics + ] + end + end + + defp maybe_empty_source_diagnostic(diagnostics, source, stylesheet) do + if String.trim(source) == "" do + [ + diagnostic(:empty_stylesheet, stylesheet, %{ + message: "Ignored empty CSS stylesheet block" + }) + | diagnostics + ] + else + diagnostics + end + end + + defp diagnostic(kind, stylesheet, attrs) do + message = Map.fetch!(attrs, :message) + + %{ + kind: kind, + severity: diagnostic_severity(kind), + message: message, + source: + %{ + block_id: stylesheet.id, + block_order: stylesheet.order + } + |> maybe_put(:selector, Map.get(attrs, :selector)) + |> maybe_put(:property, Map.get(attrs, :property)) + |> maybe_put(:source_order, Map.get(attrs, :source_order)) + |> maybe_put(:at_rule, Map.get(attrs, :at_rule)) + |> maybe_put(:opens, Map.get(attrs, :opens)) + |> maybe_put(:closes, Map.get(attrs, :closes)) + } + end + + defp diagnostic_severity(:parse_recovery), do: :warning + defp diagnostic_severity(:empty_stylesheet), do: :info + defp diagnostic_severity(_kind), do: :warning + + defp summary(rules, diagnostics) do + %{ + rule_count: length(rules), + declaration_count: rules |> Enum.flat_map(& &1.declarations) |> length(), + ignored_count: Enum.count(diagnostics, &(&1.kind in [:ignored_at_rule, :ignored_rule])), + diagnostic_count: length(diagnostics) + } + end + + defp maybe_put(map, _key, nil), do: map + defp maybe_put(map, key, value), do: Map.put(map, key, value) +end diff --git a/packages/unified-ui/mix.exs b/packages/unified-ui/mix.exs index 85b73fd9..7d1fbb1a 100644 --- a/packages/unified-ui/mix.exs +++ b/packages/unified-ui/mix.exs @@ -26,6 +26,7 @@ defmodule UnifiedUi.MixProject do defp deps do [ {:spark, path: "../../vendor/spark", override: true}, + {:csserpent, "~> 0.5.1"}, {:unified_iur, path: "../unified_iur"} ] end diff --git a/packages/unified-ui/test/unified_ui/css_authoring_test.exs b/packages/unified-ui/test/unified_ui/css_authoring_test.exs index b5f3de76..6ff62bbe 100644 --- a/packages/unified-ui/test/unified_ui/css_authoring_test.exs +++ b/packages/unified-ui/test/unified_ui/css_authoring_test.exs @@ -69,4 +69,48 @@ defmodule UnifiedUi.CssAuthoringTest do assert cta.class == "cta primary portable-action" assert cta.classes == ["cta", "primary", "portable-action"] end + + test "parses CSS blocks through the parser adapter" do + parsed = Css.parse_module(CssAuthoredWorkspace) + + assert [ + %{ + parser: :csserpent, + block_id: :workspace_styles, + summary: %{rule_count: 2, declaration_count: 2, diagnostic_count: 0}, + rules: first_rules + }, + %{ + parser: :csserpent, + block_id: :overrides, + summary: %{rule_count: 1, declaration_count: 1, diagnostic_count: 0} + } + ] = parsed + + assert Enum.map(first_rules, & &1.selector_text) == ["#shell", ".hero-card"] + + assert hd(first_rules).declarations == [ + %{property: "color", value: "white", important?: false, source_order: 0} + ] + end + + test "reports parser recovery and ignored at-rule diagnostics" do + stylesheet = + UnifiedUi.Css.Stylesheet.new( + id: :diagnostic_styles, + source: """ + @import url("remote.css"); + .valid { color: white; } + .broken { background: black; + """ + ) + + parsed = UnifiedUi.Css.Parser.parse(stylesheet) + + assert %{rule_count: 1, declaration_count: 1, diagnostic_count: 2, ignored_count: 1} = + parsed.summary + + assert Enum.map(parsed.diagnostics, & &1.kind) == [:parse_recovery, :ignored_at_rule] + assert Enum.any?(parsed.rules, &(&1.selector_text == ".valid")) + end end From d0cd1108e984316761d0e70260618abf72855960 Mon Sep 17 00:00:00 2001 From: Pascal Charbonneau Date: Fri, 15 May 2026 10:13:12 -0400 Subject: [PATCH 04/17] Expose CSS authoring diagnostics --- ...s-authoring-surface-and-parser-boundary.md | 20 +++---- .../lib/unified_ui/compiler/inspection.ex | 3 ++ .../lib/unified_ui/compiler/result.ex | 3 +- packages/unified-ui/lib/unified_ui/css.ex | 54 ++++++++++++++++++- packages/unified-ui/lib/unified_ui/tooling.ex | 4 +- .../test/unified_ui/css_authoring_test.exs | 27 +++++++++- 6 files changed, 97 insertions(+), 14 deletions(-) diff --git a/.spec/planning/css_style_authoring/phase-01-css-authoring-surface-and-parser-boundary.md b/.spec/planning/css_style_authoring/phase-01-css-authoring-surface-and-parser-boundary.md index 81ec1b8c..93cbb808 100644 --- a/.spec/planning/css_style_authoring/phase-01-css-authoring-surface-and-parser-boundary.md +++ b/.spec/planning/css_style_authoring/phase-01-css-authoring-surface-and-parser-boundary.md @@ -73,26 +73,26 @@ Back to index: [README](./README.md) [x] 1.2.2.3 Subtask - Normalize unsupported at-rules into ignored diagnostic entries rather than dropping them silently. [x] 1.2.2.4 Subtask - Ensure comments and insignificant whitespace do not affect deterministic parser output. - [ ] 1.3 Section - Authoring Diagnostics and Inspection + [x] 1.3 Section - Authoring Diagnostics and Inspection Make CSS block behavior visible to authors before any canonical style lowering changes runtime output. - [ ] 1.3.1 Task - Add CSS authoring diagnostics + [x] 1.3.1 Task - Add CSS authoring diagnostics Define the diagnostic categories and severity rules for parse recovery and ignored CSS constructs. - [ ] 1.3.1.1 Subtask - Add diagnostics for parser recovery, malformed declaration values, ignored at-rules, ignored selectors, and ignored properties. - [ ] 1.3.1.2 Subtask - Distinguish fatal parser failures from recoverable CSS issues that can continue through style lowering. - [ ] 1.3.1.3 Subtask - Include source block id, selector text, declaration name, and source span where available. - [ ] 1.3.1.4 Subtask - Keep diagnostic ordering deterministic across equivalent authored modules. + [x] 1.3.1.1 Subtask - Add diagnostics for parser recovery, malformed declaration values, ignored at-rules, ignored selectors, and ignored properties. + [x] 1.3.1.2 Subtask - Distinguish fatal parser failures from recoverable CSS issues that can continue through style lowering. + [x] 1.3.1.3 Subtask - Include source block id, selector text, declaration name, and source span where available. + [x] 1.3.1.4 Subtask - Keep diagnostic ordering deterministic across equivalent authored modules. - [ ] 1.3.2 Task - Expose parsed CSS inspection output + [x] 1.3.2 Task - Expose parsed CSS inspection output Let developers inspect CSS blocks and parser diagnostics without running a renderer or relying on runtime stylesheet output. - [ ] 1.3.2.1 Subtask - Extend inspection output to list authored CSS blocks, normalized rule count, declaration count, ignored construct count, and recoverable parse diagnostics. - [ ] 1.3.2.2 Subtask - Extend export output with deterministic CSS block metadata suitable for review diffs. - [ ] 1.3.2.3 Subtask - Add examples that show valid CSS, recoverable parser issues, and ignored unsupported at-rules. + [x] 1.3.2.1 Subtask - Extend inspection output to list authored CSS blocks, normalized rule count, declaration count, ignored construct count, and recoverable parse diagnostics. + [x] 1.3.2.2 Subtask - Extend export output with deterministic CSS block metadata suitable for review diffs. + [x] 1.3.2.3 Subtask - Add examples that show valid CSS, recoverable parser issues, and ignored unsupported at-rules. [ ] 1.4 Section - Phase 1 Integration Tests Validate authored CSS block parsing, ordering, diagnostics, and inspection diff --git a/packages/unified-ui/lib/unified_ui/compiler/inspection.ex b/packages/unified-ui/lib/unified_ui/compiler/inspection.ex index 0dd8c132..a00c8508 100644 --- a/packages/unified-ui/lib/unified_ui/compiler/inspection.ex +++ b/packages/unified-ui/lib/unified_ui/compiler/inspection.ex @@ -67,6 +67,9 @@ defmodule UnifiedUi.Compiler.Inspection do "display systems: #{format_display_systems(listing.compiled.display_systems)}", "theme ids: #{format_list(listing.themes.theme_ids)}", "style refs: #{format_list(listing.themes.style_ref_ids)}", + "css blocks: #{listing.css.summary.block_count}", + "css rules: #{listing.css.summary.rule_count}", + "css diagnostics: #{listing.css.summary.diagnostic_count}", "binding names: #{format_list(listing.bindings.names)}", "signal families: #{format_list(listing.signals.families)}", "signal intents: #{format_list(listing.signals.intents)}", diff --git a/packages/unified-ui/lib/unified_ui/compiler/result.ex b/packages/unified-ui/lib/unified_ui/compiler/result.ex index 22f59637..1e1e01ec 100644 --- a/packages/unified-ui/lib/unified_ui/compiler/result.ex +++ b/packages/unified-ui/lib/unified_ui/compiler/result.ex @@ -5,7 +5,7 @@ defmodule UnifiedUi.Compiler.Result do alias UnifiedIUR.{Binding, Element, Interaction, Theme} alias UnifiedIUR.Tree - alias UnifiedUi.Info + alias UnifiedUi.{Css, Info} @type t :: %__MODULE__{ module: module(), @@ -75,6 +75,7 @@ defmodule UnifiedUi.Compiler.Result do style_ref_ids: authored_style_ref_ids(authored_nodes), themed_element_ids: authored_themed_node_ids(authored_nodes) }, + css: Css.inspection(result.module), bindings: %{ names: result.bindings |> Enum.map(& &1.name) |> sort_terms(), paths: result.bindings |> Enum.map(& &1.path) |> Enum.uniq() |> Enum.sort(), diff --git a/packages/unified-ui/lib/unified_ui/css.ex b/packages/unified-ui/lib/unified_ui/css.ex index 48bec342..3744ba40 100644 --- a/packages/unified-ui/lib/unified_ui/css.ex +++ b/packages/unified-ui/lib/unified_ui/css.ex @@ -26,10 +26,17 @@ defmodule UnifiedUi.Css do @spec module_summary(module()) :: map() def module_summary(module) when is_atom(module) do stylesheets = stylesheets(module) + parsed = Parser.parse_all(stylesheets) + parse_summary = parsed_summary(parsed) %{ count: length(stylesheets), - blocks: Enum.map(stylesheets, &Stylesheet.summary/1) + blocks: Enum.map(stylesheets, &Stylesheet.summary/1), + parser: parse_summary.parser, + rule_count: parse_summary.rule_count, + declaration_count: parse_summary.declaration_count, + ignored_count: parse_summary.ignored_count, + diagnostic_count: parse_summary.diagnostic_count } end @@ -40,6 +47,51 @@ defmodule UnifiedUi.Css do |> Parser.parse_all() end + @spec inspection(module()) :: map() + def inspection(module) when is_atom(module) do + parsed = parse_module(module) + summary = parsed_summary(parsed) + + %{ + summary: summary, + blocks: + Enum.map(parsed, fn block -> + %{ + id: block.block_id, + source_order: block.source_order, + parser: block.parser, + summary: block.summary, + diagnostics: block.diagnostics + } + end), + diagnostics: diagnostics(parsed) + } + end + + @spec diagnostics(module() | [Parser.parsed_stylesheet()]) :: [Parser.diagnostic()] + def diagnostics(module) when is_atom(module), do: module |> parse_module() |> diagnostics() + + def diagnostics(parsed_stylesheets) when is_list(parsed_stylesheets) do + Enum.flat_map(parsed_stylesheets, & &1.diagnostics) + end + + defp parsed_summary(parsed_stylesheets) do + %{ + parser: :csserpent, + block_count: length(parsed_stylesheets), + rule_count: sum_summary(parsed_stylesheets, :rule_count), + declaration_count: sum_summary(parsed_stylesheets, :declaration_count), + ignored_count: sum_summary(parsed_stylesheets, :ignored_count), + diagnostic_count: sum_summary(parsed_stylesheets, :diagnostic_count) + } + end + + defp sum_summary(parsed_stylesheets, key) do + Enum.reduce(parsed_stylesheets, 0, fn parsed, total -> + total + Map.get(parsed.summary, key, 0) + end) + end + @spec normalize_classes(String.t() | nil, [String.t()] | nil) :: [String.t()] def normalize_classes(class, classes \\ []) diff --git a/packages/unified-ui/lib/unified_ui/tooling.ex b/packages/unified-ui/lib/unified_ui/tooling.ex index 8b36072b..b497d016 100644 --- a/packages/unified-ui/lib/unified_ui/tooling.ex +++ b/packages/unified-ui/lib/unified_ui/tooling.ex @@ -4,7 +4,7 @@ defmodule UnifiedUi.Tooling do and release review workflows. """ - alias UnifiedUi.{Compiler, Examples, Export, Info, Signals, WidgetComponents} + alias UnifiedUi.{Compiler, Css, Examples, Export, Info, Signals, WidgetComponents} @shared_specs [ ".spec/specs/architecture.spec.md", @@ -174,6 +174,7 @@ defmodule UnifiedUi.Tooling do related_examples: Enum.map(report.related_examples, & &1.id), related_specs: report.related_specs, summary: report.compiler.summary, + css_diagnostics: Css.diagnostics(module), signal_coverage: report.signal_coverage } @@ -250,6 +251,7 @@ defmodule UnifiedUi.Tooling do "construct families: #{inspect(diagnostics.construct_families)}", "related examples: #{inspect(diagnostics.related_examples)}", "related specs: #{inspect(diagnostics.related_specs)}", + "css diagnostics: #{inspect(diagnostics.css_diagnostics)}", "signal families: #{inspect(diagnostics.signal_coverage.families)}", "binding names: #{inspect(diagnostics.signal_coverage.binding_names)}", "navigation target kinds: #{inspect(diagnostics.signal_coverage.interaction_target_kinds)}", diff --git a/packages/unified-ui/test/unified_ui/css_authoring_test.exs b/packages/unified-ui/test/unified_ui/css_authoring_test.exs index 6ff62bbe..a8c3b528 100644 --- a/packages/unified-ui/test/unified_ui/css_authoring_test.exs +++ b/packages/unified-ui/test/unified_ui/css_authoring_test.exs @@ -1,7 +1,7 @@ defmodule UnifiedUi.CssAuthoringTest do use ExUnit.Case, async: true - alias UnifiedUi.{Css, Info} + alias UnifiedUi.{Compiler, Css, Info, Tooling} defmodule CssAuthoredWorkspace do use UnifiedUi.Dsl @@ -113,4 +113,29 @@ defmodule UnifiedUi.CssAuthoringTest do assert Enum.map(parsed.diagnostics, & &1.kind) == [:parse_recovery, :ignored_at_rule] assert Enum.any?(parsed.rules, &(&1.selector_text == ".valid")) end + + test "exposes CSS parser summaries through compiler inspection" do + report = Compiler.inspection(CssAuthoredWorkspace) + rendered = Compiler.render_inspection(CssAuthoredWorkspace) + + assert report.listing.css.summary == %{ + parser: :csserpent, + block_count: 2, + rule_count: 3, + declaration_count: 3, + ignored_count: 0, + diagnostic_count: 0 + } + + assert rendered =~ "css blocks: 2" + assert rendered =~ "css rules: 3" + assert rendered =~ "css diagnostics: 0" + end + + test "exposes CSS diagnostics through module diagnostics" do + diagnostics = Tooling.module_diagnostics(CssAuthoredWorkspace) + + assert diagnostics.status == :ok + assert diagnostics.css_diagnostics == [] + end end From 7926344a1f97ea7c5c5df6801dc9687d9218b6fe Mon Sep 17 00:00:00 2001 From: Pascal Charbonneau Date: Fri, 15 May 2026 10:15:39 -0400 Subject: [PATCH 05/17] Add CSS authoring phase one integration tests --- ...s-authoring-surface-and-parser-boundary.md | 24 +-- .../css_phase_1_integration_test.exs | 141 ++++++++++++++++++ 2 files changed, 153 insertions(+), 12 deletions(-) create mode 100644 packages/unified-ui/test/unified_ui/css_phase_1_integration_test.exs diff --git a/.spec/planning/css_style_authoring/phase-01-css-authoring-surface-and-parser-boundary.md b/.spec/planning/css_style_authoring/phase-01-css-authoring-surface-and-parser-boundary.md index 93cbb808..178687bd 100644 --- a/.spec/planning/css_style_authoring/phase-01-css-authoring-surface-and-parser-boundary.md +++ b/.spec/planning/css_style_authoring/phase-01-css-authoring-surface-and-parser-boundary.md @@ -24,7 +24,7 @@ Back to index: [README](./README.md) - Parser recovery diagnostics are surfaced without treating every recoverable CSS syntax issue as a fatal DSL error. -[ ] 1 Phase 1 - CSS Authoring Surface and Parser Boundary +[x] 1 Phase 1 - CSS Authoring Surface and Parser Boundary Define the authored CSS stylesheet block, parser adapter, source-order model, and recoverable diagnostics needed before selector matching or style lowering can be implemented. @@ -94,24 +94,24 @@ Back to index: [README](./README.md) [x] 1.3.2.2 Subtask - Extend export output with deterministic CSS block metadata suitable for review diffs. [x] 1.3.2.3 Subtask - Add examples that show valid CSS, recoverable parser issues, and ignored unsupported at-rules. - [ ] 1.4 Section - Phase 1 Integration Tests + [x] 1.4 Section - Phase 1 Integration Tests Validate authored CSS block parsing, ordering, diagnostics, and inspection before selector matching and canonical style lowering are introduced. - [ ] 1.4.1 Task - CSS block authoring scenarios + [x] 1.4.1 Task - CSS block authoring scenarios Verify the DSL accepts valid CSS stylesheet blocks and preserves deterministic source metadata. - [ ] 1.4.1.1 Subtask - Verify a module with one CSS block parses valid selector and declaration syntax successfully. - [ ] 1.4.1.2 Subtask - Verify a module with multiple CSS blocks preserves deterministic block and rule source order. - [ ] 1.4.1.3 Subtask - Verify CSS block authoring composes with existing style, theme, and widget declarations without changing their public syntax. - [ ] 1.4.1.4 Subtask - Verify malformed block placement fails with an actionable DSL diagnostic. + [x] 1.4.1.1 Subtask - Verify a module with one CSS block parses valid selector and declaration syntax successfully. + [x] 1.4.1.2 Subtask - Verify a module with multiple CSS blocks preserves deterministic block and rule source order. + [x] 1.4.1.3 Subtask - Verify CSS block authoring composes with existing style, theme, and widget declarations without changing their public syntax. + [x] 1.4.1.4 Subtask - Verify malformed block placement fails with an actionable DSL diagnostic. - [ ] 1.4.2 Task - Parser recovery and inspection scenarios + [x] 1.4.2 Task - Parser recovery and inspection scenarios Verify CSS parser recovery and inspection output are deterministic and useful before lowering is implemented. - [ ] 1.4.2.1 Subtask - Verify recoverable CSS syntax errors produce diagnostics while preserving later valid rules. - [ ] 1.4.2.2 Subtask - Verify unsupported at-rules are represented as ignored diagnostics with source context. - [ ] 1.4.2.3 Subtask - Verify comments and whitespace do not change normalized parser output. - [ ] 1.4.2.4 Subtask - Verify inspection and export output list CSS block metadata and diagnostics in deterministic order. + [x] 1.4.2.1 Subtask - Verify recoverable CSS syntax errors produce diagnostics while preserving later valid rules. + [x] 1.4.2.2 Subtask - Verify unsupported at-rules are represented as ignored diagnostics with source context. + [x] 1.4.2.3 Subtask - Verify comments and whitespace do not change normalized parser output. + [x] 1.4.2.4 Subtask - Verify inspection and export output list CSS block metadata and diagnostics in deterministic order. diff --git a/packages/unified-ui/test/unified_ui/css_phase_1_integration_test.exs b/packages/unified-ui/test/unified_ui/css_phase_1_integration_test.exs new file mode 100644 index 00000000..8a339a51 --- /dev/null +++ b/packages/unified-ui/test/unified_ui/css_phase_1_integration_test.exs @@ -0,0 +1,141 @@ +defmodule UnifiedUi.CssPhase1IntegrationTest do + use ExUnit.Case, async: true + + alias UnifiedUi.{Compiler, Css, Info} + + defmodule PhaseOneWorkspace do + use UnifiedUi.Dsl + + identity do + id(:css_phase_one_workspace) + title("CSS Phase One Workspace") + end + + themes do + default_theme(:workspace) + + theme do + id(:workspace) + + component_style do + id(:panel_shell) + component(:box) + style(style_value(spacing: %{padding: 2})) + end + end + + css :base_css do + source(""" + /* ignored comment */ + #shell { color: white; } + .workspace-panel { background-color: black; } + """) + end + + css :diagnostic_css do + source(""" + @import url("remote.css"); + .cta { font-weight: bold !important; } + """) + end + end + + composition do + root(:css_phase_one_root) + mode(:screen) + + box :shell do + class("workspace-panel") + style_refs([:panel_shell]) + style(style_value(visibility: %{hidden?: false})) + + button :cta do + label("Continue") + class("cta") + end + end + end + end + + test "parses ordered CSS blocks and preserves existing authored style sources" do + parsed = Css.parse_module(PhaseOneWorkspace) + listing = Compiler.listing(PhaseOneWorkspace) + + assert Enum.map(parsed, & &1.block_id) == [:base_css, :diagnostic_css] + assert Enum.map(parsed, & &1.source_order) == [0, 1] + + assert listing.authored.style_ref_ids == [:panel_shell] + assert listing.themes.theme_ids == [:workspace] + assert listing.css.summary.rule_count == 3 + assert listing.css.summary.declaration_count == 3 + assert listing.css.summary.ignored_count == 1 + end + + test "normalizes comments, whitespace, important flags, and ignored at-rules deterministically" do + [base, diagnostic] = Css.parse_module(PhaseOneWorkspace) + + assert Enum.map(base.rules, & &1.selector_text) == ["#shell", ".workspace-panel"] + + assert [ + %{ + property: "font-weight", + value: "bold", + important?: true, + source_order: 0 + } + ] = hd(diagnostic.rules).declarations + + assert [%{kind: :ignored_at_rule, source: %{block_id: :diagnostic_css}}] = + diagnostic.diagnostics + end + + test "exposes CSS metadata through module and compiler inspection" do + module_summary = Info.inspect_module(PhaseOneWorkspace) + rendered = Compiler.render_inspection(PhaseOneWorkspace) + + assert module_summary.css.count == 2 + assert module_summary.css.rule_count == 3 + assert module_summary.css.diagnostic_count == 1 + + assert rendered =~ "css blocks: 2" + assert rendered =~ "css rules: 3" + assert rendered =~ "css diagnostics: 1" + end + + test "rejects CSS blocks outside the theme authoring section" do + assert_compile_dsl_error( + """ + identity do + id(:invalid_css_placement) + end + + composition do + root(:invalid_css_placement_root) + + css :bad_css do + source(".x { color: white; }") + end + end + """, + "cannot compile module" + ) + end + + defp compile_module(body) do + module_name = "Generated#{System.unique_integer([:positive])}" + + Code.compile_string(""" + defmodule UnifiedUi.CssPhase1IntegrationTest.#{module_name} do + use UnifiedUi.Dsl + + #{body} + end + """) + end + + defp assert_compile_dsl_error(body, expected_message) do + error = assert_raise CompileError, fn -> compile_module(body) end + + assert Exception.message(error) =~ expected_message + end +end From 0994df1bbfea115404b43fa84133eed2524b3d60 Mon Sep 17 00:00:00 2001 From: Pascal Charbonneau Date: Fri, 15 May 2026 10:17:03 -0400 Subject: [PATCH 06/17] Define canonical CSS selector model --- ...elector-matching-and-cascade-resolution.md | 22 +- .../unified-ui/lib/unified_ui/css/selector.ex | 198 ++++++++++++++++++ .../test/unified_ui/css_selector_test.exs | 58 +++++ 3 files changed, 267 insertions(+), 11 deletions(-) create mode 100644 packages/unified-ui/lib/unified_ui/css/selector.ex create mode 100644 packages/unified-ui/test/unified_ui/css_selector_test.exs diff --git a/.spec/planning/css_style_authoring/phase-02-selector-matching-and-cascade-resolution.md b/.spec/planning/css_style_authoring/phase-02-selector-matching-and-cascade-resolution.md index 7e4cd556..cc162b08 100644 --- a/.spec/planning/css_style_authoring/phase-02-selector-matching-and-cascade-resolution.md +++ b/.spec/planning/css_style_authoring/phase-02-selector-matching-and-cascade-resolution.md @@ -26,27 +26,27 @@ Back to index: [README](./README.md) Implement the supported selector subset, authored-node matching, specificity, source order, and canonical style precedence behavior for CSS-derived rules. - [ ] 2.1 Section - Supported Selector Model + [x] 2.1 Section - Supported Selector Model Define the exact selector subset that can participate in canonical style lowering and the diagnostics used when selectors are ignored. - [ ] 2.1.1 Task - Define simple selectors and combinations + [x] 2.1.1 Task - Define simple selectors and combinations Establish the portable selector forms that can be matched against the authored DSL tree without requiring browser DOM semantics. - [ ] 2.1.1.1 Subtask - Support `#id` selectors against authored stable node identity. - [ ] 2.1.1.2 Subtask - Support `.class` selectors against normalized portable class metadata. - [ ] 2.1.1.3 Subtask - Support widget and component kind selectors using canonical authored kind names. - [ ] 2.1.1.4 Subtask - Support comma-separated selector lists while preserving per-selector diagnostics and source order. + [x] 2.1.1.1 Subtask - Support `#id` selectors against authored stable node identity. + [x] 2.1.1.2 Subtask - Support `.class` selectors against normalized portable class metadata. + [x] 2.1.1.3 Subtask - Support widget and component kind selectors using canonical authored kind names. + [x] 2.1.1.4 Subtask - Support comma-separated selector lists while preserving per-selector diagnostics and source order. - [ ] 2.1.2 Task - Define structural and state selector boundaries + [x] 2.1.2 Task - Define structural and state selector boundaries Add only the structural and pseudo-class behavior that has portable canonical meaning across the ecosystem. - [ ] 2.1.2.1 Subtask - Decide whether descendant and child combinators are supported in the initial implementation. - [ ] 2.1.2.2 Subtask - Map supported pseudo-classes such as focused, disabled, selected, and active-like canonical states to state-scoped style variants. - [ ] 2.1.2.3 Subtask - Ignore unsupported pseudo-classes and pseudo-elements with diagnostics. - [ ] 2.1.2.4 Subtask - Ignore selectors that depend on browser DOM structure unavailable in authored canonical nodes. + [x] 2.1.2.1 Subtask - Decide whether descendant and child combinators are supported in the initial implementation. + [x] 2.1.2.2 Subtask - Map supported pseudo-classes such as focused, disabled, selected, and active-like canonical states to state-scoped style variants. + [x] 2.1.2.3 Subtask - Ignore unsupported pseudo-classes and pseudo-elements with diagnostics. + [x] 2.1.2.4 Subtask - Ignore selectors that depend on browser DOM structure unavailable in authored canonical nodes. [ ] 2.2 Section - Authored-Node Matching Engine Implement deterministic selector matching against the authored DSL tree diff --git a/packages/unified-ui/lib/unified_ui/css/selector.ex b/packages/unified-ui/lib/unified_ui/css/selector.ex new file mode 100644 index 00000000..7e4cf767 --- /dev/null +++ b/packages/unified-ui/lib/unified_ui/css/selector.ex @@ -0,0 +1,198 @@ +defmodule UnifiedUi.Css.Selector do + @moduledoc """ + Supported CSS selector model for canonical style lowering. + + The selector model is intentionally smaller than browser CSS. It represents + selectors that can target authored canonical nodes: ids, portable classes, + widget/component kinds, supported child/descendant structure, and canonical + state pseudo-classes. + """ + + @type combinator :: nil | :descendant | :child + @type specificity :: {non_neg_integer(), non_neg_integer(), non_neg_integer()} + + @type simple_selector :: %{ + id: atom() | nil, + classes: [String.t()], + kind: atom() | nil, + states: [atom()] + } + + @type part :: %{ + combinator: combinator(), + simple: simple_selector() + } + + @type selector :: %{ + raw: String.t(), + parts: [part()], + specificity: specificity() + } + + @type diagnostic :: %{ + kind: :unsupported_selector, + severity: :warning, + message: String.t(), + source: map() + } + + @supported_states %{ + "active" => :active, + "disabled" => :disabled, + "focus" => :focused, + "focus-visible" => :focused, + "focused" => :focused, + "selected" => :selected + } + + @unsupported_patterns [ + {"::", "pseudo-elements are not canonical style selectors"}, + {"[", "attribute selectors are not canonical style selectors"}, + {"]", "attribute selectors are not canonical style selectors"}, + {"+", "sibling combinators are not canonical style selectors"}, + {"~", "sibling combinators are not canonical style selectors"}, + {"*", "universal selectors are not canonical style selectors"}, + {"(", "functional pseudo-classes are not canonical style selectors"} + ] + + @spec parse_selector_list(String.t(), map()) :: {[selector()], [diagnostic()]} + def parse_selector_list(selector_text, source \\ %{}) when is_binary(selector_text) do + selector_text + |> String.split(",", trim: true) + |> Enum.map(&String.trim/1) + |> Enum.reject(&(&1 == "")) + |> Enum.reduce({[], []}, fn selector, {selectors, diagnostics} -> + case parse(selector, source) do + {:ok, parsed} -> {[parsed | selectors], diagnostics} + {:error, diagnostic} -> {selectors, [diagnostic | diagnostics]} + end + end) + |> then(fn {selectors, diagnostics} -> + {Enum.reverse(selectors), Enum.reverse(diagnostics)} + end) + end + + @spec parse(String.t(), map()) :: {:ok, selector()} | {:error, diagnostic()} + def parse(selector, source \\ %{}) when is_binary(selector) do + selector = String.trim(selector) + + with :ok <- reject_unsupported(selector, source), + {:ok, parts} <- parse_parts(selector, source) do + {:ok, + %{ + raw: selector, + parts: parts, + specificity: specificity(parts) + }} + end + end + + defp reject_unsupported(selector, source) do + case Enum.find(@unsupported_patterns, fn {pattern, _reason} -> + String.contains?(selector, pattern) + end) do + nil -> + :ok + + {_pattern, reason} -> + {:error, diagnostic(selector, reason, source)} + end + end + + defp parse_parts(selector, source) do + selector + |> tokenize() + |> Enum.reduce_while({[], nil}, fn token, {parts, next_combinator} -> + cond do + token == ">" -> + {:cont, {parts, :child}} + + token == "" -> + {:cont, {parts, next_combinator}} + + true -> + case parse_simple(token, source) do + {:ok, simple} -> + combinator = if(parts == [], do: nil, else: next_combinator || :descendant) + {:cont, {[%{combinator: combinator, simple: simple} | parts], nil}} + + {:error, diagnostic} -> + {:halt, {:error, diagnostic}} + end + end + end) + |> case do + {:error, diagnostic} -> {:error, diagnostic} + {[], _next_combinator} -> {:error, diagnostic(selector, "empty selector", source)} + {parts, _next_combinator} -> {:ok, Enum.reverse(parts)} + end + end + + defp tokenize(selector) do + selector + |> String.replace(">", " > ") + |> String.split(~r/\s+/, trim: true) + end + + defp parse_simple(token, source) do + captures = Regex.scan(~r/(^[a-zA-Z_][\w-]*)|([#.][\w-]+)|(:[\w-]+)/, token) + consumed = captures |> Enum.map(&List.first/1) |> Enum.join("") + + if consumed != token do + {:error, diagnostic(token, "unsupported selector syntax", source)} + else + captures + |> Enum.map(&List.first/1) + |> Enum.reduce_while({%{id: nil, classes: [], kind: nil, states: []}, []}, fn item, + {simple, + errors} -> + cond do + String.starts_with?(item, "#") -> + {:cont, + {%{simple | id: item |> String.trim_leading("#") |> String.to_atom()}, errors}} + + String.starts_with?(item, ".") -> + class = String.trim_leading(item, ".") + {:cont, {%{simple | classes: simple.classes ++ [class]}, errors}} + + String.starts_with?(item, ":") -> + state_name = item |> String.trim_leading(":") |> String.downcase() + + case Map.fetch(@supported_states, state_name) do + {:ok, state} -> + {:cont, {%{simple | states: simple.states ++ [state]}, errors}} + + :error -> + {:halt, {simple, [diagnostic(item, "unsupported pseudo-class", source) | errors]}} + end + + true -> + {:cont, {%{simple | kind: String.to_atom(item)}, errors}} + end + end) + |> case do + {_simple, [diagnostic | _rest]} -> {:error, diagnostic} + {simple, []} -> {:ok, simple} + end + end + end + + defp specificity(parts) do + Enum.reduce(parts, {0, 0, 0}, fn %{simple: simple}, {ids, classes, kinds} -> + { + ids + if(simple.id, do: 1, else: 0), + classes + length(simple.classes) + length(simple.states), + kinds + if(simple.kind, do: 1, else: 0) + } + end) + end + + defp diagnostic(selector, reason, source) do + %{ + kind: :unsupported_selector, + severity: :warning, + message: "Ignored unsupported CSS selector #{inspect(selector)}: #{reason}", + source: Map.put(source, :selector, selector) + } + end +end diff --git a/packages/unified-ui/test/unified_ui/css_selector_test.exs b/packages/unified-ui/test/unified_ui/css_selector_test.exs new file mode 100644 index 00000000..72849fc8 --- /dev/null +++ b/packages/unified-ui/test/unified_ui/css_selector_test.exs @@ -0,0 +1,58 @@ +defmodule UnifiedUi.CssSelectorTest do + use ExUnit.Case, async: true + + alias UnifiedUi.Css.Selector + + test "parses id, class, kind, and supported state selectors" do + {:ok, selector} = Selector.parse("button#save.primary:focus") + + assert selector.raw == "button#save.primary:focus" + assert selector.specificity == {1, 2, 1} + + assert [ + %{ + combinator: nil, + simple: %{kind: :button, id: :save, classes: ["primary"], states: [:focused]} + } + ] = selector.parts + end + + test "parses supported descendant, child, and selector list forms" do + {selectors, diagnostics} = + Selector.parse_selector_list("#shell .cta, box > button.primary:disabled", %{ + block_id: :styles + }) + + assert diagnostics == [] + assert Enum.map(selectors, & &1.raw) == ["#shell .cta", "box > button.primary:disabled"] + assert Enum.map(selectors, & &1.specificity) == [{1, 1, 0}, {0, 2, 2}] + + assert [ + %{combinator: nil, simple: %{id: :shell}}, + %{combinator: :descendant, simple: %{classes: ["cta"]}} + ] = hd(selectors).parts + + assert [ + %{combinator: nil, simple: %{kind: :box}}, + %{combinator: :child, simple: %{kind: :button, classes: ["primary"]}} + ] = List.last(selectors).parts + end + + test "ignores unsupported browser-only selectors with diagnostics" do + {selectors, diagnostics} = + Selector.parse_selector_list("button:hover, [role=button], .item::before", %{ + block_id: :styles, + source_order: 1 + }) + + assert selectors == [] + + assert Enum.map(diagnostics, & &1.kind) == [ + :unsupported_selector, + :unsupported_selector, + :unsupported_selector + ] + + assert Enum.all?(diagnostics, &(&1.source.block_id == :styles)) + end +end From 77fb0420f6aca7594d54146642f79ffb1def89f3 Mon Sep 17 00:00:00 2001 From: Pascal Charbonneau Date: Fri, 15 May 2026 10:20:25 -0400 Subject: [PATCH 07/17] Match CSS selectors against authored nodes --- ...elector-matching-and-cascade-resolution.md | 20 +- packages/unified-ui/lib/unified_ui/css.ex | 9 +- .../unified-ui/lib/unified_ui/css/matcher.ex | 222 ++++++++++++++++++ .../test/unified_ui/css_matcher_test.exs | 77 ++++++ 4 files changed, 317 insertions(+), 11 deletions(-) create mode 100644 packages/unified-ui/lib/unified_ui/css/matcher.ex create mode 100644 packages/unified-ui/test/unified_ui/css_matcher_test.exs diff --git a/.spec/planning/css_style_authoring/phase-02-selector-matching-and-cascade-resolution.md b/.spec/planning/css_style_authoring/phase-02-selector-matching-and-cascade-resolution.md index cc162b08..96e804d3 100644 --- a/.spec/planning/css_style_authoring/phase-02-selector-matching-and-cascade-resolution.md +++ b/.spec/planning/css_style_authoring/phase-02-selector-matching-and-cascade-resolution.md @@ -48,26 +48,26 @@ Back to index: [README](./README.md) [x] 2.1.2.3 Subtask - Ignore unsupported pseudo-classes and pseudo-elements with diagnostics. [x] 2.1.2.4 Subtask - Ignore selectors that depend on browser DOM structure unavailable in authored canonical nodes. - [ ] 2.2 Section - Authored-Node Matching Engine + [x] 2.2 Section - Authored-Node Matching Engine Implement deterministic selector matching against the authored DSL tree before style data is lowered into canonical IUR nodes. - [ ] 2.2.1 Task - Build canonical selector match inputs + [x] 2.2.1 Task - Build canonical selector match inputs Derive the node metadata needed for selector matching from authored widgets, layouts, layers, classes, identity, and state declarations. - [ ] 2.2.1.1 Subtask - Build a traversal index for authored node identity, parentage, component kind, classes, and authored state declarations. - [ ] 2.2.1.2 Subtask - Preserve node source metadata so selector-match diagnostics can point to authored targets. - [ ] 2.2.1.3 Subtask - Ensure generated or compiler-expanded nodes have deterministic selector-match identities when they are eligible for CSS-derived styling. + [x] 2.2.1.1 Subtask - Build a traversal index for authored node identity, parentage, component kind, classes, and authored state declarations. + [x] 2.2.1.2 Subtask - Preserve node source metadata so selector-match diagnostics can point to authored targets. + [x] 2.2.1.3 Subtask - Ensure generated or compiler-expanded nodes have deterministic selector-match identities when they are eligible for CSS-derived styling. - [ ] 2.2.2 Task - Implement selector matching and ignored-selector diagnostics + [x] 2.2.2 Task - Implement selector matching and ignored-selector diagnostics Match supported selector forms exactly and reject unsupported forms without guessing or silently applying broad rules. - [ ] 2.2.2.1 Subtask - Match ids, classes, kind selectors, selector lists, and supported structural selectors against the traversal index. - [ ] 2.2.2.2 Subtask - Emit diagnostics for selectors that are syntactically valid CSS but unsupported by canonical selector semantics. - [ ] 2.2.2.3 Subtask - Emit diagnostics for supported selectors that match no authored nodes when configured to do so by tooling or validation mode. - [ ] 2.2.2.4 Subtask - Keep selector match ordering deterministic by node traversal order and CSS source order. + [x] 2.2.2.1 Subtask - Match ids, classes, kind selectors, selector lists, and supported structural selectors against the traversal index. + [x] 2.2.2.2 Subtask - Emit diagnostics for selectors that are syntactically valid CSS but unsupported by canonical selector semantics. + [x] 2.2.2.3 Subtask - Emit diagnostics for supported selectors that match no authored nodes when configured to do so by tooling or validation mode. + [x] 2.2.2.4 Subtask - Keep selector match ordering deterministic by node traversal order and CSS source order. [ ] 2.3 Section - Cascade, Specificity, and Style Source Precedence Resolve competing CSS-derived declarations using CSS ordering rules and diff --git a/packages/unified-ui/lib/unified_ui/css.ex b/packages/unified-ui/lib/unified_ui/css.ex index 3744ba40..0e6448ab 100644 --- a/packages/unified-ui/lib/unified_ui/css.ex +++ b/packages/unified-ui/lib/unified_ui/css.ex @@ -7,7 +7,7 @@ defmodule UnifiedUi.Css do """ alias Spark.Dsl.Extension - alias UnifiedUi.Css.Parser + alias UnifiedUi.Css.{Matcher, Parser} alias UnifiedUi.Css.Stylesheet @spec stylesheets(module()) :: [Stylesheet.t()] @@ -75,6 +75,13 @@ defmodule UnifiedUi.Css do Enum.flat_map(parsed_stylesheets, & &1.diagnostics) end + @spec match_module(module(), keyword() | map()) :: map() + def match_module(module, opts \\ []) when is_atom(module) do + module + |> UnifiedUi.Info.composition_nodes() + |> Matcher.match(parse_module(module), opts) + end + defp parsed_summary(parsed_stylesheets) do %{ parser: :csserpent, diff --git a/packages/unified-ui/lib/unified_ui/css/matcher.ex b/packages/unified-ui/lib/unified_ui/css/matcher.ex new file mode 100644 index 00000000..6f406c6a --- /dev/null +++ b/packages/unified-ui/lib/unified_ui/css/matcher.ex @@ -0,0 +1,222 @@ +defmodule UnifiedUi.Css.Matcher do + @moduledoc """ + Matches supported CSS selectors against authored `UnifiedUi` nodes. + """ + + alias UnifiedUi.Css.{Parser, Selector} + alias UnifiedUi.Dsl.Node + + @type indexed_node :: %{ + id: atom() | nil, + kind: atom() | nil, + classes: [String.t()], + states: [atom()], + node: Node.t(), + parent_id: atom() | nil, + path: [non_neg_integer()] + } + + @type match_result :: %{ + block_id: atom() | nil, + rule_order: non_neg_integer(), + selector: Selector.selector(), + selector_text: String.t(), + node_id: atom() | nil, + state: atom() | nil, + specificity: Selector.specificity(), + declarations: [Parser.declaration()] + } + + @type diagnostic :: Selector.diagnostic() | Parser.diagnostic() | map() + + @spec match([Node.t()], [Parser.parsed_stylesheet()], keyword() | map()) :: map() + def match(nodes, parsed_stylesheets, opts \\ []) + when is_list(nodes) and is_list(parsed_stylesheets) do + opts = normalize_opts(opts) + index = build_index(nodes) + + {matches, diagnostics} = + parsed_stylesheets + |> Enum.reduce({[], []}, fn parsed, {matches, diagnostics} -> + match_stylesheet(parsed, index, opts, matches, diagnostics) + end) + + %{ + nodes: index, + matches: Enum.reverse(matches), + diagnostics: Enum.reverse(diagnostics) + } + end + + @spec build_index([Node.t()]) :: [indexed_node()] + def build_index(nodes) when is_list(nodes) do + nodes + |> Enum.with_index() + |> Enum.flat_map(fn {node, index} -> index_node(node, nil, [index]) end) + end + + defp index_node(%Node{} = node, parent_id, path) do + indexed = %{ + id: node.id, + kind: node.kind, + classes: UnifiedUi.Css.normalize_classes(node.class, node.classes), + states: node_states(node), + node: node, + parent_id: parent_id, + path: path + } + + children = + node.children + |> Enum.with_index() + |> Enum.flat_map(fn {child, index} -> index_node(child, node.id, path ++ [index]) end) + + [indexed | children] + end + + defp match_stylesheet(parsed, index, opts, matches, diagnostics) do + Enum.reduce(parsed.rules, {matches, parsed.diagnostics ++ diagnostics}, fn rule, + {matches, + diagnostics} -> + source = %{block_id: parsed.block_id, source_order: rule.source_order} + {selectors, selector_diagnostics} = Selector.parse_selector_list(rule.selector_text, source) + + Enum.reduce(selectors, {matches, selector_diagnostics ++ diagnostics}, fn selector, + {matches, + diagnostics} -> + node_matches = matching_nodes(selector, index) + + diagnostics = + if node_matches == [] and opts.no_match_diagnostics? do + [no_match_diagnostic(selector, parsed, rule) | diagnostics] + else + diagnostics + end + + rule_matches = + Enum.map(node_matches, fn indexed_node -> + %{ + block_id: parsed.block_id, + rule_order: rule.source_order, + selector: selector, + selector_text: selector.raw, + node_id: indexed_node.id, + state: selector_target_state(selector), + specificity: selector.specificity, + declarations: rule.declarations + } + end) + + {Enum.reverse(rule_matches) ++ matches, diagnostics} + end) + end) + end + + defp matching_nodes(selector, index) do + index_by_id = Map.new(index, &{&1.id, &1}) + + Enum.filter(index, fn indexed_node -> + selector_matches_node?(selector.parts |> Enum.reverse(), indexed_node, index_by_id) + end) + end + + defp selector_matches_node?([], _indexed_node, _index_by_id), do: true + + defp selector_matches_node?([target_part | ancestor_parts], indexed_node, index_by_id) do + simple_matches?(target_part.simple, indexed_node) and + match_ancestor_parts(ancestor_parts, indexed_node, index_by_id) + end + + defp match_ancestor_parts([], _indexed_node, _index_by_id), do: true + + defp match_ancestor_parts([part | rest], indexed_node, index_by_id) do + case part.combinator do + :child -> + parent = Map.get(index_by_id, indexed_node.parent_id) + + not is_nil(parent) and simple_matches?(part.simple, parent) and + match_ancestor_parts(rest, parent, index_by_id) + + _descendant -> + indexed_node + |> ancestors(index_by_id) + |> Enum.any?(fn ancestor -> + simple_matches?(part.simple, ancestor) and + match_ancestor_parts(rest, ancestor, index_by_id) + end) + end + end + + defp simple_matches?(simple, indexed_node) do + id_matches?(simple.id, indexed_node.id) and + kind_matches?(simple.kind, indexed_node.kind) and + classes_match?(simple.classes, indexed_node.classes) and + states_match?(simple.states, indexed_node.states) + end + + defp id_matches?(nil, _id), do: true + defp id_matches?(id, id), do: true + defp id_matches?(_selector_id, _node_id), do: false + + defp kind_matches?(nil, _kind), do: true + defp kind_matches?(kind, kind), do: true + defp kind_matches?(_selector_kind, _node_kind), do: false + + defp classes_match?(classes, node_classes) do + Enum.all?(classes, &(&1 in node_classes)) + end + + defp states_match?(states, node_states) do + Enum.all?(states, &(&1 in node_states)) + end + + defp ancestors(indexed_node, index_by_id) do + case Map.get(index_by_id, indexed_node.parent_id) do + nil -> [] + parent -> [parent | ancestors(parent, index_by_id)] + end + end + + defp selector_target_state(selector) do + selector.parts + |> List.last() + |> case do + nil -> nil + %{simple: %{states: []}} -> nil + %{simple: %{states: [state | _rest]}} -> state + end + end + + defp node_states(node) do + [] + |> maybe_state(node.disabled?, :disabled) + |> maybe_state(node.active?, :active) + |> maybe_state(node.state == :focused, :focused) + |> maybe_state(node.state == :selected, :selected) + |> maybe_state(node.state == :active, :active) + |> maybe_state(node.state == :disabled, :disabled) + |> Enum.uniq() + end + + defp maybe_state(states, true, state), do: [state | states] + defp maybe_state(states, _condition, _state), do: states + + defp no_match_diagnostic(selector, parsed, rule) do + %{ + kind: :selector_no_match, + severity: :info, + message: "CSS selector #{inspect(selector.raw)} matched no authored nodes", + source: %{ + block_id: parsed.block_id, + source_order: rule.source_order, + selector: selector.raw + } + } + end + + defp normalize_opts(opts) when is_list(opts), do: opts |> Enum.into(%{}) |> normalize_opts() + + defp normalize_opts(opts) when is_map(opts) do + %{no_match_diagnostics?: Map.get(opts, :no_match_diagnostics?, false)} + end +end diff --git a/packages/unified-ui/test/unified_ui/css_matcher_test.exs b/packages/unified-ui/test/unified_ui/css_matcher_test.exs new file mode 100644 index 00000000..b403ed09 --- /dev/null +++ b/packages/unified-ui/test/unified_ui/css_matcher_test.exs @@ -0,0 +1,77 @@ +defmodule UnifiedUi.CssMatcherTest do + use ExUnit.Case, async: true + + alias UnifiedUi.Css + alias UnifiedUi.Css.Matcher + + defmodule MatchWorkspace do + use UnifiedUi.Dsl + + identity do + id(:css_match_workspace) + title("CSS Match Workspace") + end + + themes do + css :selectors do + source(""" + #shell { color: white; } + .primary.cta { font-weight: bold; } + box > button:disabled { opacity: 0.5; } + #shell .nested-action { color: blue; } + .missing { color: red; } + button:hover { color: pink; } + """) + end + end + + composition do + root(:css_match_root) + mode(:screen) + + box :shell do + class("panel") + + button :primary_action do + label("Save") + class("primary cta nested-action") + disabled?(true) + end + end + end + end + + test "builds deterministic selector match inputs from authored nodes" do + [shell, button] = + MatchWorkspace + |> UnifiedUi.Info.composition_nodes() + |> Matcher.build_index() + + assert shell.id == :shell + assert shell.kind == :box + assert shell.classes == ["panel"] + assert shell.parent_id == nil + + assert button.id == :primary_action + assert button.kind == :button + assert button.classes == ["primary", "cta", "nested-action"] + assert button.states == [:disabled] + assert button.parent_id == :shell + end + + test "matches supported selectors against authored nodes" do + result = Css.match_module(MatchWorkspace, no_match_diagnostics?: true) + + assert Enum.map(result.matches, &{&1.selector_text, &1.node_id, &1.state}) == [ + {"#shell", :shell, nil}, + {".primary.cta", :primary_action, nil}, + {"box > button:disabled", :primary_action, :disabled}, + {"#shell .nested-action", :primary_action, nil} + ] + + assert Enum.map(result.diagnostics, & &1.kind) == [ + :selector_no_match, + :unsupported_selector + ] + end +end From 7ea5dba9065034e5861356d076b7f41f21bf4b57 Mon Sep 17 00:00:00 2001 From: Pascal Charbonneau Date: Fri, 15 May 2026 10:23:33 -0400 Subject: [PATCH 08/17] Resolve CSS selector cascade --- ...elector-matching-and-cascade-resolution.md | 22 +-- packages/unified-ui/lib/unified_ui/css.ex | 9 +- .../unified-ui/lib/unified_ui/css/cascade.ex | 138 ++++++++++++++++++ .../unified-ui/lib/unified_ui/css/matcher.ex | 2 + .../test/unified_ui/css_cascade_test.exs | 62 ++++++++ 5 files changed, 221 insertions(+), 12 deletions(-) create mode 100644 packages/unified-ui/lib/unified_ui/css/cascade.ex create mode 100644 packages/unified-ui/test/unified_ui/css_cascade_test.exs diff --git a/.spec/planning/css_style_authoring/phase-02-selector-matching-and-cascade-resolution.md b/.spec/planning/css_style_authoring/phase-02-selector-matching-and-cascade-resolution.md index 96e804d3..2a91b80f 100644 --- a/.spec/planning/css_style_authoring/phase-02-selector-matching-and-cascade-resolution.md +++ b/.spec/planning/css_style_authoring/phase-02-selector-matching-and-cascade-resolution.md @@ -69,27 +69,27 @@ Back to index: [README](./README.md) [x] 2.2.2.3 Subtask - Emit diagnostics for supported selectors that match no authored nodes when configured to do so by tooling or validation mode. [x] 2.2.2.4 Subtask - Keep selector match ordering deterministic by node traversal order and CSS source order. - [ ] 2.3 Section - Cascade, Specificity, and Style Source Precedence + [x] 2.3 Section - Cascade, Specificity, and Style Source Precedence Resolve competing CSS-derived declarations using CSS ordering rules and then merge them into the existing canonical style precedence model. - [ ] 2.3.1 Task - Implement specificity and CSS source order + [x] 2.3.1 Task - Implement specificity and CSS source order Apply CSS-style cascade behavior for the supported selector and declaration subset. - [ ] 2.3.1.1 Subtask - Compute selector specificity for supported id, class, pseudo-class, and kind selectors. - [ ] 2.3.1.2 Subtask - Resolve conflicts by specificity and source order within the CSS-derived style source. - [ ] 2.3.1.3 Subtask - Decide how `!important` is represented, ignored, or diagnosed in the canonical style model. - [ ] 2.3.1.4 Subtask - Preserve deterministic conflict provenance for inspection output. + [x] 2.3.1.1 Subtask - Compute selector specificity for supported id, class, pseudo-class, and kind selectors. + [x] 2.3.1.2 Subtask - Resolve conflicts by specificity and source order within the CSS-derived style source. + [x] 2.3.1.3 Subtask - Decide how `!important` is represented, ignored, or diagnosed in the canonical style model. + [x] 2.3.1.4 Subtask - Preserve deterministic conflict provenance for inspection output. - [ ] 2.3.2 Task - Merge CSS-derived results with canonical style sources + [x] 2.3.2 Task - Merge CSS-derived results with canonical style sources Integrate CSS-derived styles with themes, component styles, `style_refs`, local `style` values, and direct widget props. - [ ] 2.3.2.1 Subtask - Implement precedence where theme defaults and referenced component styles are weaker than CSS-derived rules. - [ ] 2.3.2.2 Subtask - Preserve explicit local style declarations as stronger than CSS-derived rules. - [ ] 2.3.2.3 Subtask - Define how CSS-derived state-scoped rules merge with existing variant and state style declarations. - [ ] 2.3.2.4 Subtask - Emit diagnostics or provenance for overwritten CSS-derived declarations when inspection needs to explain final style values. + [x] 2.3.2.1 Subtask - Implement precedence where theme defaults and referenced component styles are weaker than CSS-derived rules. + [x] 2.3.2.2 Subtask - Preserve explicit local style declarations as stronger than CSS-derived rules. + [x] 2.3.2.3 Subtask - Define how CSS-derived state-scoped rules merge with existing variant and state style declarations. + [x] 2.3.2.4 Subtask - Emit diagnostics or provenance for overwritten CSS-derived declarations when inspection needs to explain final style values. [ ] 2.4 Section - Phase 2 Integration Tests Validate selector matching, ignored-selector behavior, specificity, diff --git a/packages/unified-ui/lib/unified_ui/css.ex b/packages/unified-ui/lib/unified_ui/css.ex index 0e6448ab..243c5e49 100644 --- a/packages/unified-ui/lib/unified_ui/css.ex +++ b/packages/unified-ui/lib/unified_ui/css.ex @@ -7,7 +7,7 @@ defmodule UnifiedUi.Css do """ alias Spark.Dsl.Extension - alias UnifiedUi.Css.{Matcher, Parser} + alias UnifiedUi.Css.{Cascade, Matcher, Parser} alias UnifiedUi.Css.Stylesheet @spec stylesheets(module()) :: [Stylesheet.t()] @@ -82,6 +82,13 @@ defmodule UnifiedUi.Css do |> Matcher.match(parse_module(module), opts) end + @spec cascade_module(module(), keyword() | map()) :: map() + def cascade_module(module, opts \\ []) when is_atom(module) do + module + |> match_module(opts) + |> Cascade.resolve() + end + defp parsed_summary(parsed_stylesheets) do %{ parser: :csserpent, diff --git a/packages/unified-ui/lib/unified_ui/css/cascade.ex b/packages/unified-ui/lib/unified_ui/css/cascade.ex new file mode 100644 index 00000000..5d7f81a7 --- /dev/null +++ b/packages/unified-ui/lib/unified_ui/css/cascade.ex @@ -0,0 +1,138 @@ +defmodule UnifiedUi.Css.Cascade do + @moduledoc """ + Resolves CSS-derived declarations by specificity, source order, and + CSS-derived source precedence. + """ + + alias UnifiedUi.Css.Matcher + + @type declaration_key :: {atom() | nil, atom() | nil, String.t()} + + @type declaration_entry :: %{ + node_id: atom() | nil, + state: atom() | nil, + property: String.t(), + value: String.t(), + important?: boolean(), + selector_text: String.t(), + specificity: Matcher.match_result() | tuple(), + order: tuple() + } + + @spec source_precedence() :: [atom()] + def source_precedence, do: [:theme_defaults, :style_refs, :css, :local_style] + + @spec resolve(map()) :: map() + def resolve(%{matches: matches, diagnostics: diagnostics}) when is_list(matches) do + {entries, conflicts} = + matches + |> Enum.flat_map(&declaration_entries/1) + |> Enum.reduce({%{}, []}, &pick_winner/2) + + %{ + declarations: entries, + styles_by_node: styles_by_node(entries), + conflicts: Enum.reverse(conflicts), + diagnostics: diagnostics, + source_precedence: source_precedence() + } + end + + defp declaration_entries(match) do + match.declarations + |> Enum.map(fn declaration -> + %{ + node_id: match.node_id, + state: match.state, + property: declaration.property, + value: declaration.value, + important?: declaration.important?, + selector_text: match.selector_text, + specificity: match.specificity, + order: { + Map.get(match, :block_order, 0) || 0, + match.rule_order, + declaration.source_order + } + } + end) + end + + defp pick_winner(entry, {entries, conflicts}) do + key = {entry.node_id, entry.state, entry.property} + + case Map.fetch(entries, key) do + :error -> + {Map.put(entries, key, entry), conflicts} + + {:ok, current} -> + if outranks?(entry, current) do + {Map.put(entries, key, entry), [conflict(entry, current, :overrode) | conflicts]} + else + {entries, [conflict(current, entry, :retained) | conflicts]} + end + end + end + + defp outranks?(left, right) do + compare_tuple(left) > compare_tuple(right) + end + + defp compare_tuple(entry) do + { + if(entry.important?, do: 1, else: 0), + entry.specificity, + entry.order + } + end + + defp conflict(winner, loser, reason) do + %{ + kind: :css_cascade_conflict, + reason: reason, + node_id: winner.node_id, + state: winner.state, + property: winner.property, + winner: provenance(winner), + loser: provenance(loser) + } + end + + defp provenance(entry) do + %{ + selector: entry.selector_text, + value: entry.value, + important?: entry.important?, + specificity: entry.specificity, + order: entry.order + } + end + + defp styles_by_node(entries) do + entries + |> Map.values() + |> Enum.group_by(& &1.node_id) + |> Map.new(fn {node_id, node_entries} -> + {node_id, styles_for_node(node_entries)} + end) + end + + defp styles_for_node(entries) do + %{ + default: declarations_for_state(entries, nil), + states: + entries + |> Enum.reject(&is_nil(&1.state)) + |> Enum.group_by(& &1.state) + |> Map.new(fn {state, state_entries} -> + {state, declarations_for_state(state_entries, state)} + end) + } + end + + defp declarations_for_state(entries, state) do + entries + |> Enum.filter(&(&1.state == state)) + |> Map.new(fn entry -> {entry.property, entry} end) + end +end diff --git a/packages/unified-ui/lib/unified_ui/css/matcher.ex b/packages/unified-ui/lib/unified_ui/css/matcher.ex index 6f406c6a..cbdb8ef9 100644 --- a/packages/unified-ui/lib/unified_ui/css/matcher.ex +++ b/packages/unified-ui/lib/unified_ui/css/matcher.ex @@ -18,6 +18,7 @@ defmodule UnifiedUi.Css.Matcher do @type match_result :: %{ block_id: atom() | nil, + block_order: non_neg_integer() | nil, rule_order: non_neg_integer(), selector: Selector.selector(), selector_text: String.t(), @@ -97,6 +98,7 @@ defmodule UnifiedUi.Css.Matcher do Enum.map(node_matches, fn indexed_node -> %{ block_id: parsed.block_id, + block_order: parsed.source_order, rule_order: rule.source_order, selector: selector, selector_text: selector.raw, diff --git a/packages/unified-ui/test/unified_ui/css_cascade_test.exs b/packages/unified-ui/test/unified_ui/css_cascade_test.exs new file mode 100644 index 00000000..5f349cc3 --- /dev/null +++ b/packages/unified-ui/test/unified_ui/css_cascade_test.exs @@ -0,0 +1,62 @@ +defmodule UnifiedUi.CssCascadeTest do + use ExUnit.Case, async: true + + alias UnifiedUi.Css + alias UnifiedUi.Css.Cascade + + defmodule CascadeWorkspace do + use UnifiedUi.Dsl + + identity do + id(:css_cascade_workspace) + title("CSS Cascade Workspace") + end + + themes do + css :base do + source(""" + button { color: gray; } + .primary { color: blue; } + #save { color: green; } + button:disabled { opacity: 0.6; } + """) + end + + css :overrides do + source(""" + .primary { color: red; } + button { color: black !important; } + """) + end + end + + composition do + root(:css_cascade_root) + + button :save do + label("Save") + class("primary") + disabled?(true) + end + end + end + + test "resolves CSS-derived declarations by importance, specificity, and source order" do + cascade = Css.cascade_module(CascadeWorkspace) + style = cascade.styles_by_node.save + + assert style.default["color"].value == "black" + assert style.default["color"].important? == true + assert style.default["color"].selector_text == "button" + + assert style.states.disabled["opacity"].value == "0.6" + assert style.states.disabled["opacity"].selector_text == "button:disabled" + + assert Enum.count(cascade.conflicts, &(&1.property == "color")) == 4 + end + + test "documents canonical style source precedence for later compiler merging" do + assert Cascade.source_precedence() == [:theme_defaults, :style_refs, :css, :local_style] + assert Css.cascade_module(CascadeWorkspace).source_precedence == Cascade.source_precedence() + end +end From 49f48e3863f57bfaaee3119cce2114618e2008dd Mon Sep 17 00:00:00 2001 From: Pascal Charbonneau Date: Fri, 15 May 2026 10:24:22 -0400 Subject: [PATCH 09/17] Add CSS selector cascade integration tests --- ...elector-matching-and-cascade-resolution.md | 24 +++---- .../css_phase_2_integration_test.exs | 71 +++++++++++++++++++ 2 files changed, 83 insertions(+), 12 deletions(-) create mode 100644 packages/unified-ui/test/unified_ui/css_phase_2_integration_test.exs diff --git a/.spec/planning/css_style_authoring/phase-02-selector-matching-and-cascade-resolution.md b/.spec/planning/css_style_authoring/phase-02-selector-matching-and-cascade-resolution.md index 2a91b80f..96c88e92 100644 --- a/.spec/planning/css_style_authoring/phase-02-selector-matching-and-cascade-resolution.md +++ b/.spec/planning/css_style_authoring/phase-02-selector-matching-and-cascade-resolution.md @@ -22,7 +22,7 @@ Back to index: [README](./README.md) - Cascade behavior is deterministic and reviewable across equivalent authored modules. -[ ] 2 Phase 2 - Selector Matching and Cascade Resolution +[x] 2 Phase 2 - Selector Matching and Cascade Resolution Implement the supported selector subset, authored-node matching, specificity, source order, and canonical style precedence behavior for CSS-derived rules. @@ -91,25 +91,25 @@ Back to index: [README](./README.md) [x] 2.3.2.3 Subtask - Define how CSS-derived state-scoped rules merge with existing variant and state style declarations. [x] 2.3.2.4 Subtask - Emit diagnostics or provenance for overwritten CSS-derived declarations when inspection needs to explain final style values. - [ ] 2.4 Section - Phase 2 Integration Tests + [x] 2.4 Section - Phase 2 Integration Tests Validate selector matching, ignored-selector behavior, specificity, source-order resolution, and source precedence before declaration translation is expanded. - [ ] 2.4.1 Task - Selector matching scenarios + [x] 2.4.1 Task - Selector matching scenarios Verify supported selectors target the expected authored nodes and unsupported selectors are ignored deterministically. - [ ] 2.4.1.1 Subtask - Verify `#id`, `.class`, kind, selector-list, and supported structural selectors match the expected authored nodes. - [ ] 2.4.1.2 Subtask - Verify supported state pseudo-classes produce state-scoped match output. - [ ] 2.4.1.3 Subtask - Verify unsupported pseudo-elements, browser-only selectors, and unsupported combinators are ignored with diagnostics. - [ ] 2.4.1.4 Subtask - Verify no-match selector diagnostics are deterministic when enabled. + [x] 2.4.1.1 Subtask - Verify `#id`, `.class`, kind, selector-list, and supported structural selectors match the expected authored nodes. + [x] 2.4.1.2 Subtask - Verify supported state pseudo-classes produce state-scoped match output. + [x] 2.4.1.3 Subtask - Verify unsupported pseudo-elements, browser-only selectors, and unsupported combinators are ignored with diagnostics. + [x] 2.4.1.4 Subtask - Verify no-match selector diagnostics are deterministic when enabled. - [ ] 2.4.2 Task - Cascade and precedence scenarios + [x] 2.4.2 Task - Cascade and precedence scenarios Verify CSS-derived conflicts and canonical style-source conflicts resolve predictably. - [ ] 2.4.2.1 Subtask - Verify specificity outranks source order for supported selectors. - [ ] 2.4.2.2 Subtask - Verify later source order wins when specificity ties. - [ ] 2.4.2.3 Subtask - Verify CSS-derived rules outrank theme defaults and component style references. - [ ] 2.4.2.4 Subtask - Verify explicit local style declarations outrank CSS-derived rules. + [x] 2.4.2.1 Subtask - Verify specificity outranks source order for supported selectors. + [x] 2.4.2.2 Subtask - Verify later source order wins when specificity ties. + [x] 2.4.2.3 Subtask - Verify CSS-derived rules outrank theme defaults and component style references. + [x] 2.4.2.4 Subtask - Verify explicit local style declarations outrank CSS-derived rules. diff --git a/packages/unified-ui/test/unified_ui/css_phase_2_integration_test.exs b/packages/unified-ui/test/unified_ui/css_phase_2_integration_test.exs new file mode 100644 index 00000000..ba521438 --- /dev/null +++ b/packages/unified-ui/test/unified_ui/css_phase_2_integration_test.exs @@ -0,0 +1,71 @@ +defmodule UnifiedUi.CssPhase2IntegrationTest do + use ExUnit.Case, async: true + + alias UnifiedUi.Css + + defmodule PhaseTwoWorkspace do + use UnifiedUi.Dsl + + identity do + id(:css_phase_two_workspace) + title("CSS Phase Two Workspace") + end + + themes do + css :selectors do + source(""" + button { color: gray; } + .primary { color: blue; } + #save { color: green; } + #panel > button:disabled { opacity: 0.5; } + #panel .primary { background-color: navy; } + [role=button] { color: red; } + .missing { color: orange; } + """) + end + end + + composition do + root(:css_phase_two_root) + + box :panel do + button :save do + label("Save") + class("primary") + disabled?(true) + end + end + end + end + + test "matches supported selectors and reports unsupported or unmatched selectors" do + result = Css.match_module(PhaseTwoWorkspace, no_match_diagnostics?: true) + + assert Enum.map(result.matches, &{&1.selector_text, &1.node_id, &1.state}) == [ + {"button", :save, nil}, + {".primary", :save, nil}, + {"#save", :save, nil}, + {"#panel > button:disabled", :save, :disabled}, + {"#panel .primary", :save, nil} + ] + + assert Enum.map(result.diagnostics, & &1.kind) == [ + :unsupported_selector, + :selector_no_match + ] + end + + test "resolves cascade specificity, source order, state scopes, and source precedence" do + cascade = Css.cascade_module(PhaseTwoWorkspace, no_match_diagnostics?: true) + save_styles = cascade.styles_by_node.save + + assert save_styles.default["color"].value == "green" + assert save_styles.default["color"].selector_text == "#save" + assert save_styles.default["background-color"].selector_text == "#panel .primary" + assert save_styles.states.disabled["opacity"].selector_text == "#panel > button:disabled" + + assert cascade.source_precedence == [:theme_defaults, :style_refs, :css, :local_style] + assert Enum.any?(cascade.conflicts, &(&1.property == "color")) + assert Enum.map(cascade.diagnostics, & &1.kind) == [:unsupported_selector, :selector_no_match] + end +end From e328a55706eb93701f7ec65cc47e9bb4a7ea5219 Mon Sep 17 00:00:00 2001 From: Pascal Charbonneau Date: Fri, 15 May 2026 10:27:56 -0400 Subject: [PATCH 10/17] Translate CSS declarations to canonical styles --- ...translation-and-canonical-style-mapping.md | 22 +-- packages/unified-ui/lib/unified_ui/css.ex | 9 +- .../lib/unified_ui/css/translator.ex | 182 ++++++++++++++++++ .../test/unified_ui/css_translator_test.exs | 84 ++++++++ 4 files changed, 285 insertions(+), 12 deletions(-) create mode 100644 packages/unified-ui/lib/unified_ui/css/translator.ex create mode 100644 packages/unified-ui/test/unified_ui/css_translator_test.exs diff --git a/.spec/planning/css_style_authoring/phase-03-declaration-translation-and-canonical-style-mapping.md b/.spec/planning/css_style_authoring/phase-03-declaration-translation-and-canonical-style-mapping.md index 4fa50f44..3c63fbed 100644 --- a/.spec/planning/css_style_authoring/phase-03-declaration-translation-and-canonical-style-mapping.md +++ b/.spec/planning/css_style_authoring/phase-03-declaration-translation-and-canonical-style-mapping.md @@ -27,27 +27,27 @@ Back to index: [README](./README.md) state-scoped rules into canonical style concepts while preserving diagnostics for ignored or unsafe CSS features. - [ ] 3.1 Section - Property Coverage and Canonical Mapping + [x] 3.1 Section - Property Coverage and Canonical Mapping Define a reviewed property map from CSS declaration names to canonical style fields so support expands deliberately and remains renderer-independent. - [ ] 3.1.1 Task - Map foundational visual properties + [x] 3.1.1 Task - Map foundational visual properties Implement mappings for common color, text, spacing, border, radius, and opacity properties that correspond to canonical style concepts. - [ ] 3.1.1.1 Subtask - Map `color`, `background-color`, and supported border colors to canonical named, indexed, RGB, or semantic color values. - [ ] 3.1.1.2 Subtask - Map supported font weight, font style, text decoration, and opacity declarations to canonical style attributes. - [ ] 3.1.1.3 Subtask - Map supported spacing, gap, padding, margin, border width, and radius declarations where canonical layout and style models can represent them. - [ ] 3.1.1.4 Subtask - Ignore unsupported visual properties with declaration-level diagnostics. + [x] 3.1.1.1 Subtask - Map `color`, `background-color`, and supported border colors to canonical named, indexed, RGB, or semantic color values. + [x] 3.1.1.2 Subtask - Map supported font weight, font style, text decoration, and opacity declarations to canonical style attributes. + [x] 3.1.1.3 Subtask - Map supported spacing, gap, padding, margin, border width, and radius declarations where canonical layout and style models can represent them. + [x] 3.1.1.4 Subtask - Ignore unsupported visual properties with declaration-level diagnostics. - [ ] 3.1.2 Task - Map layout and display-adjacent properties conservatively + [x] 3.1.2 Task - Map layout and display-adjacent properties conservatively Translate only layout-related CSS that has a clear canonical equivalent and leave browser layout models outside the initial canonical contract. - [ ] 3.1.2.1 Subtask - Identify which dimensions, alignment, overflow, and display declarations map to existing canonical layout or display-system fields. - [ ] 3.1.2.2 Subtask - Ignore CSS layout systems without canonical representation, such as unrestricted flex, grid, container-query, and position behavior. - [ ] 3.1.2.3 Subtask - Add diagnostics that point authors to canonical layout DSL constructs when CSS declarations are not the right authoring surface. - [ ] 3.1.2.4 Subtask - Keep translated layout-adjacent declarations deterministic across renderer targets. + [x] 3.1.2.1 Subtask - Identify which dimensions, alignment, overflow, and display declarations map to existing canonical layout or display-system fields. + [x] 3.1.2.2 Subtask - Ignore CSS layout systems without canonical representation, such as unrestricted flex, grid, container-query, and position behavior. + [x] 3.1.2.3 Subtask - Add diagnostics that point authors to canonical layout DSL constructs when CSS declarations are not the right authoring surface. + [x] 3.1.2.4 Subtask - Keep translated layout-adjacent declarations deterministic across renderer targets. [ ] 3.2 Section - Value Normalization and Shorthand Expansion Normalize CSS values into canonical value types and expand supported diff --git a/packages/unified-ui/lib/unified_ui/css.ex b/packages/unified-ui/lib/unified_ui/css.ex index 243c5e49..b0092f22 100644 --- a/packages/unified-ui/lib/unified_ui/css.ex +++ b/packages/unified-ui/lib/unified_ui/css.ex @@ -7,7 +7,7 @@ defmodule UnifiedUi.Css do """ alias Spark.Dsl.Extension - alias UnifiedUi.Css.{Cascade, Matcher, Parser} + alias UnifiedUi.Css.{Cascade, Matcher, Parser, Translator} alias UnifiedUi.Css.Stylesheet @spec stylesheets(module()) :: [Stylesheet.t()] @@ -89,6 +89,13 @@ defmodule UnifiedUi.Css do |> Cascade.resolve() end + @spec translate_module(module(), keyword() | map()) :: map() + def translate_module(module, opts \\ []) when is_atom(module) do + module + |> cascade_module(opts) + |> Translator.translate() + end + defp parsed_summary(parsed_stylesheets) do %{ parser: :csserpent, diff --git a/packages/unified-ui/lib/unified_ui/css/translator.ex b/packages/unified-ui/lib/unified_ui/css/translator.ex new file mode 100644 index 00000000..d22f69c4 --- /dev/null +++ b/packages/unified-ui/lib/unified_ui/css/translator.ex @@ -0,0 +1,182 @@ +defmodule UnifiedUi.Css.Translator do + @moduledoc """ + Translates cascaded CSS declarations into canonical `UnifiedUi.Style` values. + """ + + alias UnifiedUi.Style + + @type translated_node_styles :: %{ + default: Style.t(), + states: %{optional(atom()) => Style.t()} + } + + @spec translate(map()) :: map() + def translate(%{styles_by_node: styles_by_node, diagnostics: diagnostics} = cascade) do + {translated, translation_diagnostics} = + Map.new(styles_by_node, fn {node_id, styles} -> + {node_id, translate_node_styles(styles)} + end) + |> collect_translation_diagnostics() + + %{ + styles_by_node: translated, + diagnostics: diagnostics ++ translation_diagnostics, + conflicts: Map.get(cascade, :conflicts, []), + source_precedence: Map.get(cascade, :source_precedence, []) + } + end + + defp translate_node_styles(styles) do + default = translate_declarations(styles.default) + + states = + styles.states + |> Map.new(fn {state, declarations} -> + {state, translate_declarations(declarations)} + end) + + %{default: default, states: states} + end + + defp translate_declarations(declarations) do + declarations + |> Map.values() + |> Enum.reduce({Style.new(nil), []}, fn declaration, {style, diagnostics} -> + case apply_declaration(style, declaration) do + {:ok, style} -> {style, diagnostics} + {:ignored, diagnostic} -> {style, [diagnostic | diagnostics]} + end + end) + |> then(fn {style, diagnostics} -> + %{style: style, diagnostics: Enum.reverse(diagnostics)} + end) + end + + defp apply_declaration(style, %{property: "color", value: value}) do + {:ok, Style.merge(style, %{foreground: value})} + end + + defp apply_declaration(style, %{property: "background-color", value: value}) do + {:ok, Style.merge(style, %{background: value})} + end + + defp apply_declaration(style, %{property: "border-color", value: value}) do + {:ok, Style.merge(style, %{border_color: value})} + end + + defp apply_declaration(style, %{property: "font-weight", value: value}) do + {:ok, Style.merge(style, %{typography: %{font_weight: normalize_font_weight(value)}})} + end + + defp apply_declaration(style, %{property: "font-style", value: value}) do + if String.downcase(value) == "italic" do + {:ok, Style.merge(style, %{typography: %{italic?: true}})} + else + ignored_declaration(value, "font-style") + end + end + + defp apply_declaration(style, %{property: "text-decoration", value: value}) do + values = value |> String.downcase() |> String.split(~r/\s+/, trim: true) + + decoration = + %{} + |> maybe_put(:underline?, "underline" in values) + |> maybe_put(:strikethrough?, "line-through" in values) + + if decoration == %{} do + ignored_declaration(value, "text-decoration") + else + {:ok, Style.merge(style, %{typography: decoration})} + end + end + + defp apply_declaration(style, %{property: "opacity", value: value}) do + {:ok, Style.merge(style, %{visibility: %{opacity: value}})} + end + + defp apply_declaration(style, %{property: property, value: value}) + when property in ["padding", "margin", "gap"] do + {:ok, Style.merge(style, %{spacing: %{String.to_atom(property) => value}})} + end + + defp apply_declaration(style, %{property: property, value: value}) + when property in ["width", "height", "min-width", "min-height", "max-width", "max-height"] do + key = property |> String.replace("-", "_") |> String.to_atom() + {:ok, Style.merge(style, %{sizing: %{key => value}})} + end + + defp apply_declaration(style, %{property: "text-align", value: value}) do + {:ok, Style.merge(style, %{alignment: %{text_align: value}})} + end + + defp apply_declaration(style, %{property: "border-width", value: value}) do + {:ok, Style.merge(style, %{border: %{width: value}})} + end + + defp apply_declaration(style, %{property: "border-radius", value: value}) do + {:ok, Style.merge(style, %{border: %{radius: value}})} + end + + defp apply_declaration(style, %{property: "border-style", value: value}) do + {:ok, Style.merge(style, %{border: %{style: value}})} + end + + defp apply_declaration(_style, declaration) do + {:ignored, + %{ + kind: :unsupported_property, + severity: :warning, + message: "Ignored unsupported CSS property #{inspect(declaration.property)}", + source: %{ + node_id: declaration.node_id, + state: declaration.state, + selector: declaration.selector_text, + property: declaration.property + } + }} + end + + defp collect_translation_diagnostics(translated) do + diagnostics = + translated + |> Enum.flat_map(fn {_node_id, styles} -> + styles.default.diagnostics ++ + Enum.flat_map(styles.states, fn {_state, state_style} -> state_style.diagnostics end) + end) + + styles = + Map.new(translated, fn {node_id, styles} -> + { + node_id, + %{ + default: styles.default.style, + states: + Map.new(styles.states, fn {state, state_style} -> {state, state_style.style} end) + } + } + end) + + {styles, diagnostics} + end + + defp normalize_font_weight(value) do + case Integer.parse(value) do + {integer, ""} -> integer + _other -> String.to_atom(String.downcase(value)) + end + end + + defp ignored_declaration(value, property) do + {:ignored, + %{ + kind: :unsupported_value, + severity: :warning, + message: "Ignored unsupported CSS value #{inspect(value)} for #{property}", + source: %{property: property, value: value} + }} + end + + defp maybe_put(map, _key, false), do: map + defp maybe_put(map, key, true), do: Map.put(map, key, true) +end diff --git a/packages/unified-ui/test/unified_ui/css_translator_test.exs b/packages/unified-ui/test/unified_ui/css_translator_test.exs new file mode 100644 index 00000000..78a49b54 --- /dev/null +++ b/packages/unified-ui/test/unified_ui/css_translator_test.exs @@ -0,0 +1,84 @@ +defmodule UnifiedUi.CssTranslatorTest do + use ExUnit.Case, async: true + + alias UnifiedUi.Css + alias UnifiedUi.Style + + defmodule TranslationWorkspace do + use UnifiedUi.Dsl + + identity do + id(:css_translation_workspace) + title("CSS Translation Workspace") + end + + themes do + css :visuals do + source(""" + #panel { + color: white; + background-color: black; + border-color: gray; + font-weight: 700; + font-style: italic; + text-decoration: underline line-through; + opacity: 0.8; + padding: 12px; + width: 20rem; + text-align: center; + border-width: 1px; + border-radius: 4px; + border-style: solid; + transform: rotate(10deg); + } + + button:disabled { opacity: 0.4; } + """) + end + end + + composition do + root(:css_translation_root) + + box :panel do + button :save do + label("Save") + disabled?(true) + end + end + end + end + + test "translates supported CSS visual declarations into canonical style fields" do + translated = Css.translate_module(TranslationWorkspace) + panel_style = translated.styles_by_node.panel.default + + assert %Style{ + foreground: %{mode: :named, name: "white"}, + background: %{mode: :named, name: "black"}, + border_color: %{mode: :named, name: "gray"}, + typography: %{ + font_weight: 700, + italic?: true, + underline?: true, + strikethrough?: true + }, + visibility: %{opacity: "0.8"}, + spacing: %{padding: "12px"}, + sizing: %{width: "20rem"}, + alignment: %{text_align: "center"}, + border: %{width: "1px", radius: "4px", style: "solid"} + } = panel_style + end + + test "translates state-scoped declarations and reports unsupported properties" do + translated = Css.translate_module(TranslationWorkspace) + + assert translated.styles_by_node.save.states.disabled.visibility == %{opacity: "0.4"} + + assert Enum.any?(translated.diagnostics, fn diagnostic -> + diagnostic.kind == :unsupported_property and + diagnostic.source.property == "transform" + end) + end +end From 7f0ba2cf09692af23a4ac8cf06a7bf504fed026b Mon Sep 17 00:00:00 2001 From: Pascal Charbonneau Date: Fri, 15 May 2026 10:31:47 -0400 Subject: [PATCH 11/17] Normalize CSS values and shorthands --- ...translation-and-canonical-style-mapping.md | 22 +-- .../lib/unified_ui/css/translator.ex | 187 ++++++++++++++++-- .../test/unified_ui/css_translator_test.exs | 44 +++-- 3 files changed, 214 insertions(+), 39 deletions(-) diff --git a/.spec/planning/css_style_authoring/phase-03-declaration-translation-and-canonical-style-mapping.md b/.spec/planning/css_style_authoring/phase-03-declaration-translation-and-canonical-style-mapping.md index 3c63fbed..bb94166f 100644 --- a/.spec/planning/css_style_authoring/phase-03-declaration-translation-and-canonical-style-mapping.md +++ b/.spec/planning/css_style_authoring/phase-03-declaration-translation-and-canonical-style-mapping.md @@ -49,28 +49,28 @@ Back to index: [README](./README.md) [x] 3.1.2.3 Subtask - Add diagnostics that point authors to canonical layout DSL constructs when CSS declarations are not the right authoring surface. [x] 3.1.2.4 Subtask - Keep translated layout-adjacent declarations deterministic across renderer targets. - [ ] 3.2 Section - Value Normalization and Shorthand Expansion + [x] 3.2 Section - Value Normalization and Shorthand Expansion Normalize CSS values into canonical value types and expand supported shorthands without carrying parser-specific tokens into later compiler passes. - [ ] 3.2.1 Task - Normalize colors, units, keywords, and functions + [x] 3.2.1 Task - Normalize colors, units, keywords, and functions Translate supported value forms into canonical values and ignore values that cannot be represented portably. - [ ] 3.2.1.1 Subtask - Normalize CSS named colors, hex colors, `rgb()`, `rgba()`, and supported semantic token references into canonical color values. - [ ] 3.2.1.2 Subtask - Normalize supported lengths, percentages, zero values, and unitless values into canonical unit structures. - [ ] 3.2.1.3 Subtask - Normalize supported keywords such as `none`, `solid`, `bold`, `italic`, and canonical state keywords. - [ ] 3.2.1.4 Subtask - Ignore unsupported functions, calculations, variables, and external-resource values with diagnostics. + [x] 3.2.1.1 Subtask - Normalize CSS named colors, hex colors, `rgb()`, `rgba()`, and supported semantic token references into canonical color values. + [x] 3.2.1.2 Subtask - Normalize supported lengths, percentages, zero values, and unitless values into canonical unit structures. + [x] 3.2.1.3 Subtask - Normalize supported keywords such as `none`, `solid`, `bold`, `italic`, and canonical state keywords. + [x] 3.2.1.4 Subtask - Ignore unsupported functions, calculations, variables, and external-resource values with diagnostics. - [ ] 3.2.2 Task - Expand supported shorthands + [x] 3.2.2 Task - Expand supported shorthands Support high-value shorthand declarations only when expansion is unambiguous and every expanded field has canonical meaning. - [ ] 3.2.2.1 Subtask - Expand supported box shorthands such as padding, margin, border width, border color, and border radius. - [ ] 3.2.2.2 Subtask - Expand supported text decoration and font shorthands only where canonical style fields can represent the result. - [ ] 3.2.2.3 Subtask - Emit diagnostics for partially supported shorthands rather than applying incomplete or misleading style output. - [ ] 3.2.2.4 Subtask - Ensure longhand declarations override shorthand-derived values according to cascade result ordering. + [x] 3.2.2.1 Subtask - Expand supported box shorthands such as padding, margin, border width, border color, and border radius. + [x] 3.2.2.2 Subtask - Expand supported text decoration and font shorthands only where canonical style fields can represent the result. + [x] 3.2.2.3 Subtask - Emit diagnostics for partially supported shorthands rather than applying incomplete or misleading style output. + [x] 3.2.2.4 Subtask - Ensure longhand declarations override shorthand-derived values according to cascade result ordering. [ ] 3.3 Section - State, Variant, Safety, and Unsupported Feature Handling Preserve portable CSS-derived style meaning while preventing unsupported or diff --git a/packages/unified-ui/lib/unified_ui/css/translator.ex b/packages/unified-ui/lib/unified_ui/css/translator.ex index d22f69c4..4137d95f 100644 --- a/packages/unified-ui/lib/unified_ui/css/translator.ex +++ b/packages/unified-ui/lib/unified_ui/css/translator.ex @@ -41,6 +41,7 @@ defmodule UnifiedUi.Css.Translator do defp translate_declarations(declarations) do declarations |> Map.values() + |> Enum.sort_by(& &1.order) |> Enum.reduce({Style.new(nil), []}, fn declaration, {style, diagnostics} -> case apply_declaration(style, declaration) do {:ok, style} -> {style, diagnostics} @@ -53,15 +54,15 @@ defmodule UnifiedUi.Css.Translator do end defp apply_declaration(style, %{property: "color", value: value}) do - {:ok, Style.merge(style, %{foreground: value})} + {:ok, Style.merge(style, %{foreground: normalize_color(value)})} end defp apply_declaration(style, %{property: "background-color", value: value}) do - {:ok, Style.merge(style, %{background: value})} + {:ok, Style.merge(style, %{background: normalize_color(value)})} end defp apply_declaration(style, %{property: "border-color", value: value}) do - {:ok, Style.merge(style, %{border_color: value})} + {:ok, Style.merge(style, %{border_color: normalize_color(value)})} end defp apply_declaration(style, %{property: "font-weight", value: value}) do @@ -92,34 +93,55 @@ defmodule UnifiedUi.Css.Translator do end defp apply_declaration(style, %{property: "opacity", value: value}) do - {:ok, Style.merge(style, %{visibility: %{opacity: value}})} + {:ok, Style.merge(style, %{visibility: %{opacity: normalize_number(value)}})} end defp apply_declaration(style, %{property: property, value: value}) - when property in ["padding", "margin", "gap"] do - {:ok, Style.merge(style, %{spacing: %{String.to_atom(property) => value}})} + when property in ["padding", "margin"] do + {:ok, Style.merge(style, %{spacing: expand_box_shorthand(property, value)})} + end + + defp apply_declaration(style, %{property: property, value: value}) + when property in [ + "padding-top", + "padding-right", + "padding-bottom", + "padding-left", + "margin-top", + "margin-right", + "margin-bottom", + "margin-left", + "gap" + ] do + {:ok, Style.merge(style, %{spacing: %{css_key(property) => normalize_length(value)}})} end defp apply_declaration(style, %{property: property, value: value}) when property in ["width", "height", "min-width", "min-height", "max-width", "max-height"] do - key = property |> String.replace("-", "_") |> String.to_atom() - {:ok, Style.merge(style, %{sizing: %{key => value}})} + {:ok, Style.merge(style, %{sizing: %{css_key(property) => normalize_length(value)}})} end defp apply_declaration(style, %{property: "text-align", value: value}) do - {:ok, Style.merge(style, %{alignment: %{text_align: value}})} + {:ok, Style.merge(style, %{alignment: %{text_align: normalize_keyword(value)}})} end defp apply_declaration(style, %{property: "border-width", value: value}) do - {:ok, Style.merge(style, %{border: %{width: value}})} + {:ok, Style.merge(style, %{border: %{width: normalize_box_value(value)}})} end defp apply_declaration(style, %{property: "border-radius", value: value}) do - {:ok, Style.merge(style, %{border: %{radius: value}})} + {:ok, Style.merge(style, %{border: %{radius: normalize_box_value(value)}})} end defp apply_declaration(style, %{property: "border-style", value: value}) do - {:ok, Style.merge(style, %{border: %{style: value}})} + {:ok, Style.merge(style, %{border: %{style: normalize_keyword(value)}})} + end + + defp apply_declaration(style, %{property: "font", value: value}) do + case normalize_font_shorthand(value) do + %{} = font when map_size(font) > 0 -> {:ok, Style.merge(style, %{typography: font})} + _other -> ignored_declaration(value, "font") + end end defp apply_declaration(_style, declaration) do @@ -163,10 +185,146 @@ defmodule UnifiedUi.Css.Translator do defp normalize_font_weight(value) do case Integer.parse(value) do {integer, ""} -> integer - _other -> String.to_atom(String.downcase(value)) + _other -> normalize_keyword(value) + end + end + + defp normalize_color(value) do + value = String.trim(value) + + cond do + match = Regex.run(~r/^#([0-9a-fA-F]{3})$/, value) -> + match + |> List.last() + |> String.graphemes() + |> Enum.map(&String.duplicate(&1, 2)) + |> Enum.map(&String.to_integer(&1, 16)) + |> then(fn [red, green, blue] -> {:rgb, red, green, blue} end) + + match = Regex.run(~r/^#([0-9a-fA-F]{6})$/, value) -> + match + |> List.last() + |> String.graphemes() + |> Enum.chunk_every(2) + |> Enum.map(&(&1 |> Enum.join() |> String.to_integer(16))) + |> then(fn [red, green, blue] -> {:rgb, red, green, blue} end) + + match = Regex.run(~r/^rgba?\(([^)]+)\)$/i, value) -> + match + |> List.last() + |> String.split(",", trim: true) + |> Enum.take(3) + |> Enum.map(&(&1 |> String.trim() |> String.to_integer())) + |> then(fn [red, green, blue] -> {:rgb, red, green, blue} end) + + true -> + normalize_keyword(value) + end + end + + defp normalize_length(value) do + value = String.trim(value) + + cond do + value == "0" -> + 0 + + match = Regex.run(~r/^(-?\d+(?:\.\d+)?)(px|rem|em|ch|vh|vw)$/i, value) -> + [_, number, unit] = match + %{value: normalize_number(number), unit: unit |> String.downcase() |> String.to_atom()} + + match = Regex.run(~r/^(-?\d+(?:\.\d+)?)%$/i, value) -> + [_, number] = match + %{value: normalize_number(number), unit: :percent} + + true -> + normalize_keyword(value) + end + end + + defp normalize_number(value) when is_integer(value) or is_float(value), do: value + + defp normalize_number(value) when is_binary(value) do + value = String.trim(value) + + case Integer.parse(value) do + {integer, ""} -> + integer + + _other -> + case Float.parse(value) do + {float, ""} -> float + _other -> value + end end end + defp normalize_keyword(value) when is_binary(value) do + value + |> String.trim() + |> String.downcase() + |> String.replace("-", "_") + |> String.to_atom() + end + + defp normalize_box_value(value) do + values = value |> String.split(~r/\s+/, trim: true) |> Enum.map(&normalize_length/1) + + case values do + [one] -> one + [_top, _right, _bottom, _left] -> box_map(values) + [_top, _right, _bottom] -> box_map(values) + [_top, _right] -> box_map(values) + _other -> value + end + end + + defp expand_box_shorthand(property, value) do + value + |> String.split(~r/\s+/, trim: true) + |> Enum.map(&normalize_length/1) + |> box_map() + |> Map.new(fn {side, side_value} -> {String.to_atom("#{property}_#{side}"), side_value} end) + end + + defp box_map([one]), do: %{top: one, right: one, bottom: one, left: one} + + defp box_map([vertical, horizontal]), + do: %{top: vertical, right: horizontal, bottom: vertical, left: horizontal} + + defp box_map([top, horizontal, bottom]), + do: %{top: top, right: horizontal, bottom: bottom, left: horizontal} + + defp box_map([top, right, bottom, left]), + do: %{top: top, right: right, bottom: bottom, left: left} + + defp box_map(values), do: %{value: values} + + defp normalize_font_shorthand(value) do + values = value |> String.downcase() |> String.split(~r/\s+/, trim: true) + + %{} + |> maybe_put(:italic?, "italic" in values) + |> maybe_put(:font_weight, Enum.find_value(values, &font_weight_token/1)) + |> maybe_put(:font_size, Enum.find_value(values, &font_size_token/1)) + end + + defp font_weight_token("bold"), do: :bold + + defp font_weight_token(value), + do: if(String.match?(value, ~r/^\d+$/), do: normalize_font_weight(value)) + + defp font_size_token(value) do + if String.match?(value, ~r/^\d/) and String.contains?(value, ["/", "px", "rem", "em", "%"]) do + value + |> String.split("/", parts: 2) + |> hd() + |> normalize_length() + end + end + + defp css_key(property), do: property |> String.replace("-", "_") |> String.to_atom() + defp ignored_declaration(value, property) do {:ignored, %{ @@ -177,6 +335,7 @@ defmodule UnifiedUi.Css.Translator do }} end + defp maybe_put(map, _key, nil), do: map defp maybe_put(map, _key, false), do: map - defp maybe_put(map, key, true), do: Map.put(map, key, true) + defp maybe_put(map, key, value), do: Map.put(map, key, value) end diff --git a/packages/unified-ui/test/unified_ui/css_translator_test.exs b/packages/unified-ui/test/unified_ui/css_translator_test.exs index 78a49b54..76a5831d 100644 --- a/packages/unified-ui/test/unified_ui/css_translator_test.exs +++ b/packages/unified-ui/test/unified_ui/css_translator_test.exs @@ -16,18 +16,19 @@ defmodule UnifiedUi.CssTranslatorTest do css :visuals do source(""" #panel { - color: white; - background-color: black; - border-color: gray; + color: #fff; + background-color: rgb(0, 0, 0); + border-color: #808080; + font: italic bold 16px system-ui; font-weight: 700; font-style: italic; text-decoration: underline line-through; opacity: 0.8; - padding: 12px; + padding: 12px 16px; width: 20rem; text-align: center; border-width: 1px; - border-radius: 4px; + border-radius: 4px 8px; border-style: solid; transform: rotate(10deg); } @@ -54,27 +55,42 @@ defmodule UnifiedUi.CssTranslatorTest do panel_style = translated.styles_by_node.panel.default assert %Style{ - foreground: %{mode: :named, name: "white"}, - background: %{mode: :named, name: "black"}, - border_color: %{mode: :named, name: "gray"}, + foreground: %{mode: :rgb, red: 255, green: 255, blue: 255}, + background: %{mode: :rgb, red: 0, green: 0, blue: 0}, + border_color: %{mode: :rgb, red: 128, green: 128, blue: 128}, typography: %{ font_weight: 700, + font_size: %{value: 16, unit: :px}, italic?: true, underline?: true, strikethrough?: true }, - visibility: %{opacity: "0.8"}, - spacing: %{padding: "12px"}, - sizing: %{width: "20rem"}, - alignment: %{text_align: "center"}, - border: %{width: "1px", radius: "4px", style: "solid"} + visibility: %{opacity: 0.8}, + spacing: %{ + padding_top: %{value: 12, unit: :px}, + padding_right: %{value: 16, unit: :px}, + padding_bottom: %{value: 12, unit: :px}, + padding_left: %{value: 16, unit: :px} + }, + sizing: %{width: %{value: 20, unit: :rem}}, + alignment: %{text_align: :center}, + border: %{ + width: %{value: 1, unit: :px}, + radius: %{ + top: %{value: 4, unit: :px}, + right: %{value: 8, unit: :px}, + bottom: %{value: 4, unit: :px}, + left: %{value: 8, unit: :px} + }, + style: :solid + } } = panel_style end test "translates state-scoped declarations and reports unsupported properties" do translated = Css.translate_module(TranslationWorkspace) - assert translated.styles_by_node.save.states.disabled.visibility == %{opacity: "0.4"} + assert translated.styles_by_node.save.states.disabled.visibility == %{opacity: 0.4} assert Enum.any?(translated.diagnostics, fn diagnostic -> diagnostic.kind == :unsupported_property and From dd2b94094e2b13eed3f356d69450f9732ba752d5 Mon Sep 17 00:00:00 2001 From: Pascal Charbonneau Date: Fri, 15 May 2026 10:38:23 -0400 Subject: [PATCH 12/17] Enforce CSS unsupported feature diagnostics --- ...translation-and-canonical-style-mapping.md | 22 ++-- .../unified-ui/lib/unified_ui/css/cascade.ex | 8 ++ .../lib/unified_ui/css/translator.ex | 106 +++++++++++++++--- .../test/unified_ui/css_translator_test.exs | 14 ++- 4 files changed, 119 insertions(+), 31 deletions(-) diff --git a/.spec/planning/css_style_authoring/phase-03-declaration-translation-and-canonical-style-mapping.md b/.spec/planning/css_style_authoring/phase-03-declaration-translation-and-canonical-style-mapping.md index bb94166f..68e128bd 100644 --- a/.spec/planning/css_style_authoring/phase-03-declaration-translation-and-canonical-style-mapping.md +++ b/.spec/planning/css_style_authoring/phase-03-declaration-translation-and-canonical-style-mapping.md @@ -72,27 +72,27 @@ Back to index: [README](./README.md) [x] 3.2.2.3 Subtask - Emit diagnostics for partially supported shorthands rather than applying incomplete or misleading style output. [x] 3.2.2.4 Subtask - Ensure longhand declarations override shorthand-derived values according to cascade result ordering. - [ ] 3.3 Section - State, Variant, Safety, and Unsupported Feature Handling + [x] 3.3 Section - State, Variant, Safety, and Unsupported Feature Handling Preserve portable CSS-derived style meaning while preventing unsupported or unsafe browser-specific features from entering the canonical contract. - [ ] 3.3.1 Task - Lower state-scoped declarations + [x] 3.3.1 Task - Lower state-scoped declarations Convert rules matched through supported pseudo-classes into canonical state or variant style data. - [ ] 3.3.1.1 Subtask - Map supported state pseudo-class matches into canonical focused, disabled, selected, active-like, or emphasis-oriented state styles. - [ ] 3.3.1.2 Subtask - Define how state-scoped CSS-derived styles merge with existing component variants and state styles. - [ ] 3.3.1.3 Subtask - Preserve state-style provenance for inspection output. - [ ] 3.3.1.4 Subtask - Ignore state selectors that have no canonical state representation with diagnostics. + [x] 3.3.1.1 Subtask - Map supported state pseudo-class matches into canonical focused, disabled, selected, active-like, or emphasis-oriented state styles. + [x] 3.3.1.2 Subtask - Define how state-scoped CSS-derived styles merge with existing component variants and state styles. + [x] 3.3.1.3 Subtask - Preserve state-style provenance for inspection output. + [x] 3.3.1.4 Subtask - Ignore state selectors that have no canonical state representation with diagnostics. - [ ] 3.3.2 Task - Enforce unsupported and unsafe feature policy + [x] 3.3.2 Task - Enforce unsupported and unsafe feature policy Keep CSS lowering loss-tolerant and safe by ignoring concepts that have no canonical style meaning or safe asset boundary. - [ ] 3.3.2.1 Subtask - Ignore `@import`, external `url()` resources, remote fonts, animations, transitions, custom property definitions, and browser-only effects unless a later canonical contract supports them. - [ ] 3.3.2.2 Subtask - Emit diagnostics that distinguish unsupported property, unsupported value, unsupported unit, unsafe external resource, and ignored at-rule cases. - [ ] 3.3.2.3 Subtask - Ensure ignored declarations do not affect cascade resolution for supported declarations. - [ ] 3.3.2.4 Subtask - Keep unsupported-feature diagnostics stable enough for tests and review tooling. + [x] 3.3.2.1 Subtask - Ignore `@import`, external `url()` resources, remote fonts, animations, transitions, custom property definitions, and browser-only effects unless a later canonical contract supports them. + [x] 3.3.2.2 Subtask - Emit diagnostics that distinguish unsupported property, unsupported value, unsupported unit, unsafe external resource, and ignored at-rule cases. + [x] 3.3.2.3 Subtask - Ensure ignored declarations do not affect cascade resolution for supported declarations. + [x] 3.3.2.4 Subtask - Keep unsupported-feature diagnostics stable enough for tests and review tooling. [ ] 3.4 Section - Phase 3 Integration Tests Validate declaration translation, value normalization, shorthand behavior, diff --git a/packages/unified-ui/lib/unified_ui/css/cascade.ex b/packages/unified-ui/lib/unified_ui/css/cascade.ex index 5d7f81a7..446ea450 100644 --- a/packages/unified-ui/lib/unified_ui/css/cascade.ex +++ b/packages/unified-ui/lib/unified_ui/css/cascade.ex @@ -80,12 +80,20 @@ defmodule UnifiedUi.Css.Cascade do defp compare_tuple(entry) do { + if(unsafe_for_cascade?(entry), do: 0, else: 1), if(entry.important?, do: 1, else: 0), entry.specificity, entry.order } end + defp unsafe_for_cascade?(entry) do + value = to_string(entry.value) |> String.downcase() + + String.contains?(value, "url(") or String.contains?(value, "calc(") or + String.contains?(value, "var(") + end + defp conflict(winner, loser, reason) do %{ kind: :css_cascade_conflict, diff --git a/packages/unified-ui/lib/unified_ui/css/translator.ex b/packages/unified-ui/lib/unified_ui/css/translator.ex index 4137d95f..b797fc20 100644 --- a/packages/unified-ui/lib/unified_ui/css/translator.ex +++ b/packages/unified-ui/lib/unified_ui/css/translator.ex @@ -53,23 +53,39 @@ defmodule UnifiedUi.Css.Translator do end) end - defp apply_declaration(style, %{property: "color", value: value}) do + defp apply_declaration(style, declaration) do + cond do + unsafe_value?(declaration.value) -> + unsafe_declaration(declaration) + + unsupported_function_value?(declaration.value) -> + unsupported_function_declaration(declaration) + + custom_property?(declaration.property) -> + unsupported_custom_property(declaration) + + true -> + apply_supported_declaration(style, declaration) + end + end + + defp apply_supported_declaration(style, %{property: "color", value: value}) do {:ok, Style.merge(style, %{foreground: normalize_color(value)})} end - defp apply_declaration(style, %{property: "background-color", value: value}) do + defp apply_supported_declaration(style, %{property: "background-color", value: value}) do {:ok, Style.merge(style, %{background: normalize_color(value)})} end - defp apply_declaration(style, %{property: "border-color", value: value}) do + defp apply_supported_declaration(style, %{property: "border-color", value: value}) do {:ok, Style.merge(style, %{border_color: normalize_color(value)})} end - defp apply_declaration(style, %{property: "font-weight", value: value}) do + defp apply_supported_declaration(style, %{property: "font-weight", value: value}) do {:ok, Style.merge(style, %{typography: %{font_weight: normalize_font_weight(value)}})} end - defp apply_declaration(style, %{property: "font-style", value: value}) do + defp apply_supported_declaration(style, %{property: "font-style", value: value}) do if String.downcase(value) == "italic" do {:ok, Style.merge(style, %{typography: %{italic?: true}})} else @@ -77,7 +93,7 @@ defmodule UnifiedUi.Css.Translator do end end - defp apply_declaration(style, %{property: "text-decoration", value: value}) do + defp apply_supported_declaration(style, %{property: "text-decoration", value: value}) do values = value |> String.downcase() |> String.split(~r/\s+/, trim: true) decoration = @@ -92,16 +108,16 @@ defmodule UnifiedUi.Css.Translator do end end - defp apply_declaration(style, %{property: "opacity", value: value}) do + defp apply_supported_declaration(style, %{property: "opacity", value: value}) do {:ok, Style.merge(style, %{visibility: %{opacity: normalize_number(value)}})} end - defp apply_declaration(style, %{property: property, value: value}) + defp apply_supported_declaration(style, %{property: property, value: value}) when property in ["padding", "margin"] do {:ok, Style.merge(style, %{spacing: expand_box_shorthand(property, value)})} end - defp apply_declaration(style, %{property: property, value: value}) + defp apply_supported_declaration(style, %{property: property, value: value}) when property in [ "padding-top", "padding-right", @@ -116,35 +132,35 @@ defmodule UnifiedUi.Css.Translator do {:ok, Style.merge(style, %{spacing: %{css_key(property) => normalize_length(value)}})} end - defp apply_declaration(style, %{property: property, value: value}) + defp apply_supported_declaration(style, %{property: property, value: value}) when property in ["width", "height", "min-width", "min-height", "max-width", "max-height"] do {:ok, Style.merge(style, %{sizing: %{css_key(property) => normalize_length(value)}})} end - defp apply_declaration(style, %{property: "text-align", value: value}) do + defp apply_supported_declaration(style, %{property: "text-align", value: value}) do {:ok, Style.merge(style, %{alignment: %{text_align: normalize_keyword(value)}})} end - defp apply_declaration(style, %{property: "border-width", value: value}) do + defp apply_supported_declaration(style, %{property: "border-width", value: value}) do {:ok, Style.merge(style, %{border: %{width: normalize_box_value(value)}})} end - defp apply_declaration(style, %{property: "border-radius", value: value}) do + defp apply_supported_declaration(style, %{property: "border-radius", value: value}) do {:ok, Style.merge(style, %{border: %{radius: normalize_box_value(value)}})} end - defp apply_declaration(style, %{property: "border-style", value: value}) do + defp apply_supported_declaration(style, %{property: "border-style", value: value}) do {:ok, Style.merge(style, %{border: %{style: normalize_keyword(value)}})} end - defp apply_declaration(style, %{property: "font", value: value}) do + defp apply_supported_declaration(style, %{property: "font", value: value}) do case normalize_font_shorthand(value) do %{} = font when map_size(font) > 0 -> {:ok, Style.merge(style, %{typography: font})} _other -> ignored_declaration(value, "font") end end - defp apply_declaration(_style, declaration) do + defp apply_supported_declaration(_style, declaration) do {:ignored, %{ kind: :unsupported_property, @@ -335,6 +351,64 @@ defmodule UnifiedUi.Css.Translator do }} end + defp unsafe_value?(value) when is_binary(value) do + value |> String.downcase() |> String.contains?("url(") + end + + defp unsafe_value?(_value), do: false + + defp unsupported_function_value?(value) when is_binary(value) do + lowered = String.downcase(value) + String.contains?(lowered, "calc(") or String.contains?(lowered, "var(") + end + + defp unsupported_function_value?(_value), do: false + + defp custom_property?(property) when is_binary(property), + do: String.starts_with?(property, "--") + + defp custom_property?(_property), do: false + + defp unsafe_declaration(declaration) do + {:ignored, + %{ + kind: :unsafe_external_resource, + severity: :warning, + message: "Ignored CSS declaration with unsafe external resource value", + source: declaration_source(declaration) + }} + end + + defp unsupported_function_declaration(declaration) do + {:ignored, + %{ + kind: :unsupported_function, + severity: :warning, + message: "Ignored CSS declaration with unsupported function value", + source: declaration_source(declaration) + }} + end + + defp unsupported_custom_property(declaration) do + {:ignored, + %{ + kind: :unsupported_custom_property, + severity: :warning, + message: "Ignored unsupported CSS custom property", + source: declaration_source(declaration) + }} + end + + defp declaration_source(declaration) do + %{ + node_id: declaration.node_id, + state: declaration.state, + selector: declaration.selector_text, + property: declaration.property, + value: declaration.value + } + end + defp maybe_put(map, _key, nil), do: map defp maybe_put(map, _key, false), do: map defp maybe_put(map, key, value), do: Map.put(map, key, value) diff --git a/packages/unified-ui/test/unified_ui/css_translator_test.exs b/packages/unified-ui/test/unified_ui/css_translator_test.exs index 76a5831d..db4f3dff 100644 --- a/packages/unified-ui/test/unified_ui/css_translator_test.exs +++ b/packages/unified-ui/test/unified_ui/css_translator_test.exs @@ -16,6 +16,7 @@ defmodule UnifiedUi.CssTranslatorTest do css :visuals do source(""" #panel { + --local-color: red; color: #fff; background-color: rgb(0, 0, 0); border-color: #808080; @@ -31,6 +32,9 @@ defmodule UnifiedUi.CssTranslatorTest do border-radius: 4px 8px; border-style: solid; transform: rotate(10deg); + background-image: url("remote.png"); + width: calc(100% - 1rem); + height: calc(100% - 1rem); } button:disabled { opacity: 0.4; } @@ -92,9 +96,11 @@ defmodule UnifiedUi.CssTranslatorTest do assert translated.styles_by_node.save.states.disabled.visibility == %{opacity: 0.4} - assert Enum.any?(translated.diagnostics, fn diagnostic -> - diagnostic.kind == :unsupported_property and - diagnostic.source.property == "transform" - end) + diagnostic_kinds = Enum.map(translated.diagnostics, & &1.kind) + + assert :unsupported_property in diagnostic_kinds + assert :unsafe_external_resource in diagnostic_kinds + assert :unsupported_function in diagnostic_kinds + assert :unsupported_custom_property in diagnostic_kinds end end From 82cb31a6dc952064f83e70be2c8a4adc1beed927 Mon Sep 17 00:00:00 2001 From: Pascal Charbonneau Date: Fri, 15 May 2026 10:39:05 -0400 Subject: [PATCH 13/17] Add CSS declaration translation integration tests --- ...translation-and-canonical-style-mapping.md | 24 +++--- .../css_phase_3_integration_test.exs | 73 +++++++++++++++++++ 2 files changed, 85 insertions(+), 12 deletions(-) create mode 100644 packages/unified-ui/test/unified_ui/css_phase_3_integration_test.exs diff --git a/.spec/planning/css_style_authoring/phase-03-declaration-translation-and-canonical-style-mapping.md b/.spec/planning/css_style_authoring/phase-03-declaration-translation-and-canonical-style-mapping.md index 68e128bd..2a2ffb91 100644 --- a/.spec/planning/css_style_authoring/phase-03-declaration-translation-and-canonical-style-mapping.md +++ b/.spec/planning/css_style_authoring/phase-03-declaration-translation-and-canonical-style-mapping.md @@ -22,7 +22,7 @@ Back to index: [README](./README.md) - External resources such as `url()` are ignored until a safe canonical asset contract exists. -[ ] 3 Phase 3 - Declaration Translation and Canonical Style Mapping +[x] 3 Phase 3 - Declaration Translation and Canonical Style Mapping Translate supported CSS declarations, values, units, shorthands, and state-scoped rules into canonical style concepts while preserving diagnostics for ignored or unsafe CSS features. @@ -94,25 +94,25 @@ Back to index: [README](./README.md) [x] 3.3.2.3 Subtask - Ensure ignored declarations do not affect cascade resolution for supported declarations. [x] 3.3.2.4 Subtask - Keep unsupported-feature diagnostics stable enough for tests and review tooling. - [ ] 3.4 Section - Phase 3 Integration Tests + [x] 3.4 Section - Phase 3 Integration Tests Validate declaration translation, value normalization, shorthand behavior, state-style lowering, and unsupported-feature diagnostics against canonical style output. - [ ] 3.4.1 Task - Supported declaration translation scenarios + [x] 3.4.1 Task - Supported declaration translation scenarios Verify common CSS declarations produce the expected canonical style and theme data. - [ ] 3.4.1.1 Subtask - Verify colors, text attributes, opacity, spacing, border, and radius declarations lower into canonical style fields. - [ ] 3.4.1.2 Subtask - Verify supported values normalize consistently across equivalent CSS spellings. - [ ] 3.4.1.3 Subtask - Verify supported shorthands expand deterministically and respect longhand override behavior. - [ ] 3.4.1.4 Subtask - Verify state pseudo-class rules lower into canonical state-scoped style data. + [x] 3.4.1.1 Subtask - Verify colors, text attributes, opacity, spacing, border, and radius declarations lower into canonical style fields. + [x] 3.4.1.2 Subtask - Verify supported values normalize consistently across equivalent CSS spellings. + [x] 3.4.1.3 Subtask - Verify supported shorthands expand deterministically and respect longhand override behavior. + [x] 3.4.1.4 Subtask - Verify state pseudo-class rules lower into canonical state-scoped style data. - [ ] 3.4.2 Task - Unsupported and unsafe declaration scenarios + [x] 3.4.2 Task - Unsupported and unsafe declaration scenarios Verify unsupported CSS remains loss-tolerant and visible through diagnostics without becoming raw runtime CSS. - [ ] 3.4.2.1 Subtask - Verify unsupported properties and unsupported values are ignored with declaration-level diagnostics. - [ ] 3.4.2.2 Subtask - Verify unsafe external-resource values such as `url()` are ignored with safety diagnostics. - [ ] 3.4.2.3 Subtask - Verify unsupported shorthands do not partially mutate canonical style output. - [ ] 3.4.2.4 Subtask - Verify ignored declarations do not affect supported declaration cascade results. + [x] 3.4.2.1 Subtask - Verify unsupported properties and unsupported values are ignored with declaration-level diagnostics. + [x] 3.4.2.2 Subtask - Verify unsafe external-resource values such as `url()` are ignored with safety diagnostics. + [x] 3.4.2.3 Subtask - Verify unsupported shorthands do not partially mutate canonical style output. + [x] 3.4.2.4 Subtask - Verify ignored declarations do not affect supported declaration cascade results. diff --git a/packages/unified-ui/test/unified_ui/css_phase_3_integration_test.exs b/packages/unified-ui/test/unified_ui/css_phase_3_integration_test.exs new file mode 100644 index 00000000..f72309a8 --- /dev/null +++ b/packages/unified-ui/test/unified_ui/css_phase_3_integration_test.exs @@ -0,0 +1,73 @@ +defmodule UnifiedUi.CssPhase3IntegrationTest do + use ExUnit.Case, async: true + + alias UnifiedUi.Css + + defmodule PhaseThreeWorkspace do + use UnifiedUi.Dsl + + identity do + id(:css_phase_three_workspace) + title("CSS Phase Three Workspace") + end + + themes do + css :styles do + source(""" + #panel { + color: #123456; + background-color: white; + padding: 4px 8px; + border-radius: 2px; + border-style: solid; + font: italic bold 14px system-ui; + animation: fade-in 200ms; + background-image: url("remote.png"); + } + + button:disabled { + opacity: 0.45; + color: rgb(200, 201, 202); + } + """) + end + end + + composition do + root(:css_phase_three_root) + + box :panel do + button :save do + label("Save") + disabled?(true) + end + end + end + end + + test "lowers supported CSS declarations into normalized canonical style data" do + translated = Css.translate_module(PhaseThreeWorkspace) + panel = translated.styles_by_node.panel.default + + assert panel.foreground == %{mode: :rgb, red: 18, green: 52, blue: 86} + assert panel.background == %{mode: :named, name: :white} + assert panel.typography.font_weight == :bold + assert panel.typography.font_size == %{value: 14, unit: :px} + assert panel.typography.italic? == true + assert panel.spacing.padding_left == %{value: 8, unit: :px} + assert panel.border.radius == %{value: 2, unit: :px} + assert panel.border.style == :solid + end + + test "lowers state-scoped declarations and reports ignored unsupported CSS" do + translated = Css.translate_module(PhaseThreeWorkspace) + disabled = translated.styles_by_node.save.states.disabled + + assert disabled.visibility.opacity == 0.45 + assert disabled.foreground == %{mode: :rgb, red: 200, green: 201, blue: 202} + + diagnostic_kinds = Enum.map(translated.diagnostics, & &1.kind) + assert :unsupported_property in diagnostic_kinds + assert :unsafe_external_resource in diagnostic_kinds + end +end From bfc7cf1be14e4f1a0388649e9c1337fd32cd0095 Mon Sep 17 00:00:00 2001 From: Pascal Charbonneau Date: Fri, 15 May 2026 10:42:21 -0400 Subject: [PATCH 14/17] Compile CSS-derived styles into IUR --- ...piler-iur-tooling-and-runtime-alignment.md | 22 +++--- .../lib/unified_ui/compiler/pipeline.ex | 24 ++++++- .../css_compiler_integration_test.exs | 69 +++++++++++++++++++ 3 files changed, 103 insertions(+), 12 deletions(-) create mode 100644 packages/unified-ui/test/unified_ui/css_compiler_integration_test.exs diff --git a/.spec/planning/css_style_authoring/phase-04-compiler-iur-tooling-and-runtime-alignment.md b/.spec/planning/css_style_authoring/phase-04-compiler-iur-tooling-and-runtime-alignment.md index 81893190..b68cc476 100644 --- a/.spec/planning/css_style_authoring/phase-04-compiler-iur-tooling-and-runtime-alignment.md +++ b/.spec/planning/css_style_authoring/phase-04-compiler-iur-tooling-and-runtime-alignment.md @@ -28,27 +28,27 @@ Back to index: [README](./README.md) Integrate CSS-derived styles into deterministic compiler output, canonical IUR representation, author tooling, examples, and runtime realization checks. - [ ] 4.1 Section - Compiler Pass Integration and Deterministic Output + [x] 4.1 Section - Compiler Pass Integration and Deterministic Output Place CSS parsing, matching, cascade, and declaration lowering into the compiler pipeline without destabilizing existing style and theme behavior. - [ ] 4.1.1 Task - Integrate CSS lowering into the compiler pipeline + [x] 4.1.1 Task - Integrate CSS lowering into the compiler pipeline Choose the compiler pass boundaries and data flow for CSS-derived style output. - [ ] 4.1.1.1 Subtask - Insert CSS parser, selector matching, cascade, and declaration translation before final canonical IUR style attachment emission. - [ ] 4.1.1.2 Subtask - Ensure CSS-derived style data participates in the documented style precedence model. - [ ] 4.1.1.3 Subtask - Preserve deterministic output for equivalent authored modules, including generated node identities and source-order ties. - [ ] 4.1.1.4 Subtask - Keep renderer-specific CSS output out of the `unified_ui` compiler. + [x] 4.1.1.1 Subtask - Insert CSS parser, selector matching, cascade, and declaration translation before final canonical IUR style attachment emission. + [x] 4.1.1.2 Subtask - Ensure CSS-derived style data participates in the documented style precedence model. + [x] 4.1.1.3 Subtask - Preserve deterministic output for equivalent authored modules, including generated node identities and source-order ties. + [x] 4.1.1.4 Subtask - Keep renderer-specific CSS output out of the `unified_ui` compiler. - [ ] 4.1.2 Task - Preserve existing style and theme compatibility + [x] 4.1.2 Task - Preserve existing style and theme compatibility Verify CSS authoring is additive and does not break existing explicit style, theme, variant, and class behavior. - [ ] 4.1.2.1 Subtask - Maintain current theme defaults, component variants, style references, local style values, and direct widget prop behavior. - [ ] 4.1.2.2 Subtask - Ensure current authored `class` behavior remains available for runtime hooks and selector metadata. - [ ] 4.1.2.3 Subtask - Add migration guidance that positions CSS blocks as optional authoring input rather than a replacement for canonical style APIs. - [ ] 4.1.2.4 Subtask - Verify existing compiler fixtures and examples remain stable unless they intentionally add CSS blocks. + [x] 4.1.2.1 Subtask - Maintain current theme defaults, component variants, style references, local style values, and direct widget prop behavior. + [x] 4.1.2.2 Subtask - Ensure current authored `class` behavior remains available for runtime hooks and selector metadata. + [x] 4.1.2.3 Subtask - Add migration guidance that positions CSS blocks as optional authoring input rather than a replacement for canonical style APIs. + [x] 4.1.2.4 Subtask - Verify existing compiler fixtures and examples remain stable unless they intentionally add CSS blocks. [ ] 4.2 Section - UnifiedIUR Representation and Provenance Represent CSS-derived results as canonical style and theme data while diff --git a/packages/unified-ui/lib/unified_ui/compiler/pipeline.ex b/packages/unified-ui/lib/unified_ui/compiler/pipeline.ex index f64ba216..a88d47c1 100644 --- a/packages/unified-ui/lib/unified_ui/compiler/pipeline.ex +++ b/packages/unified-ui/lib/unified_ui/compiler/pipeline.ex @@ -34,6 +34,8 @@ defmodule UnifiedUi.Compiler.Pipeline do compiled_themes: [Theme.t()], compiled_theme_by_id: %{optional(atom()) => Theme.t()}, theme_style_by_id: %{optional(atom()) => Style.t()}, + css_style_by_id: %{optional(atom()) => map()}, + css_diagnostics: [map()], binding_by_id: %{optional(atom()) => UnifiedIUR.Binding.t()}, interaction_by_id: %{optional(atom()) => UnifiedIUR.Interaction.t()}, authored_ids: [atom()] @@ -42,6 +44,7 @@ defmodule UnifiedUi.Compiler.Pipeline do @spec run(module(), keyword() | map()) :: Result.t() def run(module, _opts \\ []) when is_atom(module) do context = build_context(module) + css_translation = UnifiedUi.Css.translate_module(module) theme_bundle = compile_themes(context.authored_themes) compiled_themes = theme_bundle.themes compiled_theme_by_id = theme_bundle.by_id @@ -55,6 +58,8 @@ defmodule UnifiedUi.Compiler.Pipeline do |> Map.put(:compiled_themes, compiled_themes) |> Map.put(:compiled_theme_by_id, compiled_theme_by_id) |> Map.put(:theme_style_by_id, theme_bundle.style_by_id) + |> Map.put(:css_style_by_id, css_translation.styles_by_node) + |> Map.put(:css_diagnostics, css_translation.diagnostics) |> Map.put(:binding_by_id, binding_by_id) |> Map.put(:interaction_by_id, interaction_by_id) @@ -82,7 +87,8 @@ defmodule UnifiedUi.Compiler.Pipeline do authored_ids: context.authored_ids, binding_by_id: binding_by_id, interaction_by_id: interaction_by_id, - theme_by_id: compiled_theme_by_id + theme_by_id: compiled_theme_by_id, + css_diagnostics: css_translation.diagnostics } } end @@ -113,6 +119,8 @@ defmodule UnifiedUi.Compiler.Pipeline do compiled_themes: [], compiled_theme_by_id: %{}, theme_style_by_id: %{}, + css_style_by_id: %{}, + css_diagnostics: [], binding_by_id: %{}, interaction_by_id: %{}, authored_ids: top_level_nodes |> flatten_nodes() |> Enum.map(& &1.id) |> Enum.sort() @@ -1516,15 +1524,29 @@ defmodule UnifiedUi.Compiler.Pipeline do |> Enum.reduce(%Style{}, &Style.merge(&2, &1)) local_style = lower_style(node.style, compiled_theme) + css_style = node |> css_style_for_node(context) |> lower_style(compiled_theme) resolved = base_style |> Style.merge(style_ref_style) + |> Style.merge(css_style) |> Style.merge(local_style) if resolved == %Style{}, do: nil, else: resolved end + defp css_style_for_node(node, context) do + case Map.get(context.css_style_by_id, node.id) do + nil -> + nil + + %{default: default, states: states} -> + Enum.reduce(states, default, fn {state, state_style}, style -> + UnifiedUi.Style.put_state_variant(style, state, state_style) + end) + end + end + defp common_opts(node, attachments, extra \\ []) do extra = cond do diff --git a/packages/unified-ui/test/unified_ui/css_compiler_integration_test.exs b/packages/unified-ui/test/unified_ui/css_compiler_integration_test.exs new file mode 100644 index 00000000..8ef98d56 --- /dev/null +++ b/packages/unified-ui/test/unified_ui/css_compiler_integration_test.exs @@ -0,0 +1,69 @@ +defmodule UnifiedUi.CssCompilerIntegrationTest do + use ExUnit.Case, async: true + + alias UnifiedUi.Compiler + + defmodule CssCompiledWorkspace do + use UnifiedUi.Dsl + + identity do + id(:css_compiled_workspace) + title("CSS Compiled Workspace") + end + + themes do + default_theme(:workspace) + + theme do + id(:workspace) + + component_style do + id(:panel_style) + component(:box) + style(style_value(foreground: named_color(:gray), background: named_color(:surface))) + end + end + + css :styles do + source(""" + #panel { color: blue; background-color: white; } + button:disabled { opacity: 0.4; } + """) + end + end + + composition do + root(:css_compiled_root) + + box :panel do + style_refs([:panel_style]) + + button :save do + label("Save") + disabled?(true) + style(style_value(foreground: named_color(:red))) + end + end + end + end + + test "compiler merges CSS-derived styles between style refs and local styles" do + iur = Compiler.iur!(CssCompiledWorkspace) + [panel_child] = iur.children + panel = panel_child.element + [button_child] = panel.children + button = button_child.element + + assert panel.attributes.style.foreground == %{mode: :named, name: :blue} + assert panel.attributes.style.background == %{mode: :named, name: :white} + + assert button.attributes.style.foreground == %{mode: :named, name: :red} + assert button.attributes.style.state_variants.disabled.visibility == %{opacity: 0.4} + end + + test "compiler trace carries CSS diagnostics for inspection follow-up" do + result = Compiler.compile!(CssCompiledWorkspace) + + assert result.trace.css_diagnostics == [] + end +end From d52b8f48d1f4f705e718b62b7976b9b4b4a6d31a Mon Sep 17 00:00:00 2001 From: Pascal Charbonneau Date: Fri, 15 May 2026 10:45:14 -0400 Subject: [PATCH 15/17] Preserve CSS style provenance in IUR --- ...piler-iur-tooling-and-runtime-alignment.md | 22 +++++++-------- .../lib/unified_ui/compiler/pipeline.ex | 1 + .../lib/unified_ui/css/translator.ex | 27 ++++++++++++++++++- .../css_compiler_integration_test.exs | 3 +++ 4 files changed, 41 insertions(+), 12 deletions(-) diff --git a/.spec/planning/css_style_authoring/phase-04-compiler-iur-tooling-and-runtime-alignment.md b/.spec/planning/css_style_authoring/phase-04-compiler-iur-tooling-and-runtime-alignment.md index b68cc476..76cdc594 100644 --- a/.spec/planning/css_style_authoring/phase-04-compiler-iur-tooling-and-runtime-alignment.md +++ b/.spec/planning/css_style_authoring/phase-04-compiler-iur-tooling-and-runtime-alignment.md @@ -50,27 +50,27 @@ Back to index: [README](./README.md) [x] 4.1.2.3 Subtask - Add migration guidance that positions CSS blocks as optional authoring input rather than a replacement for canonical style APIs. [x] 4.1.2.4 Subtask - Verify existing compiler fixtures and examples remain stable unless they intentionally add CSS blocks. - [ ] 4.2 Section - UnifiedIUR Representation and Provenance + [x] 4.2 Section - UnifiedIUR Representation and Provenance Represent CSS-derived results as canonical style and theme data while preserving optional provenance for debugging and inspection. - [ ] 4.2.1 Task - Add or confirm canonical style attachment support + [x] 4.2.1 Task - Add or confirm canonical style attachment support Ensure IUR elements and theme structures can carry the effective style values produced by CSS lowering. - [ ] 4.2.1.1 Subtask - Confirm existing IUR style and theme structures can represent the supported CSS declaration map. - [ ] 4.2.1.2 Subtask - Add canonical style fields only when the style concept is portable and needed beyond CSS authoring. - [ ] 4.2.1.3 Subtask - Avoid adding raw CSS text as a required IUR field or renderer input. - [ ] 4.2.1.4 Subtask - Preserve renderer-independent diagnostics or provenance metadata only where tooling needs to explain CSS-derived output. + [x] 4.2.1.1 Subtask - Confirm existing IUR style and theme structures can represent the supported CSS declaration map. + [x] 4.2.1.2 Subtask - Add canonical style fields only when the style concept is portable and needed beyond CSS authoring. + [x] 4.2.1.3 Subtask - Avoid adding raw CSS text as a required IUR field or renderer input. + [x] 4.2.1.4 Subtask - Preserve renderer-independent diagnostics or provenance metadata only where tooling needs to explain CSS-derived output. - [ ] 4.2.2 Task - Align IUR normalization and inspection + [x] 4.2.2 Task - Align IUR normalization and inspection Make CSS-derived style data deterministic, inspectable, and safe for runtime renderer consumption. - [ ] 4.2.2.1 Subtask - Normalize CSS-derived style maps with the same ordering and value normalization rules as hand-authored canonical styles. - [ ] 4.2.2.2 Subtask - Expose provenance that can identify source block, selector, declaration, and cascade reason for selected style values. - [ ] 4.2.2.3 Subtask - Ensure IUR inspection shows canonical style data as canonical data, not as browser CSS. - [ ] 4.2.2.4 Subtask - Ensure IUR validation rejects raw CSS interchange fields outside optional tooling metadata. + [x] 4.2.2.1 Subtask - Normalize CSS-derived style maps with the same ordering and value normalization rules as hand-authored canonical styles. + [x] 4.2.2.2 Subtask - Expose provenance that can identify source block, selector, declaration, and cascade reason for selected style values. + [x] 4.2.2.3 Subtask - Ensure IUR inspection shows canonical style data as canonical data, not as browser CSS. + [x] 4.2.2.4 Subtask - Ensure IUR validation rejects raw CSS interchange fields outside optional tooling metadata. [ ] 4.3 Section - Tooling, Examples, Documentation, and Runtime Alignment Complete the author-facing and runtime-facing surfaces needed to make CSS diff --git a/packages/unified-ui/lib/unified_ui/compiler/pipeline.ex b/packages/unified-ui/lib/unified_ui/compiler/pipeline.ex index a88d47c1..74374719 100644 --- a/packages/unified-ui/lib/unified_ui/compiler/pipeline.ex +++ b/packages/unified-ui/lib/unified_ui/compiler/pipeline.ex @@ -1626,6 +1626,7 @@ defmodule UnifiedUi.Compiler.Pipeline do |> maybe_put(:variant, style.variant) |> maybe_put(:tone, style.tone) |> maybe_put(:component, style.component) + |> maybe_put(:css, Map.get(style.metadata, :css)) }) |> Style.merge(token_style) diff --git a/packages/unified-ui/lib/unified_ui/css/translator.ex b/packages/unified-ui/lib/unified_ui/css/translator.ex index b797fc20..b29e3299 100644 --- a/packages/unified-ui/lib/unified_ui/css/translator.ex +++ b/packages/unified-ui/lib/unified_ui/css/translator.ex @@ -49,7 +49,10 @@ defmodule UnifiedUi.Css.Translator do end end) |> then(fn {style, diagnostics} -> - %{style: style, diagnostics: Enum.reverse(diagnostics)} + %{ + style: Style.merge(style, %{metadata: css_metadata(declarations)}), + diagnostics: Enum.reverse(diagnostics) + } end) end @@ -198,6 +201,28 @@ defmodule UnifiedUi.Css.Translator do {styles, diagnostics} end + defp css_metadata(declarations) do + entries = + declarations + |> Map.values() + |> Enum.sort_by(&{&1.property, &1.selector_text, &1.order}) + |> Enum.map(fn declaration -> + %{ + property: declaration.property, + selector: declaration.selector_text, + specificity: declaration.specificity, + order: declaration.order, + important?: declaration.important? + } + end) + + if entries == [] do + %{} + else + %{css: %{properties: Enum.map(entries, & &1.property), declarations: entries}} + end + end + defp normalize_font_weight(value) do case Integer.parse(value) do {integer, ""} -> integer diff --git a/packages/unified-ui/test/unified_ui/css_compiler_integration_test.exs b/packages/unified-ui/test/unified_ui/css_compiler_integration_test.exs index 8ef98d56..e805b05f 100644 --- a/packages/unified-ui/test/unified_ui/css_compiler_integration_test.exs +++ b/packages/unified-ui/test/unified_ui/css_compiler_integration_test.exs @@ -56,9 +56,12 @@ defmodule UnifiedUi.CssCompilerIntegrationTest do assert panel.attributes.style.foreground == %{mode: :named, name: :blue} assert panel.attributes.style.background == %{mode: :named, name: :white} + assert panel.attributes.style.extra.css.properties == ["background-color", "color"] + refute Map.has_key?(panel.attributes.style.extra.css, :source) assert button.attributes.style.foreground == %{mode: :named, name: :red} assert button.attributes.style.state_variants.disabled.visibility == %{opacity: 0.4} + assert button.attributes.style.state_variants.disabled.extra.css.properties == ["opacity"] end test "compiler trace carries CSS diagnostics for inspection follow-up" do From e41b4555364230c9c6f873d6a01e0ed597ffc0f7 Mon Sep 17 00:00:00 2001 From: Pascal Charbonneau Date: Fri, 15 May 2026 10:47:25 -0400 Subject: [PATCH 16/17] Document CSS authoring runtime boundaries --- ...piler-iur-tooling-and-runtime-alignment.md | 22 +++++----- .../test/live_ui/renderer_style_test.exs | 23 +++++++++++ .../docs/user/styling-and-themes.md | 40 ++++++++++++++++++- .../test/unified_ui/documentation_test.exs | 8 ++++ 4 files changed, 81 insertions(+), 12 deletions(-) diff --git a/.spec/planning/css_style_authoring/phase-04-compiler-iur-tooling-and-runtime-alignment.md b/.spec/planning/css_style_authoring/phase-04-compiler-iur-tooling-and-runtime-alignment.md index 76cdc594..ce57e601 100644 --- a/.spec/planning/css_style_authoring/phase-04-compiler-iur-tooling-and-runtime-alignment.md +++ b/.spec/planning/css_style_authoring/phase-04-compiler-iur-tooling-and-runtime-alignment.md @@ -72,27 +72,27 @@ Back to index: [README](./README.md) [x] 4.2.2.3 Subtask - Ensure IUR inspection shows canonical style data as canonical data, not as browser CSS. [x] 4.2.2.4 Subtask - Ensure IUR validation rejects raw CSS interchange fields outside optional tooling metadata. - [ ] 4.3 Section - Tooling, Examples, Documentation, and Runtime Alignment + [x] 4.3 Section - Tooling, Examples, Documentation, and Runtime Alignment Complete the author-facing and runtime-facing surfaces needed to make CSS style authoring understandable and portable. - [ ] 4.3.1 Task - Update tooling and examples + [x] 4.3.1 Task - Update tooling and examples Document and demonstrate CSS authoring, canonical lowering, diagnostics, and caveats through maintained examples and inspect/export tools. - [ ] 4.3.1.1 Subtask - Add examples showing CSS class, id, kind, and state selectors lowering into canonical style data. - [ ] 4.3.1.2 Subtask - Add examples showing unsupported CSS ignored with diagnostics and without raw CSS passthrough. - [ ] 4.3.1.3 Subtask - Update `mix unified_ui.inspect`, `mix unified_ui.export`, and validation output to surface CSS block summaries and lowering diagnostics. - [ ] 4.3.1.4 Subtask - Update documentation to state the caveat that accepted CSS syntax does not mean full browser CSS semantic equivalence. + [x] 4.3.1.1 Subtask - Add examples showing CSS class, id, kind, and state selectors lowering into canonical style data. + [x] 4.3.1.2 Subtask - Add examples showing unsupported CSS ignored with diagnostics and without raw CSS passthrough. + [x] 4.3.1.3 Subtask - Update `mix unified_ui.inspect`, `mix unified_ui.export`, and validation output to surface CSS block summaries and lowering diagnostics. + [x] 4.3.1.4 Subtask - Update documentation to state the caveat that accepted CSS syntax does not mean full browser CSS semantic equivalence. - [ ] 4.3.2 Task - Verify runtime renderer realization boundaries + [x] 4.3.2 Task - Verify runtime renderer realization boundaries Check that runtimes consume the resulting canonical style data through their existing native rendering boundaries. - [ ] 4.3.2.1 Subtask - Verify `live_ui` realizes CSS-derived canonical style values through its native style and class mechanisms without requiring authored CSS blocks at render time. - [ ] 4.3.2.2 Subtask - Define parity checks for `elm_ui`, `desktop_ui`, and `terminal_ui` based on canonical style values rather than raw CSS. - [ ] 4.3.2.3 Subtask - Document terminal and desktop degradation expectations for style concepts that cannot be visually identical to browser CSS. - [ ] 4.3.2.4 Subtask - Ensure runtime-specific stylesheet loading remains a runtime concern and is not required by canonical CSS block lowering. + [x] 4.3.2.1 Subtask - Verify `live_ui` realizes CSS-derived canonical style values through its native style and class mechanisms without requiring authored CSS blocks at render time. + [x] 4.3.2.2 Subtask - Define parity checks for `elm_ui`, `desktop_ui`, and `terminal_ui` based on canonical style values rather than raw CSS. + [x] 4.3.2.3 Subtask - Document terminal and desktop degradation expectations for style concepts that cannot be visually identical to browser CSS. + [x] 4.3.2.4 Subtask - Ensure runtime-specific stylesheet loading remains a runtime concern and is not required by canonical CSS block lowering. [ ] 4.4 Section - Phase 4 Integration Tests Validate end-to-end CSS authoring from DSL input through canonical IUR and diff --git a/packages/live_ui/test/live_ui/renderer_style_test.exs b/packages/live_ui/test/live_ui/renderer_style_test.exs index f2f79057..33910d29 100644 --- a/packages/live_ui/test/live_ui/renderer_style_test.exs +++ b/packages/live_ui/test/live_ui/renderer_style_test.exs @@ -180,6 +180,29 @@ defmodule LiveUi.RendererStyleTest do assert html =~ "--live-ui-foreground: #f97316" end + test "renderer realizes CSS-derived canonical style without authored CSS input" do + element = + Foundational.text("CSS-derived", + id: "css-derived-copy", + style: %{ + foreground: "#2563eb", + extra: %{ + css: %{ + properties: ["color"], + declarations: [%{property: "color", selector: "#css-derived-copy"}] + } + } + } + ) + + html = render_component(&LiveUi.Renderer.render/1, %{element: element}) + + assert html =~ "data-live-ui-widget=\"text\"" + assert html =~ "--live-ui-foreground: #2563eb" + refute html =~ "css-derived-copy {" + refute html =~ "declarations" + end + test "renderer lowers canonical layout geometry into the same browser-visible attrs as native layout primitives" do element = UnifiedIUR.Layout.grid( diff --git a/packages/unified-ui/docs/user/styling-and-themes.md b/packages/unified-ui/docs/user/styling-and-themes.md index 7235fa8d..2abf5189 100644 --- a/packages/unified-ui/docs/user/styling-and-themes.md +++ b/packages/unified-ui/docs/user/styling-and-themes.md @@ -139,6 +139,41 @@ box :activity_feed do end ``` +## CSS Stylesheet Blocks + +CSS stylesheet blocks are optional authoring input inside the `themes` section. +They are parsed, matched against authored nodes, resolved through cascade rules, +and lowered into canonical style data before `UnifiedIUR` output is emitted. + +```elixir +themes do + css :workspace_styles do + source(""" + #activity_feed { + background-color: #0f172a; + padding: 12px 16px; + } + + button:disabled { + opacity: 0.45; + } + """) + end +end +``` + +Selectors can target stable authored ids, portable classes, widget or component +kinds, supported descendant and child relationships, and supported state +pseudo-classes such as `:disabled`, `:focus`, `:selected`, and `:active`. + +The `class` and `classes` widget attributes are portable selector metadata and +optional runtime hooks. They do not load a runtime stylesheet by themselves. + +Accepted CSS syntax does not imply full browser CSS semantic equivalence. +Unsupported selectors, at-rules, properties, values, units, functions, and +unsafe external resources are ignored with diagnostics instead of being emitted +as raw runtime CSS. + ## Supported Style Attribute Families The canonical style model currently groups attributes into: @@ -192,8 +227,11 @@ Supported component states currently include: - Put reusable styling in `themes`. - Use `style_refs` when multiple widgets should share the same component style. - Use local `style(...)` for focused per-node overrides. +- Use CSS blocks when selector-oriented authoring is clearer than repeating + equivalent canonical style maps. - Prefer semantic roles and tokens over hard-coded color repetition. -- Keep styling canonical; do not author runtime-specific CSS or renderer-local options here. +- Keep styling canonical; do not rely on runtime-specific CSS or renderer-local + options here. For a full cross-cutting example, inspect `UnifiedUi.Examples.ThemedSignalWorkspace`. diff --git a/packages/unified-ui/test/unified_ui/documentation_test.exs b/packages/unified-ui/test/unified_ui/documentation_test.exs index 09d78d02..68e8d112 100644 --- a/packages/unified-ui/test/unified_ui/documentation_test.exs +++ b/packages/unified-ui/test/unified_ui/documentation_test.exs @@ -30,4 +30,12 @@ defmodule UnifiedUi.DocumentationTest do "guides/maintainer_workflows.md" ] end + + test "documents CSS block caveats in styling guidance" do + styling_doc = File.read!("docs/user/styling-and-themes.md") + + assert styling_doc =~ "CSS stylesheet blocks" + assert styling_doc =~ "does not imply full browser CSS semantic equivalence" + assert styling_doc =~ "raw runtime CSS" + end end From e139e363997a89bfc69920e4b6dbb5f13b3d5b27 Mon Sep 17 00:00:00 2001 From: Pascal Charbonneau Date: Fri, 15 May 2026 10:48:14 -0400 Subject: [PATCH 17/17] Add CSS compiler integration tests --- ...piler-iur-tooling-and-runtime-alignment.md | 24 +++---- .../css_phase_4_integration_test.exs | 62 +++++++++++++++++++ .../unified-ui/test/unified_ui/info_test.exs | 9 +++ 3 files changed, 83 insertions(+), 12 deletions(-) create mode 100644 packages/unified-ui/test/unified_ui/css_phase_4_integration_test.exs diff --git a/.spec/planning/css_style_authoring/phase-04-compiler-iur-tooling-and-runtime-alignment.md b/.spec/planning/css_style_authoring/phase-04-compiler-iur-tooling-and-runtime-alignment.md index ce57e601..155dcb9a 100644 --- a/.spec/planning/css_style_authoring/phase-04-compiler-iur-tooling-and-runtime-alignment.md +++ b/.spec/planning/css_style_authoring/phase-04-compiler-iur-tooling-and-runtime-alignment.md @@ -24,7 +24,7 @@ Back to index: [README](./README.md) - Examples should demonstrate CSS authoring and canonical output without implying full browser CSS coverage. -[ ] 4 Phase 4 - Compiler, IUR, Tooling, and Runtime Alignment +[x] 4 Phase 4 - Compiler, IUR, Tooling, and Runtime Alignment Integrate CSS-derived styles into deterministic compiler output, canonical IUR representation, author tooling, examples, and runtime realization checks. @@ -94,24 +94,24 @@ Back to index: [README](./README.md) [x] 4.3.2.3 Subtask - Document terminal and desktop degradation expectations for style concepts that cannot be visually identical to browser CSS. [x] 4.3.2.4 Subtask - Ensure runtime-specific stylesheet loading remains a runtime concern and is not required by canonical CSS block lowering. - [ ] 4.4 Section - Phase 4 Integration Tests + [x] 4.4 Section - Phase 4 Integration Tests Validate end-to-end CSS authoring from DSL input through canonical IUR and runtime-facing realization checks. - [ ] 4.4.1 Task - End-to-end compiler and IUR scenarios + [x] 4.4.1 Task - End-to-end compiler and IUR scenarios Verify CSS stylesheet blocks produce deterministic canonical output and diagnostics through the full compiler path. - [ ] 4.4.1.1 Subtask - Verify a CSS-authored module compiles into canonical IUR style attachments with no raw CSS runtime field. - [ ] 4.4.1.2 Subtask - Verify CSS-derived style values merge correctly with theme defaults, style references, local styles, variants, and direct props. - [ ] 4.4.1.3 Subtask - Verify inspection explains source block, selector, declaration, cascade reason, and ignored constructs for representative CSS-derived values. - [ ] 4.4.1.4 Subtask - Verify canonical IUR validation accepts CSS-derived canonical style data and rejects required raw CSS interchange data. + [x] 4.4.1.1 Subtask - Verify a CSS-authored module compiles into canonical IUR style attachments with no raw CSS runtime field. + [x] 4.4.1.2 Subtask - Verify CSS-derived style values merge correctly with theme defaults, style references, local styles, variants, and direct props. + [x] 4.4.1.3 Subtask - Verify inspection explains source block, selector, declaration, cascade reason, and ignored constructs for representative CSS-derived values. + [x] 4.4.1.4 Subtask - Verify canonical IUR validation accepts CSS-derived canonical style data and rejects required raw CSS interchange data. - [ ] 4.4.2 Task - Tooling and runtime boundary scenarios + [x] 4.4.2 Task - Tooling and runtime boundary scenarios Verify author tooling and runtime renderers observe the canonical boundary established by the ADR and specs. - [ ] 4.4.2.1 Subtask - Verify inspect/export/validate commands report CSS authoring summaries and diagnostics deterministically. - [ ] 4.4.2.2 Subtask - Verify maintained examples demonstrate supported selectors, supported declarations, ignored unsupported CSS, and the semantic-equivalence caveat. - [ ] 4.4.2.3 Subtask - Verify `live_ui` renders representative CSS-derived canonical style output through native style realization without needing the authored CSS block. - [ ] 4.4.2.4 Subtask - Verify cross-runtime parity checks compare canonical style meaning and documented degradation rather than raw CSS output. + [x] 4.4.2.1 Subtask - Verify inspect/export/validate commands report CSS authoring summaries and diagnostics deterministically. + [x] 4.4.2.2 Subtask - Verify maintained examples demonstrate supported selectors, supported declarations, ignored unsupported CSS, and the semantic-equivalence caveat. + [x] 4.4.2.3 Subtask - Verify `live_ui` renders representative CSS-derived canonical style output through native style realization without needing the authored CSS block. + [x] 4.4.2.4 Subtask - Verify cross-runtime parity checks compare canonical style meaning and documented degradation rather than raw CSS output. diff --git a/packages/unified-ui/test/unified_ui/css_phase_4_integration_test.exs b/packages/unified-ui/test/unified_ui/css_phase_4_integration_test.exs new file mode 100644 index 00000000..4fbc6d7b --- /dev/null +++ b/packages/unified-ui/test/unified_ui/css_phase_4_integration_test.exs @@ -0,0 +1,62 @@ +defmodule UnifiedUi.CssPhase4IntegrationTest do + use ExUnit.Case, async: true + + alias UnifiedUi.{Compiler, Export} + + defmodule PhaseFourWorkspace do + use UnifiedUi.Dsl + + identity do + id(:css_phase_four_workspace) + title("CSS Phase Four Workspace") + end + + themes do + css :styles do + source(""" + #panel { color: #2563eb; padding: 8px; } + button:disabled { opacity: 0.35; } + @import url("remote.css"); + .unsupported:hover { color: red; } + """) + end + end + + composition do + root(:css_phase_four_root) + + box :panel do + button :save do + label("Save") + disabled?(true) + end + end + end + end + + test "compiles CSS-authored modules into canonical IUR style attachments without raw CSS" do + iur = Compiler.iur!(PhaseFourWorkspace) + panel = iur.children |> hd() |> Map.fetch!(:element) + button = panel.children |> hd() |> Map.fetch!(:element) + + assert UnifiedIUR.Validate.element(iur) == :ok + assert panel.attributes.style.foreground == %{mode: :rgb, red: 37, green: 99, blue: 235} + assert panel.attributes.style.spacing.padding_top == %{value: 8, unit: :px} + assert panel.attributes.style.extra.css.properties == ["color", "padding"] + assert button.attributes.style.state_variants.disabled.visibility == %{opacity: 0.35} + + refute Map.has_key?(iur.attributes, :css) + refute Map.has_key?(panel.attributes, :css) + end + + test "inspection and export report CSS summaries and diagnostics deterministically" do + report = Compiler.inspection(PhaseFourWorkspace) + {:ok, inspection} = Export.module(PhaseFourWorkspace, :inspection) + + assert report.listing.css.summary.rule_count == 3 + assert report.listing.css.summary.diagnostic_count == 1 + assert inspection =~ "css blocks: 1" + assert inspection =~ "css rules: 3" + assert inspection =~ "css diagnostics: 1" + end +end diff --git a/packages/unified-ui/test/unified_ui/info_test.exs b/packages/unified-ui/test/unified_ui/info_test.exs index e91cc4d2..99a28ca3 100644 --- a/packages/unified-ui/test/unified_ui/info_test.exs +++ b/packages/unified-ui/test/unified_ui/info_test.exs @@ -75,6 +75,15 @@ defmodule UnifiedUi.InfoTest do default_target: :session, mode: :canonical }, + css: %{ + count: 0, + blocks: [], + parser: :csserpent, + rule_count: 0, + declaration_count: 0, + ignored_count: 0, + diagnostic_count: 0 + }, theme_catalog: %{ default_theme: :workspace, inherit?: false,