Skip to content

Add custom properties and var()#211

Open
jhaygood86 wants to merge 1 commit into
TylerBrinks:masterfrom
jhaygood86:feature/custom-properties-and-var
Open

Add custom properties and var()#211
jhaygood86 wants to merge 1 commit into
TylerBrinks:masterfrom
jhaygood86:feature/custom-properties-and-var

Conversation

@jhaygood86

@jhaygood86 jhaygood86 commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Feature

Custom properties and var() references (CSS Custom Properties for Cascading Variables 1):

:root { --main-color: red; --gap: 10px; }
.a { color: var(--main-color); margin: var(--gap) 5px; }

On master: --foo lexes as a - delimiter plus -foo, custom-property declarations are dropped, and any value containing var() is rejected against the property's own grammar.

Changes

  • Lexer — a second - also begins an ident, so --foo is one ident (fixed in both the top-level - dispatch and IdentStart). --> still closes an HTML-style comment.
  • CustomProperty — created for any --* name via PropertyFactory.IsCustomPropertyName, with its value kept verbatim (Converters.Any). - and -- alone are reserved, not custom properties.
  • var() on any propertyProperty.TrySetValue routes a value containing var() through Converters.Any rather than the property grammar, since the referenced value is only known per-element at cascade time (§3).
  • var() in a shorthand — kept whole rather than sliced into longhands (both StyleDeclaration.SetShorthand and the StylesheetComposer declaration path), because it can only be split after substitution (§3.2). margin: var(--gap) 5px stays a single margin declaration.
  • ValueExtensions.ContainsFunction — detects a function call anywhere, including nested (var() inside calc()).

Scope: object model only, no substitution

This adds the object model for custom properties and var() — parsing, storage, and serialization — so a stylesheet round-trips faithfully and the declarations are queryable. It does not include a var() substitution layer: a property that references var() keeps its value verbatim (e.g. color reports var(--c)), it is not resolved to the referenced custom property's computed value.

Fully resolving a stylesheet — walking the cascade per element, substituting each var() reference (honouring fallbacks and the guaranteed-invalid / cyclic-reference rules of CSS Variables 1 §3), then re-expanding any shorthand that contained one — is a separate cascade/computed-value layer that would have to be built on top of this. That layer is out of scope here; this PR is only the parser/object-model foundation it would consume.

If a substitution layer would be useful upstream, I'm happy to contribute one as a follow-up: this object model was extracted from a downstream renderer that already implements var() substitution on top of exactly these types, so a version that builds on this framework already exists and could be adapted.

Tests

19 cases in PropertyTests/CustomPropertyTests.cs: custom properties stored verbatim (including odd values), the CustomProperty type, IsCustomPropertyName (rejecting -/--), var() accepted on longhands and inside calc(), var()-bearing shorthands kept whole with longhands unpopulated, and the --foo-is-one-ident lexer regression.

The full suite (1263 existing tests) stays green — the -- lexer change and var() routing disturb nothing — and all seven target frameworks build with no new warnings.

@jhaygood86
jhaygood86 marked this pull request as ready for review July 22, 2026 23:16
Custom properties and var() references (CSS Variables 1) had no support:
"--foo" lexed as a '-' delimiter plus "-foo", custom-property
declarations were dropped, and any value containing var() was rejected
against the property's own grammar.

- Lexer: a second '-' also begins an ident, so "--foo" is one ident
  (both the top-level '-' dispatch and IdentStart). "-->" still closes an
  HTML-style comment.
- CustomProperty, created for any "--*" name via
  PropertyFactory.IsCustomPropertyName; its value is kept verbatim
  (Converters.Any). "-" and "--" alone are not custom properties.
- Property.TrySetValue routes a value containing var() through
  Converters.Any instead of the property grammar - the reference is only
  resolvable per-element at cascade time.
- A shorthand whose value contains var() is kept whole rather than sliced
  into longhands (both StyleDeclaration.SetShorthand and the
  StylesheetComposer declaration path), since it can only be split after
  substitution.
- ValueExtensions.ContainsFunction detects a function call anywhere,
  including nested (var() inside calc()).
@jhaygood86
jhaygood86 force-pushed the feature/custom-properties-and-var branch from 0fa3a4f to abb5272 Compare July 23, 2026 22:29
@jhaygood86

Copy link
Copy Markdown
Contributor Author

Rebased onto current master.

The --foo-is-one-ident lexer change this branch carried as a prerequisite is already on master (it went in with #212), so it has dropped out of the diff entirely. What's left is just the custom-property object model: CustomProperty, PropertyFactory.IsCustomPropertyName/CreateCustomProperty, ValueExtensions.ContainsFunction, the var() routing in Property.TrySetValue, and keeping a var()-bearing shorthand whole in StyleDeclaration/StylesheetComposer.

The "Dependency" section of the description above is therefore obsolete — there's nothing left to rebase around. The scope note still stands: this is the object model only, no var() substitution layer.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant