Skip to content

Make htmlang more elegant: the 24 review proposals - #1

Merged
iko-soy merged 53 commits into
masterfrom
claude/project-thread-stq41b
Sep 27, 2026
Merged

iko-soy merged 53 commits into
masterfrom
claude/project-thread-stq41b

Conversation

@iko-soy

@iko-soy iko-soy commented Sep 26, 2026 •

Copy link
Copy Markdown
Owner

Before: the compiler, the formatter and the language server each re-read raw lines their own way. A $variable was pasted into a line before it was split, so it could turn into an attribute or a comma. Each element decided text joining, spacing and centring through its own flags. Functions were written @let card $title $tone=#f9fafb and bound unnamed attributes by position. The same idea often had two spellings (first: and first-child:, $--x and var(--x), lighten() and CSS colour functions), and several mistakes were dropped without a word.

After: there is one syntax tree, shared by the compiler, htmlang fmt, the language server and a test that checks the VS Code grammar. A line is tokenized first, and then a $name fills exactly its own slot. Every element has one layout mode (column, row, grid, text, native or void), and that mode decides how text, spacing, fill and centring behave. Functions are declared as @let @card [title, tone #f9fafb] and called the same way, @card [title Hello]. Every remaining spelling is CSS's or HTML's own, and every mistake gets a diagnostic with a stable code.

This implements all 24 proposals from the elegance review. Each proposal is one commit plus a review-fixes commit, and the final four commits come from a whole-branch review.

# Proposal What changes for someone writing htmlang Commit
P19 One grammar, shared by every tool A DIRECTIVES table and one lossless syntax tree used by the compiler, fmt and the LSP e5cf070
P6 Parse first, then substitute $x fills one slot only; ${x} is the delimited form; $5 is text e57a996
P17 Commas, quotes and backslashes One closed escape table (\\ \$ \@ \{ \} \[ \] \, \" \--) everywhere c563149
P22 Functions are marked where they are defined @let @card [...]; values, bundles and functions share one namespace df75a0a
P21 Parameters are passed the way they are declared name value binds, a bare name is true, and a missing required parameter is an error 71ed520
P2 Functions really are elements Other attributes go to the root element; inline, chained and nested calls work; shadowing a built-in warns 0ddbe68
P9 Slot mistakes are errors An unknown, duplicate or missing slot is an error that names the declared slots 23be553
P4 One if() if(c, [group]) for whole attributes and ${if(...)} for values; CSS's own if() passes through 87f32bd
P18 Typed values, real lists, lexical scope One list type, records passed whole, and @each $x needs its $ 62656a6
P5 Check names, not values An undefined $x is an error; unknown CSS properties warn; nothing is dropped silently 9b68ce9
P1 One layout per element Each TAGS row has one mode; spacing works only on containers c053967
P3 One text rule Arguments read like text everywhere, except @code and @textarea, whose content is literal 84e5043
P15 Every HTML element under its HTML name A fixed list of HTML elements by layout class; @link and @image stay 19a1192
P16 Layout words follow the direction CSS sets Children follow the parent's real flex-direction, even under md:; centring uses auto margins 24640ee
P10 One rule for the leading argument The first token fills the row's one leading attribute (href, src, action); a duplicate is an error b05e86c
P13 One verbatim rule @code is escaped text; a header is a one-line body (@raw, @Style, @Head) or a file (@markdown, @script) fb1f74b
P14 The page is the root element @page styles <body> and puts key=value on <html>; a second @page is an error 9206abc
P11 One CSS path One generic path per property; generated classes use the hl- prefix; resets stay on htmlang's own elements e875ec8
P12 The px rule in one sentence One explicit table of length properties; any other value is used as written af4428d
P8 Custom properties are ordinary properties --x value works on any element and under any prefix, used as written 149fcb9
P7 Prefixes compose md:hover: composes left to right; hover:[group] and hover:$bundle distribute; a fixed rule order 2693aad
P23 Selector prefixes under their CSS names first-child:, nth-child(2n): and the rest are checked against CSS's list 801c075
P20 Style every element of a kind inside @table [@td:padding 8] compiles through @scope 47b2d6c
P24 Colour maths in CSS color-mix() and relative colours; a token is read as var(--x) 2054535

Intended breaking changes. Following 32f2947, this adds no migration layer, so old spellings get the ordinary diagnostics:

  • @let card $a $b=1 becomes @let @card [a, b 1].
  • $--x becomes var(--x).
  • lighten(), darken(), alpha() and mix() become CSS colour functions.
  • first:, last:, odd:, even: and nth: become their CSS names.
  • @each x becomes @each $x.
  • Generated classes are now hl-*.

Left as they are, on purpose:

  • A diagnostic from a function body is reported once at each call site.
  • An undefined variable in a branch that doesn't run is not reported, because it may come from data.
  • A block card inside @link becomes an inline-flex span; use a styled @link column instead.
  • Mutual recursion between functions is not supported, because of lexical scope.
  • There are still no content-model checks, and most diagnostics still have no column.

How: each proposal was implemented against a written spec, then a separate adversarial review probed it with the compiler and fixed what it found. A final pass ran five independent reviews over the whole branch: docs, tools, semantics, visual output and leftovers. cargo fmt, clippy -D warnings and the full suite pass at every commit. The suite grew from 563 to 1003 tests, including new tests/editor.rs and tests/fmt.rs. The four examples compile with no warnings.

🤖 Generated with Claude Code

https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc

The compiler, the formatter and the language server now read htmlang
through one syntax tree, and the compiler checks the whole file rather
than only the code that runs.

For htmlang writers:
- An attribute list belongs to the element name right before it; any
  other `[` is text, so `@text Use [ to open` is one line of text. A `>`
  chains only between element heads.
- An indented line under a directive that takes no body (@page, @include,
  @DaTa, @meta) is an error instead of a silent sibling.
- Untaken @if/@else branches, uncalled function bodies and loops over
  empty lists are checked for unknown elements, functions and attributes.
  Functions may be used before their definition and from included files.
- Every diagnostic has a stable code: `error[unknown-element]: line 3:`
  on the command line, a `code` (and `column`) field in --format json.
- `htmlang fmt` prints the tree back: comments and blank lines are kept,
  unclosed lists and multi-line JSON are left as written.
- The language server resolves relative @include and @DaTa from the
  file's folder, keys its quick fixes on codes, and takes definitions,
  folding, semantic tokens, links, lenses, rename and hover from the tree
  (so CSS inside @Style is no longer read as htmlang).

How: htmlang-core gains syntax.rs (a lossless tree with spans for heads,
chains, attributes, arguments, text segments and trivia), diagnostic.rs
(Diagnostic with code, subject and suggestion) and a DIRECTIVES table in
ast.rs with each directive's argument grammar and body kind. The
evaluator walks the tree; the formatter, the LSP and tests/editor.rs (the
VS Code grammar's word lists) all read the same table. Compiled output
of every snapshot and example is unchanged.

Left out: attribute values are still substituted from the raw token (the
tree already exposes key and value), text children of a line are still
flattened as siblings, and mixed tab/space indentation is not reported.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
- A known name in the wrong place no longer suggests itself ("unknown
  element @b, or did you mean @b?"). A function called before its @let
  has run, a function used inside text and a directive in a chain each
  say what is wrong, and the function counts as used.
- @include and @meta without their argument, and @meta without a value,
  are missing-argument errors instead of being dropped silently.
- Syntax errors are reported in line order (a stray @else came after the
  errors found while reading the lines).
- An escaped quote inside a quoted attribute value no longer splits the
  attribute at a following comma: comma splitting now matches quotes the
  way the attribute list's bracket matching does.
- DESIGN.md no longer says a function may be called before the line that
  defines it; that holds for code that doesn't run (and so for function
  bodies), not for calls that run.
- Tests: the cases above, and the language server resolving a relative
  @include and @DaTa from the document's folder.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
A `$name` or `${...}` now fills exactly the slot it is written in: a run
of text, an attribute's value, an element's argument, a @let value, a
@data/@include/@markdown path, the @page title or a @meta value. What it
inserts is never read again, so a variable can no longer become an
attribute, an attribute's name, a comma between attributes, markup or
another `$name`. `@let attr href=/x` + `@el [$attr]` is now an error
(attribute-from-variable) that says attributes come from a bundle, and so
is `[$key 8]`.

Where a name ends is one rule: `$` then a letter or `_`, then letters,
digits, `_` and `-` (a `-` only before another name character);
`$--name` stays the custom-property value. `.field` continues a name only
when the value is a record or list, so after `@let lang fr`,
`$lang.json` is `fr.json` and DESIGN's `@data $t locales/$lang.json`
works. `${name}` is the delimited form, `${EXPR}` an expression; `$5`,
`$$` and a trailing `$` are text. An undefined name is an error in text,
attributes, @let, paths, @if, @each and expressions (it used to print
itself, or warn only when a close name existed); a field a record lacks
stays empty.

How: a new interp.rs holds the one routine (name scanning, reference
resolution, interpolation, syntactic name listing). expr.rs lexes names
with it and interpolates string literals with it; parser.rs uses it for
every slot, reading attribute keys and values from the syntax tree
instead of re-splitting substituted text. Diagnostics carry a column and
the source line where the line is a single physical line, and a problem
in a loop is reported once. Attribute names are now checked in code that
doesn't run even when the value holds a variable. The language server's
semantic tokens and inlay hints end names with the compiler's rule, and
the TextMate grammar highlights `$--name` and `${...}`.

Tests: regressions for each rule above, an LSP test, and a new
variable_slots snapshot. Two tests changed deliberately: one used an
undefined variable as false in @if and one relied on a leaked @let
reading as text; both are errors now. The mixin_spread snapshot wrote a
literal `$define`, now `\$define` (same output).

Left out: escapes in attribute values (P17), typed values and scoping
(P18), and the rest of the never-drop policy (P5).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
- The language server reads variables as the compiler does. `${name}` is
  the variable `$name`, so hover, go to definition, find references,
  rename and linked editing now find it, and it is highlighted. A name
  ends where the compiler ends it: `$a- b` is `$a`. The shared helper is
  a new `interp::name_spans`, which `interp::names` is now built on.
- `[${card}]` splices the bundle, like `[$card]`. It used to say
  "undefined attribute bundle '${card}'".
- A column after a text escape (`\@ \$ $nobody`) now points at the
  variable. It used to be one byte too far for each escape before it.
- An invalid `${...}` points at its `$`. An invalid @if condition or
  `@let x = ...` inside a loop is now reported once, with a column, like
  the other variable problems.
- The same problem in the main file and in an included file, on the same
  line and column, is reported for both. It used to be reported once.
- `padding $card` (a bundle used as a value) no longer also warns that
  `$card` is unused.
- Removed a stale "Variable substitution" section header in parser.rs.

Tests: 4 regressions and 1 language-server test.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
For someone writing htmlang:

- One closed escape table in every htmlang string (text, arguments,
  attribute values, @let, @page, @meta, paths, inline @each lists):
  \\ \$ \@ \{ \} \[ \] \, \" \--. A backslash before any other character
  is kept as written, so CSS's `content "\201C"` and a pattern's
  `pattern=\d{3}` pass through. Before, escapes worked only in text and
  arguments (`title=a\$b` kept the backslash) and there was no \, \] \}.
- `\,` keeps a comma in an attribute's value:
  `transition opacity 0.3s\, transform 0.3s`. A value split at a comma
  (`box-shadow 0 1px red, 0 2px blue`) now says to write `\,` in its
  unknown-attribute warning.
- Quoted text remembers that it was quoted, as in Sass: `"..."` keeps its
  quotes in a CSS value and loses them in text, HTML attribute values and
  parameters, including after passing through @let or a parameter.
  `@let arrow "→ "` then `before:content $arrow` gives `content:"→ "`;
  `@card [lede "Fast, simple"]` shows `Fast, simple` (it used to show
  `&quot;Fast, simple&quot;`). Inside a quoted CSS string, quoted text
  inserts what it says. A quoted `@let --name` custom property keeps its
  quotes. `@meta name "..."` loses them like any HTML attribute value.
- `@let areas "head head" "side main"` is no longer cut to
  `head head" "side main`: only one whole `"..."` is quoted text.
- The font-family special case is gone. This reverses 81f89d2's "font-family
  keeps unquoting a font stack": CSS means what CSS says, so
  `font-family "Inter, sans-serif"` is one family. It now warns
  (invalid-value, with a language-server quick fix) and suggests
  `font-family Inter\, sans-serif`.

How:

- syntax.rs holds `ESCAPES`, `escape_len`, `unescape` and `quoted_string`;
  attribute lists, comma splitting, text and inline elements skip escapes,
  so the formatter (which prints the tree) keeps `\,` inside its attribute.
- interp.rs has a `Sink` (Text or Css) and `Scope::quoted`: a quoted
  variable inserts its CSS form in CSS and its text elsewhere.
- parser.rs fills every slot through one escape-aware routine
  (`fill`/`fill_value`); escapes are private-use placeholders while a slot
  is filled. Quoted variables keep their CSS form under `name"` next to
  `name`, as lists keep their length under `name#`, so scoping, loops and
  parameters handle them for free. `Attribute` carries `quoted` (both
  forms) so a quoted argument binds a quoted parameter; a function call's
  parameter values are filled as text.
- codegen no longer strips quotes from HTML attribute values (the parser
  already did) and no longer unquotes font-family.
- The TextMate grammar highlights escapes and quoted strings in attribute
  lists (so `\]` and `"]"` don't close the list); a test checks its escape
  rule against `ESCAPES`. Language-server variable highlighting and rename
  skip `\$name`.
- DESIGN.md: escapes table, `\,`, quoted text, font stacks. README,
  CONTRIBUTING, VS Code README, @let hover docs, demo.hl (a two-layer
  box-shadow with `\,`).

Tests: new snapshot escapes_and_quotes, regressions for each rule, unit
tests in syntax.rs, interp.rs and fmt.rs, an LSP quick-fix test and a
grammar drift test. Changed on purpose: quoted_font_stack_is_one_attribute
(now font_stack_keeps_its_commas_escaped plus
quoted_font_family_is_one_family_and_warns) and
quoted_let_values_are_not_evaluated_as_arithmetic (now uses the bare
`@let span 1 / -1`, since quoted text keeps its quotes in CSS).

Left out: single-quoted HTML attribute values ('...') are no longer
unquoted; `"` is the one quote. Expression string literals (`${...}`,
@if) keep their own syntax and have no escapes. A computed `@let x = "a"`
is not quoted text.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
- `if(cond, a, b)` split its branches at an escaped comma, so
  `transition if($on, opacity 1s\, color 1s, none)` was left unevaluated
  as `if(true, opacity 1s, color 1s, none)`. Its arguments now skip escapes.
- An unquoted value inserted into a CSS string could break the string:
  `@let dir C:\\` in `content "$dir"` gave `"C:\"`, and a value holding
  `\"` got a second backslash. A backslash and the character after it stay
  a CSS escape (`\201C`), `"` is escaped, and a backslash at the end of the
  value is doubled.
- The unknown-attribute hint about `\,` now also covers the most common
  case, an unquoted font stack: `font-family Inter, sans-serif` warns about
  `sans-serif` and says how to keep the comma. It is given for a bare
  unknown word after another attribute when there is no "did you mean".
- The language server read attribute lists with plain `[`, `]` and `,`
  scans: signature help counted `\,` as a new argument, and value
  completions and the owning element were thrown off by `\,`, `\]` and
  quoted text. One `attr_context` helper now reads the list the way the
  compiler does.

Tests: regressions for the if() branches, the CSS string and the font
stack hint; interp unit cases; LSP tests for escaped commas in completion
and signature help.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
A function is now defined as `@let @name [params]` with an indented body,
or `@let @name` when it takes no parameters. The parameter list is read
like a call's attributes: a bare word is a parameter (`title`) and
`name value` is one with a default (`tone #f9fafb`, `words Hello there`),
so a default may hold spaces and commas (`\,`). The body uses them as
`$title`. Values (`@let x v`, `@let x = expr`, `@let x "s"`), bundles
(`@let x [..]`) and custom properties (`@let --x v`) keep their bare names.

Values, bundles and functions share one namespace: a @let replaces
whatever the name meant before, and calling a value or a bundle says so.
New errors: an indented body under a value or a bundle (what the old
`@let card $title` + body gets now; the body is not rendered), `@let $x`
(suggests `@let x`), `@let x` with no value, `@let @x` with no body, a
parameter written `$p` or `p=v` or declared twice, a name no `$name`
reaches, and text after a bundle's or parameter list's `]`. `@each`
writes its variables with `$`, like `@data $name`.

How: syntax.rs decides the kind once from the line (LetForm::Function
holds the list and its Params, with name spans), check_body only checks
bodies, and the evaluator forgets a name's old meaning before a @let. The
language server already read definitions from the tree; its parameter
labels, signature help, symbols, semantic tokens, extract-to-function and
snippets now use the new form, and a quick fix moves the `$` for the new
invalid-definition / invalid-loop diagnostics. The formatter prints
function heads canonically. std.hl, the examples, DESIGN.md, README, the
VS Code grammar and snippets, and every test source were rewritten; the
examples compile byte-identical. Tests whose positions moved (LSP rename,
definition ranges, signature label) were updated for the new spelling.

Left for P21: required parameters, defaults evaluated at call time, and
the call-site rules (bare word is true, `name=value` for a parameter).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
A @let inside a function call, an @if branch or an @each body no longer
deletes an outer definition of another kind for good. The scope used to
restore only values, so `@let gap 9` inside a function body removed the
file's `gap` bundle for the rest of the file. A scope now saves the whole
namespace and, when it ends, restores its values and brings back a bundle
or function that a value inside it replaced, while bundles and functions
defined inside it still stay (as before) and remove the value they
replaced. Functions are held behind Rc, so saving them is cheap.

The formatter no longer drops text from a malformed function head:
`@let @c $t` lost its `$t`, and `@let $pad [..]` / `@let @$c [..]` lost
their `$`. Such lines are kept as written, like text after a `]`.

Smaller: `@each $ in ...` said "`@each $$ in LIST`" and suggested `$$`;
the message now shows the whole corrected header (`@each $x, $i in
LIST`) and offers no fix for a missing name. Parameter diagnostics carry
a column on the head's line. The function-name message mentions `_`.
The language server offers no CSS attributes inside a definition's
parameter list, and escapes `\`, `$` and `}` in a call snippet's default.

Tests: scope regressions, the @each messages, the parameter column, the
formatter's kept heads, and two completion tests.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
A function's parameter list now means at a call what it says at the
definition. `@let @card [title, tone #f9fafb]` declares a required
`title` and a `tone` with a default, and:

- A call that leaves out a required parameter is an error naming the
  function and the parameter (missing-parameter), instead of binding an
  empty value silently. Code that doesn't run is checked too, unless a
  bundle or if() in the call could pass it.
- A default is filled in at each call that leaves its parameter out, like
  a value passed for it: spaces, quotes, escapes, if() and $variables,
  including the parameters declared before it (`heading "About $title"`).
  It used to be raw text, so `tone $brand` gave the text `$brand`. A
  default that uses a later parameter is an invalid-definition error, and
  a problem in a default is reported once, at the definition.
- At a call, `name value` binds a parameter and the name alone binds
  true (`@post-card [post $p, featured]`); it used to be ignored.
- `title=Hi` for a parameter is an error (parameter-form): `=` writes an
  HTML attribute, parameters are written `name value`. The value is still
  bound, so nothing cascades.
- Unnamed attributes are no longer bound to parameters by position; they
  style the root element like any other attribute that isn't a parameter.

How: FnDef keeps the syntax Params (order, defaults, spans) and the
definition's line, text and node; expand_fn_call binds by name and fills
defaults with the definition as the current source, so columns point into
the head and loops report once (ParseContext::push_once). New codes
missing-parameter and parameter-form. The language server offers the
parameters a call hasn't passed yet, highlights the parameter being
written by name in signature help, marks required parameters in hover
and signature help, and rewrites `name=value` as `name value` as a quick
fix. DESIGN.md, README, the @let hover docs, the VS Code README and
snippet description, and demo.hl (a `dot` flag on @badge; demo.html
regenerated with -d) follow.

Tests: snapshot `parameters`, focused tests for each rule in
snapshots.rs, two regressions, LSP tests for the quick fix, completion and
signature help. Changed on purpose: the regression
named_argument_is_not_used_positionally_for_another_param left out a
required parameter, which is now an error; its `title` has a default now.

Left for P2: validating the attributes forwarded to the root, inline
calls, and the warning for a @let that shadows a built-in element. P18
owns scoping: a default sees the same scope as the body (the call-time
variables plus the earlier parameters) until functions see the scope of
their definition.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
- `title="a, b"` for a parameter said "write `title a, b`", which splits
  the value at the comma: the evaluated value was shown. The error is now
  reported from the call's list as written, with the value as written and
  its column (also on a later line of a multi-line list, where the quick
  fix now finds it). A `name=value` that comes from a bundle is still
  reported at the call, without a column.
- missing-parameter points at the call's `@name` (line and column).
- Code that doesn't run showed its line without its indentation, so a
  column (now given by missing-parameter and parameter-form) put the caret
  in the wrong place; the line is shown as written.
- A default that uses a later parameter also gave an undefined-variable
  error at every call (or silently read an outer variable of that name);
  the definition's error is now the only report.
- The language server's call snippet passed every default as a value.
  Since defaults are filled in at the call and may use earlier parameters
  (`heading "About $title"`), passing one as text reads the caller's
  `$title` instead; the snippet now passes only the required parameters,
  and completion in the list offers the others. Its test is replaced.

Tests for each fix in snapshots.rs and the LSP quick-fix test.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
A function is now called wherever an element can be: on its own line, as
a link of a chain (`@el > @card [title T]`, `@card [title T] > @link /x
More`) and inline in text (`Press {@key Ctrl+K}`). An inline or chained
call whose body has several roots, or is text, stands in its place like
@Fragment. Every attribute of a call that isn't a declared parameter,
key=value HTML attributes and flags included, goes to the root element
and is checked like an attribute written there, so `@box [paddin 20]`
and `@box [20]` get the ordinary unknown-attribute warning instead of
vanishing, and `@label [spacing 4]` says spacing has no effect on @text.

A function may appear in the content its caller passes it, and it may
call itself under a condition that stops it: the outright recursion ban
is replaced by a depth limit of 64, whose error (`recursive-call`) says
the function needs a condition. A parameter passed one `$name` holding a
record or a list gets the whole value, so a guarded function renders
nested menus and trees. A function named like a built-in element or a
directive (`@let @button`, `@let @if`) is a new warning,
`shadows-built-in`; the README's example function is renamed @CTA.

Elements a function's body writes carry the call's line (also in --dev
source maps) and the function's name, so warnings about them (low
contrast, no-effect, lint) point at the call and end "in the body of
@card". A problem in the text of a body stays on its own line when the
function is in the file being compiled; for std.hl or an included file
it is reported at the call, "in @box (line 3 of lib.hl)", without a
subject so no quick fix edits the wrong line. Identical diagnostics are
reported once, so a call in a loop warns once, and lint no longer flags
the empty @el of @spacer.

How: one resolver (`resolve` + `Evaluator::complete`) turns a head into
an element or a prepared `Call` for whole lines, chain links and inline
segments; `expand_fn_call` evaluates the caller's text before entering
the call and restores the caller's current line afterwards; calls are
parsed with validation on, skipping parameter keys; `check_call` checks
the other attributes of calls that never run, inline ones included;
`Element.function` and `belongs_to_call` attribute expansions; FnDef
records its file. The LSP completes elements and functions after `{@`,
and its @let doc says where calls go. DESIGN.md (functions, chains),
README, the demo (an inline @key) and the VS Code README follow; the
docs test now rejects shadowing names.

Changed test: a regression that expected `{@f}` to be an unknown element
now checks a call before its @let has run. Left out: `title=x` for a
parameter stays the parameter-form error settled by P21 rather than the
spec's forwarded HTML title; problems in a default of another file's
function still point into that file; @text/@Li arguments still print
`{@...}` literally (P3).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
An inline call's attributes are now checked against its root element, like
those of a call on its own line: `{@Label [spacing 4]}` warns that spacing
has no effect on @text. The check covers every element inside a line of
text, so `{@text [spacing 2] x}` warns too, as it would on its own line.

A problem in a default of a function from std.hl or an included file is
reported at the call, "in @box (line 1 of lib.hl)", like a problem in its
body, instead of at a line of the other file. The doc comment of
is_definition_name, which the shadowing helper had split from its
function, is back on it.

Tests: an inline call checked against its root, and an included
function's default reported at the call.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
@children and @slot NAME keep their names and meaning; what changes is
that content which would go nowhere is now an error instead of vanishing:

- a `@slot NAME` block at a call whose function declares no such slot
  (unknown-slot): "@card has no slot 'foter', did you mean 'footer'?
  (its slots: footer)";
- text after a call, or lines under it, for a function whose body has no
  @children (unexpected-content), also for chained and inline calls, so
  `@spacer Hi` or `{@box text}` no longer drop their text;
- a `@slot` block nested inside an element at the call instead of directly
  under it, and @slot or @children outside a function's body or inline in
  text (misplaced-slot). Blocks under @if, @else and @each directly under
  the call still fill slots, and a body can still pass on its content
  (`@children` under a call) and its slots (`@slot footer` holding
  `@slot footer`);
- a slot name that is not one word (invalid-slot-name, with `my-footer`
  suggested for `my footer`), a bare `@slot` (missing-argument), and
  attributes on @slot/@children (unexpected-argument).

A function's slots and whether it has @children are read from its body
when it is defined (a `@slot` directly under a call in the body fills that
call; anywhere else it declares one), so a slot declared under an @if
counts. Unknown slots are checked where the block is written, content at
the call as evaluated, and code that doesn't run gets the same checks.
DESIGN.md documents @children's fallback content next to @slot's and
lists the errors; README and the demo prose mention them.

Language server: hover on a function lists its slots and whether it takes
content, `@slot ` under a call completes that function's slot names, and
the unknown-slot / invalid-slot-name diagnostics have quick fixes. The
@slot and @children hover docs are rewritten. Formatter, CLI, grammar,
snippets and std.hl needed no change. demo.html is regenerated with -d.

New snapshot slots_and_children; focused tests in snapshots.rs, a
regression for the silent drops, LSP tests. No existing expectation had
to change. Left out: an @include inside a function body is checked as the
top level of its file, so a @slot there is reported as outside a body.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
A file included in a function's body, or under a call, is checked where
its @include is written. Before, its top level was checked as the top
of a file, so code that worked before this change failed: a partial
included in a layout's body that holds @children or @slot NAME was
reported as misplaced-slot, and the function was said to take no
content and to have none of those slots (unexpected-content and
unknown-slot at every call); a file of @slot blocks included under a
call was reported as misplaced too.

How: each @include line records the files it brought in (by its node
id, with the include chain); the placement walk follows them from the
main file instead of walking every included tree as a top level, and the
filler check follows them too, so a misspelled slot in an included file
of blocks is still an unknown-slot error that names the file (the check
now runs after the lines under the call). A function's declared slots
and @children read the files its body includes when it is defined, since
the @include runs only at a call. The walker's position is one struct
(At) and its lookups another (Scan), instead of seven arguments.

New test: an included file's slots are where its include is.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
For someone writing htmlang: a condition in an attribute list is written
one way. `if(CONDITION, A, B)` as a whole attribute chooses attributes,
and each branch is an attribute, a `[group]` of attributes or a `$bundle`;
an empty branch (or a missing B) leaves the attribute out. One condition
that decides several attributes is one if() with a group:
`@link [if($id == $current, [background #eee, font-weight 600, aria-current=page])]`.
A value that depends on a condition is an expression, `${if(c, a, b)}`,
whose branches are expressions (a number or one word as written, other
CSS text quoted). The old value form (`padding if($on, 8, 16)`) is gone:
an if() written in a value is CSS's own if(), which passes through like
var(). An if() in a value without CSS's `:` gets an ordinary invalid-value
warning describing CSS's syntax. In expressions, if() evaluates only the
branch it takes (and takes 2 or 3 arguments), and `and`/`or` stop at the
side that decides, so `$n != 0 and 10 / $n > 1` works for 0.

How: syntax.rs reads a whole-attribute if() into `Attr.choice`
(`Choice { condition, branches }`, `Branch::{Empty, Attr, Group}`) with
real spans, so a group spanning lines keeps each attribute's line and
column. parser.rs `choose_attrs` picks branches before the attribute loop;
the branch not taken is checked like code that doesn't run (names and
literal values; its variables are not read, but count as used), and a
misshapen if() is an error (invalid-expression for the wrong number of
parts, unexpected-argument for text after a group's `]`). choose_if and
its splitters are removed, and so is the value form in parameter
defaults. expr.rs parses skipped parts without evaluating them (syntax,
function names and argument counts still checked, via one FUNCTIONS
table). The language server reads if() branches as the attribute being
written (completion inside a branch or group), offers an `if()` snippet
completion and hovers it; the VS Code grammar highlights `if` at an
attribute's start and nested groups, and has an `if(` snippet. Examples,
DESIGN.md, README, CONTRIBUTING and the VS Code README use the new forms;
docs.hl and blog.hl use one grouped if() instead of three.

Tests changed on purpose: the value-form tests in snapshots.rs and the
escaped-comma regression now use the whole-attribute or `${if()}` form;
if_expr.hl and parameters.hl are rewritten (parameters.html unchanged).
New: 8 snapshots.rs tests, an expr unit test, a syntax unit test and two
language-server tests.

Left out: no migration hint for the old value form (ordinary diagnostics
only); an attribute whose value fills to empty still emits `key:;`.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
…ixes

- A `$bundle` named only in an if() branch that isn't taken (or in an
  `@if` that isn't taken) counts as used, like a variable there, instead
  of being reported as an unused bundle.
- The warning for an if() in a value that isn't CSS's if() no longer fires
  on `if(` inside quoted CSS text (`content "see if(this)"`).
- An expression if() whose condition is followed by something other than
  a comma reports that token ("expected `,` but found `.active`") rather
  than a wrong argument count.
- The formatter prints a whole-attribute if() canonically: its groups like
  lists, on one line (`if($on, [padding 8, color red], [])`), so a group
  written over several lines no longer comes out as `[ padding 8, ... ]`.
  A misshapen if() is kept as written.
- Language server: hovering `if` works on a later line of a list that
  spans lines and inside groups (read from the syntax tree), and
  completion inside a group opened on a later line knows its element.
  The hover function's doc comment is back on it.

Tests: a formatter test; new assertions in the untaken-branch, CSS if(),
expression, hover and completion tests.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
For someone writing htmlang: a name now holds a whole value, one of
text, number, true/false, list or record, and @let, @DaTa, @each and
parameters bind it whole. `@let fruits apple, banana, cherry` is a list
of three: `${length($fruits)}` is 3, `@each` walks its items, and
`$fruits` still prints as written. A quoted item or `\,` keeps one text
with a comma in it (`"Fast, simple"` has length 12). A whole `$name` or
`${expr}` keeps its type, so a record passed as `[post $p]` arrives as a
record and `$post.title` works under any parameter name. Text functions
(upper, slice, ...) given a list or record are an error that says what
to write instead; length counts list items. @each takes a value: a
written list, a whole `$name`, a `${...}`, or a range `a..b` with an
optional `step n`; ranges also work inside `${...}` (`reverse(1..5)`),
and a variable's name ends at `..`. Iterating a record, or writing the
source in brackets, is an error instead of silently doing one thing.
`@let t.greeting hi` sets one field of a record.

Truthiness and comparison are defined once: false, 0 (any spelling,
such as 0.0), empty text and empty lists/records are false; numbers
compare as numbers, everything else by its text, lists and records
structurally. Numbers print without float noise (`${0.1 + 0.2}` is 0.3,
`${100 / 3}` is 33.3333).

Scope is lexical: a definition is visible from its line to the end of
its block. A @let inside an element or loop no longer changes the name
outside it, each @each iteration starts fresh, bundles and functions
defined in a block stop leaking out of it, and a parameter hides a
function or bundle of the same name in the body. A function's body and
its parameter defaults see its parameters and the names visible where
the function is defined, not the caller's.

Deliberate output changes: in tests/snapshots/parameters.hl, a default
(`tone $brand`) now uses the `$brand` visible where @card is defined,
not the one defined later before the call, so the third card keeps
#eff6ff. Two tests that relied on a bundle or function leaking out of a
block now see it end with the block. The error for a function used
outside its block explains where definitions are visible. The four
examples compile byte-identical, with the same diagnostics.

How: a new value.rs holds Value (text, quoted text, number, bool, list
with its written form, record) with truthiness, comparison, number
printing and ranges in one place. parser.rs replaces the flat variable,
bundle and function maps with one Env of copy-on-write frames; blocks,
iterations and calls push a frame, and a function captures the Env at
its definition. slot_value decides once what a written value is (whole
reference, quoted text, comma list, or filled text). expr.rs parses
`a..b step n` and works on typed values. The language server offers and
goes to the names visible at the cursor (syntax::Tree::visible_at); the
VS Code grammar highlights @each sources and ranges, with a range
snippet. DESIGN.md gains a Values section; README, CONTRIBUTING and the
hover docs follow.

Left out: list and record literals in expressions, filter and sort.
Unused-name warnings and LSP references/rename still go by name, not
by scope, and a whole record written in text prints nothing.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
`@let name.N value` on a list turned the list into a record with a field
named N, so `$fruits` printed nothing and its length became 1. It now
replaces that item and the list stays a list. A field set on text, a
number or true/false, or past a list's end, is an invalid-definition
error instead of silently replacing the value with a record.

A `$name` that a `@let` defines, but that the line can't see (it was
defined in a block that has ended, or below a function whose body uses
it), now says so and gives the scoping rule, like the error for a
function used outside its block. Before, it was a plain "undefined
variable".

The language server's hover on a `$name` shows the definition visible
at that line (an inner `@let`, a parameter, an `@each` variable,
`@data`), as completion and go-to-definition already did, instead of the
first `@let` of that name in the file.

DESIGN.md says what `@let t.field` and `@let items.0` do. Tests cover
all three. The examples compile byte-identical.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
For someone writing htmlang: values go to the CSS as written, so
`max-width none`, `z-index auto`, `opacity 50%`, `color rebeccapurple`
and `width fit-content` no longer warn. A CSS property htmlang doesn't
know (`corner-shape squircle`) is written to the CSS with a "did you
mean" warning instead of being dropped; `--custom` and `-vendor-`
properties pass without one. Names are checked: a misspelled prefix
(`hovr:`) or parameter (`tnoe` for `tone`), a second prefix
(`md:hover:`, which codegen dropped), a flag htmlang doesn't know, an
HTML attribute written like a style, a style without a value and an
unknown element inside `{@...}` are errors. A value with `;` (outside
parentheses), `{`, `}` or an unclosed quote or parenthesis is an error,
also when it comes from `@data`, so data can't escape a CSS rule. What
codegen would drop is an error: attributes on `@fragment`, styles and a
text argument on `@script`, content in a void element, an unknown
`@page` attribute, attributes for a function body without one root.
A record written where text goes is an error; a field of a missing
field is empty. `width fill` in a column and `width=800, width 200` no
longer warn. `hidden` is a boolean attribute. The playground shows
warnings as well as errors, and the docs test fails on any warning.

How: vocab.rs gets prefix_len/split_prefixes (balanced has()),
is_property_name / is_custom_property / is_vendor_property and
HTMLANG_FLAGS. parser.rs replaces validate_attr_value's value lists
with check_attribute (names and prefixes) and check_css_value
(css_breakout, hex digit count, the font-family and CSS if() warnings);
an attribute reported as an error is left out. A bundle's names that
could be parameters are checked where it is spliced. codegen passes
unknown well-formed names through, leaves out a style whose value came
out empty, and gives @script every HTML attribute. interp.rs walks
missing fields and reports records in text. New codes unknown-prefix,
invalid-prefix, missing-value; unknown-color and fill-fallback are gone.

Tests changed on purpose: the value-checker and fill-warning tests now
assert the opposite; `[container]`/`[contain]` defaults (invented
output) became missing-value errors, so container tests write
`container-type inline-size`; tests that used a bare `bold` flag use
`font-weight bold`; stress_large.hl drops the `dark:hover:` attributes
that were silently ignored and escapes `$48\,295` (its output now shows
the whole number); inline unknown elements are errors, not warnings.

Left out: stacked prefixes compose in P7; `padding=4` (a CSS name as an
HTML attribute) still passes silently as an HTML attribute; a parameter
passed twice still keeps the first with a warning (P21).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
For someone writing htmlang: a quoted `;` in a value now survives a later
duplicate of the same property (`content "a;b", ..., content "c;d"` used
to leave `b"` in the CSS). A value with `</style`, even quoted and even
from `@data`, is an error, since it would end the page's style element and
start HTML. `inline` on anything but `@image`, or with a prefix, was
ignored without a word and is now an error. A misspelled parameter that
comes from a bundle spliced into a call (`@let b [tnoe red]`,
`@card [$b]`) is the same error as when written directly, instead of a
CSS property warning. `htmlang fmt` no longer wraps a list that holds a
custom property, which would have put `--gap 12,` at the start of a line
where it is a comment, and DESIGN says so.

How: dedupe_declarations tracks quotes and backslashes; css_breakout
rejects `</style`; check_attribute rejects a prefixed `inline` and
dropped_by_the_element an `inline` on another element; the parameter
check is one function (misspelled_parameter) used for written and
spliced attributes. Two messages read better: the comma hint no longer
follows a `?` with a `.`, and the no-effect message names containers
instead of "@Row, @el, @el".

Tests: four regressions and one formatter test.

Left out: a line of a multi-line list starting with `--gap` is still a
comment (P8 changes the comment rule).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
Every element now has one layout, and the layout decides what the lines
written inside it are. In a row, column or grid each line of text is a
child of its own and `spacing` is the gap between the children; in a text
element (@paragraph, @h1-@h6, @button, @Label, @td, @link, ...) the
argument, the lines and the children flow as one run joined with spaces.
So `@ol [spacing 4]` really spaces its items (@ul, @ol, @dl, @search,
@address and @noscript are flex columns now, with lists keeping their
marker reset), and a @button with the lines "Save" and "changes" says
"Save changes". A row, column or grid inside text is laid out inline
(inline-flex, inline-grid), and inside text @el, @Row and @grid are written
as <span>, so no <div> lands inside a <p>. Tables, @select, @pre, media and
form controls keep HTML's own layout; their text lines are separated by a
line break. `spacing`, `wrap`, `grid-cols` and `grid-rows` on an element
that isn't a row, column or grid are now an error and are left out (CSS's
own `gap` still passes through). A section, list or heading inside
@paragraph gets the new `block-in-paragraph` warning, since the browser
would move it out of the <p>. `width fill`, `center-x` and `align-*`
compile against the parent's layout, and `children:` styles against the
element itself.

How: ast.rs gains `Layout`; every TAGS row has one `layout` value, which
replaces the wraps_text, container, inline and void flags, the
"flex-direction:column" search and the display in each row's CSS.
Codegen passes the parent's layout down (a `Site` for CSS), writes the
display from the layout, and joins children by layout. The parser's
container checks and the empty-container lint key off the layout. The
language server offers `spacing`, `wrap` and `grid-cols` only on
containers and shows each element's layout on hover. DESIGN.md has the
full table (a test checks it against the compiler), with README,
CONTRIBUTING and the VS Code README updated and demo.html regenerated.

Tests changed on purpose: spacing on @text is an error, not a no-effect
warning; @dd's and @Li's text is a span; @noscript, @address and @search
have a flex-column class; an inline call with two roots joins them with a
space in a paragraph; a function called inline in text is a span, not a
div; the completion test checks `spacing` on @ul, not @input. Snapshots
that changed show the same.

Left out: `{@...}` in @h2's argument stays literal until text arguments
are parsed (next), `width shrink` still compiles to flex-shrink:0 and
`flex-direction row` doesn't yet switch a column's children to row
semantics (both belong with direction-aware layout words).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
…ew fixes

- An empty @Fragment between two lines of a text element left two spaces
  ("a  b"), and in a native element an extra line break. A child that
  writes nothing now takes no separator.
- block-in-paragraph warned again for every block inside the first one
  (`@paragraph > @section > @ul` gave two warnings, the second saying the
  browser moves the <ul> out of the <p>, which it had already left). Only
  the element that ends the <p> is reported now; the test that expected
  two warnings for `{@ul {@Li x}}` expects one, for the <ul>.
- The language server still offered `spacing`, `wrap` and `grid-cols`
  after a prefix (`@h2 [hover:`) on an element that isn't a row, column or
  grid. It now filters them there too, except after `children:`.
- Hover for @ul and @ol described bulleted and numbered lists, and @ol's
  example `[list-style decimal]` shows no numbers on a column of flex
  items. Both now say the markers are hidden and how to show them.
- DESIGN: @In-Front and @Behind are spans inside text too, and a block in
  a paragraph takes what follows it out of the <p>.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
The text after an element's attributes is now read like any other line
of text for every element: inline `{@...}` elements, escapes and `$names`
all work in it. `@h1 Hello {@text [color red] world}`, `@li Read {@link /x
more}` and `@td {@kbd Ctrl}` make elements where they used to print the
braces, and a misspelled `{@lnk ...}` in a @text or @h2 argument is now an
"unknown element" error instead of text on the page. How an element's
first line combines with the lines under it is its layout's business, as
P1 set it: in a row or column each line is a child (the argument is the
first one), in a text element the lines flow, joined with a space. Text
that @each, @if or a function writes counts as lines written in its place,
so in a text element it joins the run with a space too.

The text of @code and @textarea is shown as written: a `{@...}` in it is
text, on its line, in the lines under it and inline, so `{@code {@link /x
y}}` still prints `{@link /x y}` and pages that document htmlang keep
working. Escapes and `$names` still work there, and a multi-line @textarea
keeps its line breaks. Two lines of text at the top of the page are now
separated by a line break (a space on the page) instead of being glued
together ("Readmore").

How:
- ast.rs: TagArg::Text and renders_argument_as_text are gone; those rows
  take the default TagArg::Child. TagSpec gets `literal` (true for code and
  textarea) and ast::has_literal_text(name) reads it.
- syntax.rs: after the tree is built, the text of a literal element (any
  head of a chain, its line, every line under it, inline `{@code ...}`) is
  one plain segment, so the evaluator, the formatter and the language
  server all see the same thing: no inline heads inside literal text.
- parser.rs: only @link, @image, @script, @slot and TagArg::Attr rows keep
  a one-slot argument; every other argument becomes the element's first
  text child through text_segments, like any line. The empty-container
  lint and the button-text lint no longer look at the argument.
- codegen.rs: the argument-as-first-line path is gone; generate_children
  takes Option<Layout>, and the top of the page separates adjacent text
  lines like a native element.
- Language server: completion after `{@` offers nothing inside literal
  text (it asks the syntax tree), hover for @code/@textarea says the text
  is shown as written, @paragraph's summary no longer implies it is the
  only element with inline elements.
- VS Code grammar: `literal-line` and `literal-inline` rules keep
  `{@...}` in @code/@textarea text unhighlighted as elements, and inline
  elements nest; tests/editor.rs checks the names against the table.
- DESIGN.md (Text section, native-element paragraph, @paragraph row),
  README, CONTRIBUTING and the VS Code README say it.

Tests: snapshot text_everywhere; snapshots.rs tests for arguments of every
text element, the unknown-inline error, literal @code/@textarea, the
argument as the first line of a row and of a text element (with @each, @if
and a function), top-level lines; two regressions; a syntax unit test; an
LSP completion test; the grammar drift test. The one snapshot that
changed is new_elements_2: `@text This is {@mark highlighted} text` and
`@text Press {@kbd Ctrl+C} to copy` now produce <mark> and <kbd> instead
of the literal braces, which is the fix. Every other snapshot and the four
examples compile byte-identical.

Left out: @code/@textarea content is literal only for inline elements;
element lines under @code are still elements and escapes/$names still
apply (P13 decides verbatim bodies). Content a call passes into a
function whose body wraps it in @code is read at the call, so it isn't
literal. @option's text is parsed like any text, so an inline element in
it is written (the browser shows only its text); a content-model check
belongs with P15.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
A @Fragment's lines were glued to the lines around it in a native element
and at the top of the page: `@pre` with `s`, a @Fragment of `m` and `n`,
then `t` gave "sm\nnt", and a function whose body is a @Fragment did the
same. The text rule says text a @Fragment writes counts as lines written
in its place, so generate_children now writes a fragment's children in
the same run as its siblings, and they get the same line breaks. Text
elements and rows and columns are unchanged, and so are every snapshot
and the four examples.

DESIGN's Text section names @Fragment with @if, @each and functions, and
says the lines are on lines of their own in a native element.

Tests: a regression for the fragment separators; an LSP hover test that
@code and @textarea say their text is shown as written.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
htmlang now accepts the rest of HTML's body elements under their HTML
names, from a fixed list, each with its layout:
- text, with the browser's own style and no CSS of htmlang's: @b, @i,
  @Strong, @em, @Small, @s, @U, @sub, @sup, @q, @var, @samp, @dfn, @bdi,
  @bdo, @ins, @del (so `{@Strong important}` and `H{@sub 2}O` work inside
  a line);
- column: @hgroup, and @menu (a list like @ul);
- native: @caption, @COLGroup, @Tfoot, @optgroup (its argument is its
  label), @ruby, @rt, @rp, @object (argument: data), @Map;
- void: @br, @wbr, @col, @track and @embed (argument: src), @area
  (argument: href).
@link, @image, @text, @paragraph and @el keep their names. @A, @img,
@span, @p and @div are unknown elements whose error suggests them
("unknown element @A, did you mean @link? (@link writes <a>)"). <data>,
<slot> and <template> share names with htmlang's own or hold content only
a script uses, so they, and <svg> and <math>, stay in @raw. A name that
isn't on the list is still an unknown element, never a new tag.

How: new rows in TAGS; ast::HTML_NAMES_WRITTEN_OTHERWISE is read only by
the unknown-element suggestion (edit distance can't find @link from @A).
hgroup and menu join ENDS_A_PARAGRAPH. The @video captions lint looked
for a @source child as a stand-in for the missing @track; it now looks
for @track. DESIGN.md's principle reads "an element that isn't about
layout or text has its HTML name", and its element section, layout table
and examples list the new elements; README, CONTRIBUTING, the LSP hover
docs and per-element attribute completion, the TextMate element list, a
@table snippet and the examples follow. Inline `{@text [font-weight 700]}`
and `{@text [font-style italic]}` runs that only set weight or style
became @b, @Strong, @em or @code (demo.html regenerated).

Tests: snapshot html_elements, 6 snapshot tests, 1 regression, ast unit
tests. Test functions named @b and @q were renamed (@later, @box,
@quote), since those names are elements now.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
- `srclang`, `shape` and `coords` (of the new @track and @area) are in the
  HTML attribute list, so `@track [srclang en]` gets "write
  `srclang=en`" instead of an unknown-CSS-property warning (and `shape`
  no longer suggests `scale`).
- An LSP test that the `@a` error's suggestion becomes a "Replace with
  '@link'" quick fix, on its own line and inside `{...}`.
- CONTRIBUTING says a new element also needs its name in the grammar's
  element list (a test checks it); the VS Code README lists the `@a` to
  `@link` fix.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
For someone writing htmlang: a child's `width fill`, `height fill`,
`width shrink` and `height shrink` now compile against the direction the
parent's CSS really has: `@row`, a column, or the parent's own
`flex-direction` / `flex-flow`. So `@nav [flex-direction row]` is a row
for its children too, and a direction set under a media or container
prefix (`md:flex-direction row`, `print:`, `landscape:`, `cq-md:`) moves
the children's words with it at that condition. No new words. Along the
direction `fill` takes the remaining space and `shrink` keeps the
content's size (`flex-shrink: 0`); across it `fill` is the full size and
`shrink` is now `fit-content` (it used to be `flex-shrink: 0`, which still
stretched in a column). `center-x`, `center-y` and `align-*` are auto
margins in every parent, so the centred column is simply
`width fill, max-width 800, center-x`. A layout word never overrides a
property the element writes itself (`min-width 200, width fill` keeps the
200px, whatever the order).

How: codegen gives every element a `Flow` (base direction plus the
changes under block prefixes, and its class). Children compile their words
against `flow.at(prefix)`, where a width prefix holds from its width up.
For each change, the child gets a rule keyed on the parent's class in the
parent's own @media/@container block, `:where(.a)>.b{...}` (one class of
specificity, written before the class rules of the block so the child's
own prefixed styles still win), with every property a layout word could
have set put back to its initial value. `children:` words get `.a>*`
rules the same way. Elements with the same CSS but different keyed rules
get different classes.

Examples: landing's nav is `@nav [flex-direction row, ...]` without the
inner @Row, and its header/footer rows use `width fill` instead of
`width 100%` (the rows stay: they carry the max-width inside full-bleed
bands); demo's footer is `@footer [flex-direction row, ...]` and the tour
shows a column that is a row from md up (demo.html regenerated with -d).
DESIGN.md (layout words, the rule, keyed rules, the responsive header and
centred column examples), README, CONTRIBUTING, LSP docs (width, height,
center/align, new flex-direction entry, @el, column summary), LSP and
VS Code snippets (navbar, page column) moved with it.

Tests: snapshot layout_direction; 11 snapshots.rs tests; 3 regressions.
Changed on purpose: fill_and_center_compile_against_the_parent_s_layout
(center-x is auto margins in a column now); snapshots alignment and
layout_modes (align-self → auto margins) and stress_large (its
`sm:flex-direction column` row's `width fill` children are now full width
at sm and fill again at lg).

Left out: a direction under a state prefix (`hover:`, `nth:`, `has()`,
`children:`) doesn't move the children's words; a child's own words under
a non-width media prefix (`dark:width fill`) compile against the parent's
direction under that prefix only.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
For someone writing htmlang: `center-x` (and `center-y`, `align-*`) keeps
centring when the same element writes a `margin`, whatever the order:
`[center-x, margin 20]` is centred with 20px above and below. With auto
margins in every parent, a later `margin` shorthand wrote over them, so an
element in a column that used to be centred (align-self) no longer was.
And `width fill` next to a `flex-grow`, `flex-shrink` or `flex-basis` the
element writes itself still fills: `[flex-shrink 0, width fill]` grows and
never shrinks, `[flex-basis 200, width fill]` grows from 200px. Before,
any flex longhand left out the whole `flex:1`, so the element no longer
filled the row, and `width shrink` dropped its `flex-shrink:0` beside a
`flex-grow`.

How: the alignment margins of a pass are written after its other
declarations. `Sizes` records each flex longhand; a layout word's `flex`
(the fill's `1` or the reset's `0 1 auto`) is written as the longhands the
element doesn't write when it writes some of them, in the class and in the
rules keyed on the parent.

Tests: 2 regressions. The layout_direction snapshot and demo.html (-d)
change only by the order of declarations (the margins come last); landing
and blog likewise.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
For someone writing htmlang: an element that points at a URL or a file
takes it as the first word after its attributes, and every such element
reads it the same way. Its row names the one attribute it fills: href for
@link and @area, src for @image, @script, @iFrame, @video, @Audio, @track
and @embed, action for @Form, data for @object, label for @optgroup, and
for @source srcset inside @picture and src elsewhere. The word ends at a
space outside "..." and ${...}, and it is taken before any $name is filled
in, so `@link $url More`, `@link /page/${$n + 1} Next` and
`@link "/my page" Open` work. The rest of the text is content, read like
any text (`@form /subscribe Sign up` no longer puts "Sign up" in the
action); on an element without content (@image, @source, @track, @embed,
@area) a word after it is an error. `@link [href=/a]` means the same as
`@link /a`, and both at once (`@link [href=/a] About`) is an error that
says which word was taken. `@script app.js` is src (it used to be dropped
with an error), every HTML attribute passes through, and a src with a body
is an error.

How: TagArg gains Source, ElementKind::arg() gives @link's and @image's
too, and TagSpec gains `verbatim`: @script is now a TAGS row (the Script
kind is gone) and body_kind reads the flag. syntax::Text::split_leading
and leading_token_len replace split_first_word. parse_single_element reads
the token, reports the duplicate at its column, and moves a lone `href=` /
`src=` / `action=` attribute into the argument, so both forms compile the
same (the attribute is written first, and `@image [src=a.svg, inline]`
inlines). Codegen writes the argument through one emit_argument_attr,
with srcset for @source in @picture; @image without a src no longer writes
src="". An attribute passed to a function's root that repeats the body's
leading argument is reported too. LSP: element docs, and completion no
longer offers the attribute an argument written after the list already
gives. VS Code: a leading-argument grammar rule (drift-tested against the
compiler), the external-script snippet. DESIGN (new "The leading argument"
section), README, CONTRIBUTING.

Changed tests: snapshot new_elements (`@form [method=post, action=/submit]`
now writes action first, as the argument form does) and
fragment_script_and_void_elements_drop_nothing_silently (`@script b.js` is
its src, so the "write [src=...]" error is gone; src comes first).

Left out: @Form, @video and @Audio keep their leading argument (their
attribute is often left out, and DESIGN says text then goes on the lines
under them). The duplicate check doesn't run on code that never runs.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
For someone writing htmlang: a word after an @optgroup's label is an
error, as after the source of an element without content. The one-token
rule made `@optgroup Citrus fruits` the label "Citrus" with "fruits" as
text the browser doesn't show; the error says to quote it,
`@optgroup "Citrus fruits"`. An element under a @script
(`@script [defer] > @b x`) is an error instead of being left out of the
page. The one-way form in the duplicate-attribute message quotes a value
that is empty or has a space in it (`@link "/my page" About`), and the
quoting hint for a word after a quoted source doesn't quote it twice.

How: parser.rs gains takes_no_text (void layout, or @optgroup), used for
the trailing-text error, the duplicate case and its suggestion;
dropped_by_the_element reports an element child of a verbatim element
without a src. DESIGN and the LSP's @optgroup doc say so. Tests: three
new snapshots.rs tests.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
For someone writing htmlang: the lines indented under @code and @textarea
are a verbatim block. Nothing in them is htmlang (`@`, `$`, `{`, `\` and
`--` are text), and they are HTML-escaped with their line breaks and
indentation kept. So `@pre > @code` over an indented block shows a code
sample, htmlang included, where docs.hl used to paste it with @raw as live
HTML. @pre stays an ordinary element, so `<pre><code>` and a function that
wraps @pre around @children keep working. The text on @code's own line and
inline `{@code ...}` keep the P3 rule (a `{@...}` is text, escapes and
$names work), so a one-line sample can still use a variable. Text on the
line and a block together is an error.

The verbatim directives share one header rule. For @raw, @Style and @Head
the rest of the line is a one-line body: `@style .a { color: red }` used to
fail with "unknown element @Style, did you mean @Style?", and the text
after @Head was dropped. For @markdown the line is the file, and for
@script the src (P10). A line and a body together are an error for all of
them. Only @script takes attributes: `@raw [id=x]` used to write "[id=x]",
`@markdown [padding 8]` looked for a file of that name, and `@head [id=x]`
dropped them. Each is now an error with the list's column, so content
that starts with `[`, like a CSS attribute selector, goes in the block.
Which lines are verbatim comes from the tables, also at the end of a
chain (`@pre > @code`, `@el > @script`).

Readable output (-d, and so `htmlang serve`) no longer indents or breaks
lines inside <pre> and <textarea>, where every space shows.

How: body_kind makes a `literal` TAGS row's body Verbatim, and the
syntax tree's Verbatim node records `escaped` (the last head of the chain
is literal). The evaluator turns an escaped body into a text node and
every other body into raw output as before. @Style and @Head take
ArgGrammar::Text like @raw and @markdown, so ArgGrammar::None,
DirectiveArgs::None and DirectiveSpec::one_line are gone. A `[` right
after a verbatim directive's name is read as an attribute list (P19's `[`
rule) and reported. check_body reports @code and @textarea that have both.
LSP: directive and element docs, and no completions in a verbatim body.
VS Code: a verbatim-line rule for the one-line bodies of @raw, @Style and
@Head (drift-tested against DIRECTIVES), a code-sample snippet, and the
@Head and @font-face snippets written on one line. DESIGN (Text, Verbatim
bodies, the Directives table, CSS, Page and head), README, CONTRIBUTING,
and docs.hl, whose samples are now @code blocks.

Changed tests: code_and_textarea_show_their_text_as_written (a @code block
keeps its line break instead of joining with a space; `@textarea [..]
text` plus a block is now an error), verbatim_directives_take_a_line_or_a_body
and the syntax unit test body_kinds_are_checked (`@style .a {...}` is a
one-line body, so `@style x` plus a block is unexpected-body instead of
unexpected-argument), and the_text_of_code_and_textarea_has_no_inline_elements
(the lines under @textarea are a verbatim node now).

Left out: a block has no $names, so a version number in a sample can't
come from a variable (written on @code's line it can). The TextMate
grammar still can't mark the indented lines of a verbatim block.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
The language server now treats the one-line body of @raw, @Style and @Head
like an indented body: the compiler reads that text as written, so a `$x`
in `@raw <b>$x</b>` or `@style .a { content: "$x" }` is no longer a
variable token, a reference, a rename target or a code-lens hit.
Find-references (local and across the workspace) now leaves out verbatim
bodies as rename already did, so a code sample under @code that mentions
`$x` or `@card` is not listed as a use of them.

@Keyframes document symbols come only from @Style's CSS (its block or its
line, `@style @Keyframes spin {...}`), not from a @code sample that shows
some CSS, which P13 made a verbatim body.

@markdown with both a file on its line and an indented block now says it
reads either the file or the Markdown in the block, instead of calling the
file "its content".

How: tree::verbatim_lines includes the header line of a verbatim directive
other than @markdown whose line holds its body, and a new
tree::style_lines gives @Style's lines. Tests: an LSP analysis test (no
`$` tokens on one-line bodies or code samples; keyframes symbols), a
navigation test (references skip verbatim bodies), and the @markdown
message in the syntax unit test.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
`@page` now styles the page itself. Its styles (prefixes included) go on
<body>, its key=value attributes on <html> (`@page [lang=en, dir=rtl,
class=x, background #f8fafc, dark:background #0b1220] Home`), and
`favicon FILE` stays its one word of htmlang's own. The reset makes
<body> a flex column with min-height 100dvh, so a page's background
covers the window, top-level `height fill` takes the rest of it and
`center-y` works. The top of a page is laid out like any column: a line
of text or an inline element there now stacks and takes the full width,
and a centred column at the top writes `width fill` as it does anywhere
else. A fragment's top level is unchanged.

@page's attributes are checked like any element's, also in code that
doesn't run: the old `lang en` form is the ordinary html-attribute-form
error, a misspelled CSS property the usual warning, `favicn` an "unknown
@page word" error, `favicon=x` a parameter-form error. The
unknown-page-attribute code is gone. A second @page is an error
(duplicate-page): the page's own next to a layout's, a layout called
twice, or an @page in a loop; the first one stays. The same @meta tag
twice is written once, and a `@meta viewport` replaces the usual one.

A file that holds only @let definitions is a library: its definitions
aren't reported unused, and `htmlang build`, `serve`, `watch` and the
directory mode don't write a page for it (quietly; `check` still checks
it, and a single file named on the command line is still compiled).

How: ast::Page on the Document (title, html_attrs, styles, favicon);
parser::read_page; codegen writes <body> through element_class with a
`root` Site whose layout display the reset already gives, and the top
level with the column layout and the body's Flow, so `flex-direction`
on @page moves its children's fill. syntax::Tree::is_library,
vocab::PAGE_WORDS. Examples: landing's wrapper @el became @page
attributes; demo and docs moved their page colours to @page (demo's
dark background now covers the window), docs' `min-height 100vh` row is
`height fill`, blog's layout column gains `width fill`. DESIGN, README,
CONTRIBUTING, LSP (completion offers favicon and <html>'s attributes
first on @page, hover), VS Code grammar (@page's list is an attribute
list), snippets and README, CLI help. Tests: snapshot page_root and 11
tests; all snapshot HTML changes only by the reset's body rule.
Changed on purpose: lang_sets_html_attr, the page-attribute test in
snapshots.rs and the regression page_takes_lang_and_favicon_only
(renamed), which asserted the old narrow @page; tests/docs.rs no
longer needs its library exception.

Left out: `dark:[...]` groups (P7); custom properties on @page land on
<body>, not :root (P8/P22 decide). --partial output still drops @page.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
A layout's @page that doesn't run (a library checked on its own) now
gets @page's own checks too: `favicon=x` is a parameter-form error, a
favicon without its file is missing-value and `inline` is
unexpected-argument, as where the @page runs. A value made from a
parameter (`favicon $icon`) counts as given. Both paths share one
helper, check_page_attr.

@page's own diagnostics now point at their attribute (column and source
line) when the list is written on the @page's line, and a repeated
favicon points at the one that repeats.

A page that includes a library and uses only some of its definitions no
longer gets unused warnings for the rest (they were reported with the
library's line numbers as if they were the page's). DESIGN says so.

Tests: three snapshots.rs tests (unevaluated page words, columns, an
included library's unused definitions).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
For someone writing htmlang:
- Every CSS property is written under its own name with its value as
  written. `outline 2 solid red` is `outline:2px solid red` (it was
  `2px solid solid red`), `outline 2 red` no longer gets an invented
  `solid`, and `container card / inline-size` is CSS's own shorthand. A
  bare `contain` or `content-visibility` stays a missing-value error.
  Only the layout words (spacing, width/height fill and shrink, center-*,
  align-*, wrap, grid-cols/rows, col/row-span) and line-clamp's
  -webkit-box fallback write anything of their own.
- Generated classes are `hl-a`, `hl-b`, ..., so `class=a` no longer picks
  up a generated `.a` rule. A class of your own starting with `hl-` gets
  an invalid-value warning. A function's scoped @Style class is now
  `hl-fn-NAME`, which can't meet a generated class.
- Element defaults stay on htmlang's own elements, in their class: the
  page reset's global `a{...}` and `img{display:block}` moved into
  @link's and @image's class, so Markdown and @raw links keep their
  underline. The focus outline is kept to htmlang's elements.
- A closed @dialog, an element with `hidden` and a popover that isn't
  showing stay hidden: htmlang's display:flex used to beat the browser's
  display:none. The rule is in @layer hl-reset, scoped to hl- classes,
  with !important.
- A fragment (no @page, or --partial) gets a reset only for htmlang's own
  elements (box-sizing and the hidden rule), never a global one. No
  output has an empty `@layer htmlang{}` any more.

How: attrs_to_css lost its ~30 per-property arms; the one generic arm
writes any property name with `with_px`, which follows
`vocab::px_rule`. px_rule states today's pixel rule exactly (whole-value
px for the properties whose arm used css_px, none for border-inline and
border-block, per-word px for other length properties), so no px
behaviour changed except outline's: P12 replaces px_rule with its one
rule. reset_css takes page/fragment; OWN_ELEMENTS scopes its element
rules. demo.hl's focus outline is now valid CSS (demo.html regenerated).

Tests: snapshot one_css_path; 6 snapshots.rs tests, 5 regressions, a
px_rule unit test and an LSP hover test. Changed on purpose: every
snapshot and assertion that names a generated class (hl- prefix, and
the extra class on links and images), css_outline/css_outline_no_color
(no invented solid), tests that asserted no `:where(` or a trailing
`</style>` on a fragment without styles, and the scoped-style test
(hl-fn-card).

Left out: `inset 0 10` still gives `inset:0 10` and `gap 10 20`
`gap:10 20` (P12 owns px); inline SVG's own attribute mapping (color to
fill) is unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
…eview fixes

- tests/snapshots/new_css_attrs.hl wrote `outline 2 red`, which with CSS's
  own shorthand now draws no outline; it writes `outline 2 solid red` so
  the snapshot still shows an outline (one_css_path keeps the style-less
  case).
- DESIGN.md's reset paragraph said a fragment's own elements get
  box-sizing "and nothing else", right before naming the hidden rule they
  also get; it now says no other element on the page is touched.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
In a length property, every bare number outside parentheses and quotes is
pixels; every other property, custom properties included, takes its value
as written. `box-shadow 0 2 4 rgba(0,0,0,.1)` is now `0 2px 4px
rgba(0,0,0,.1)`, and so are `translate 10 20`, `gap 10 20`, `inset 0 10`,
`grid-template-columns 200 1fr`, `border-inline 1 solid red`,
`background-size 20 20` and `text-shadow 0 1 2 black`, which used to
produce unitless, invalid CSS. `outline 2 dashed red` and
`border 1 solid rgb(255 128 0)` keep their numbers where they belong, and
`border-image-width 2` stays a number instead of becoming 2px. `flex`,
grid lines, `initial-letter`, `columns`, `opacity`, `z-index`,
`line-height` and the other number-valued properties are untouched. A zero
stays `0`. `spacing` and a `grid-cols` track list follow the same rule.

How: vocab.rs has one sorted table, LENGTH_PROPERTIES, and one function,
with_px, that applies the rule (words are split at spaces, commas and
slashes outside parentheses and quotes). It replaces is_length_property's
prefix/suffix guesses, px_rule with its Whole/EachWord/AS_WRITTEN lists,
and codegen's css_px/css_px_multi. DESIGN.md states the rule in one
sentence with the table; README, CONTRIBUTING, the LSP hover (which says
whether a property's numbers are px), a grid snippet and two example
box-shadows follow it. stress_large.html's frozen `box-shadow:0 4 12`
is now valid; no other snapshot changed. New snapshot px_rule, vocab unit
tests, snapshot and regression tests, and an LSP hover test.

Left out: the `background`, `mask`, `grid`, `grid-template` and `font`
shorthands are not length properties (they mix numbers of other kinds), so
lengths in them need their unit.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
`10!important` in a length is now `10px!important`: a `!` starts a word
of its own, as a space does. The last length-valued properties CSS lists
join the table, so a bare number in `vertical-align`, `shape-margin`,
`offset-distance`, `offset-position`, `offset-anchor`,
`overflow-clip-margin` and `view-timeline-inset` is px too instead of
invalid CSS. DESIGN's table lists them and says that shorthands mixing
lengths with other numbers (`font`, `background`, `grid`, `flex`, ...)
need their unit; DESIGN and the language server say that `grid-cols` and
`grid-rows` also take a track list, and the hover says quotes are skipped
too. Two regression tests and two vocab assertions cover the fixes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
For someone writing htmlang: a custom property (`--name`) is a style like
any other. It goes on any element, under any prefix (`dark:--surface
#0b1220`, `md:--gap 24px`, `hover:--x 4px`), in bundles and on calls, and
is written into the element's class, so it holds for the element and
everything inside it. Its value is written exactly as it is, with no px,
because a custom property has no type: `--hue 200` feeds hsl() and
`--cols 3` feeds repeat(); a length takes its unit (`--gap 16px`, `@let
--radius 8px`). `@let --x` declares a token for the whole page on :root;
`[--x v]` sets one on an element and its subtree, so dark mode redefines a
token once instead of repeating every colour.

A comment is now `--` followed by a space or the end of the line. A
`--gap 16px` line inside a multi-line attribute list used to be read as a
comment and dropped without a word; it is now a declaration, and the
formatter wraps lists that hold custom properties like any other.

How:
- syntax.rs: `is_comment` (public) decides comments at the start of a line
  and between the lines of a list; fmt, the LSP (hover docs above a
  function, slot completion) and the fmt corpus test use it.
- vocab.rs: CSS_PROPERTIES gains the SVG presentation properties (fill,
  fill-opacity, fill-rule, stroke, stroke-width, stroke-dasharray,
  stroke-dashoffset, stroke-linecap, stroke-linejoin, stroke-miterlimit,
  stroke-opacity, marker*, stop-*, flood-*, lighting-color, clip-rule,
  color-interpolation*, dominant-baseline, alignment-baseline,
  baseline-shift, shape-rendering, vector-effect) and other common gaps
  (corner-shape, anchor-scope, overlay, reading-flow/-order,
  text-autospace, text-spacing-trim, caret*, margin-trim, mask-border*,
  font-synthesis-*, font-variant-emoji, ...). baseline-shift is a length.
- An inline SVG (`@image [inline] x.svg`) has no generated class, so a
  style it can't take (a custom property, stroke, a prefixed style) or
  `alt=` is now an unexpected-argument error instead of being left out
  silently; its HTML attributes (aria-label=, role=, ...) now reach the
  svg root, escaped.
- LSP: completion offers the custom properties the file names, also after
  a prefix; hover explains a custom property (element and subtree, no px,
  `@let` for :root); the @let doc says the same.
- VS Code: comment rule `-- ` only, also inside attribute lists; custom
  and vendor property names highlighted; prefixes before `--`; a "Dark
  mode tokens" snippet.
- Docs: DESIGN (Comments, a Custom properties subsection with the
  @let/[--x] difference and a dark-mode token example, CSS custom
  properties with units, inline SVG row), README bullet, VS Code README.
- Examples: landing and demo redefine --ink/--muted/--line/--surface/
  --subtle once under dark: on @page instead of repeating dark colours per
  element (which also fixes text that used var(--ink) staying dark in
  dark mode); demo shows a custom property on an element; demo.html
  regenerated with -d. blog and docs output unchanged.

Tests changed on purpose: the formatter test that kept custom-property
lists on one line now checks they wrap; css_vars, stress_large and
stress_deeply_nested give their numeric tokens units (only their :root
lines change); tests that used corner-shape as the example of an unknown
property now use text-grow, since corner-shape is known; an inline SVG
regression passes aria-label= instead of alt=. New: snapshot
custom_properties, 5 snapshots.rs tests, a regression, a syntax unit test
and an LSP completion test.

Left out: `$--name` stays until P24 removes it. SVG geometry properties
(cx, cy, r, rx, ry, x, y, d) are not listed, since single letters would
collide with parameter names; they pass through with a warning. Inline
SVG still maps only width/height/color to the root rather than going
through the class path.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
- Inline SVG: `width fill` / `height shrink` (layout words, which need a
  class) were written onto the svg root as `width="fill"`; they are now
  part of the unexpected-argument error like any other style the markup
  can't take. The error names `fill` as accepted too, and inline_svg's
  doc comment and DESIGN's @image [inline] row say what the root takes.
- The custom-property hover no longer reads as if the word under the
  cursor sets the property (it is also shown inside `var(--x)`).
- DESIGN's comment rule says that `--name` or `---` starting a line
  outside a list is text.
- tests/docs.rs detects comment lines with syntax::is_comment, the one
  definition.
- vocab.rs: a LENGTH_PROPERTIES line over the width limit rewrapped.
- Tests: vendor-prefixed properties under hover:/dark:, -moz- and -ms-
  names; `width fill` on an inline SVG is an error.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
A style's key is now a chain of prefixes and a property, and the style
applies where all of them hold. `md:hover:color`, `dark:md:padding` and
`hover:children:opacity` work instead of being errors. Selector
prefixes read left to right, and a pseudo-element comes last:
`hover:before:color` works, and `before:hover:color` is an error that
suggests it. Width, media and container prefixes may appear anywhere.
They nest as at-rules, so `md:dark:` and `dark:md:` are the same style.
A prefix before a `[group]`, a `$bundle` or an `if()` applies to each
style in it (`md:[padding 32, font-size 20]`, `md:$card`,
`hover:if($on, [...])`). Such a group or bundle holds styles only: an
HTML attribute or flag in it is an error, as `md:id=x` is.
`children:` styles now compile through `:where()`, so a child's own
styles win over its parent's. The layout words that place an element in
its parent (`width fill`/`shrink`, `center-*`, `align-*`) are errors
under `children:`. The fix-it hint names the CSS to use instead. A
`key=value` whose name has a colon (`xml:lang=`, `x-on:click=`) is an
HTML attribute. Rules are written in one fixed order, whatever the
source order: plain rules first, then selector-only rules, then each
at-rule block in table order, with a stack after its innermost prefix
alone.

How: the syntax tree gets a `Prefixed` attribute (prefix + a P4
`Branch` target). The parser distributes the prefix over the group,
bundle or chosen branch, and checks each member. Codegen replaces the
six hard-coded media fields with one list of (Condition, declarations)
per class. A Condition is a sorted at-rule chain plus a selector chain.
Parent direction changes are keyed by the at-rule chain, so a
`md:dark:flex-direction` moves the children too. The formatter, the
LSP, the VS Code grammar and snippets, DESIGN.md, README.md,
CONTRIBUTING.md and the demo/landing examples follow:
- the formatter prints `prefix[a, b]`;
- the LSP completes, hovers and fixes stacked prefixes and groups;
- the examples use `dark:[...]` groups.

Left out: keyed `children:` sizing rules are removed rather than
composed, since the words they served are now errors. Duplicate-key
checks compare the written key, so `md:hover:color` next to
`hover:md:color` is not flagged; the later one wins.

Tests changed on purpose:
- the stacked-prefix regression and `prefixes_are_checked_by_name_and_number`
  now expect stacks to compose;
- `children:` expectations now use the `:where()` form, and
  `children:width fill` is now an error;
- the layout_modes and layout_direction snapshots no longer use
  `children:width fill`, and exercise a stacked direction instead;
- stress_large gets back the `dark:hover:` styles P5 had to remove;
- has_pseudo, nth_pseudo and custom_properties only reorder rules.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
`children:` compiled through `:where()` lost to every default of the
child's kind, not only to what the child writes itself, because those
defaults were part of the child's class. `children:display list-item`
(DESIGN's recipe for list markers, used in blog.hl) left the items flex
columns, and `children:margin-bottom 8` left headings at `margin:0`. An
element kind's defaults (its layout, a heading's margin, a list's
markers, a link's colour) are now written first, as
`:where(.hl-a,.hl-b){...}`, less the properties the element sets itself.
A parent's `children:` styles, which come later with the same zero
specificity, win over them, and the element's own styles still win over
both. The snapshots change only in that split: a script that merges each
class's defaults with its own rule found every class's declarations and
every other rule unchanged. DESIGN and CONTRIBUTING say where the
defaults go.

The branch a prefixed `if()` doesn't take was checked without its
prefixes, so `hover:if($on, color red, id=x)`, `children:if(..., width
fill)` and `before:if(..., hover:color x)` passed whenever the other
branch was taken. It is now checked under them, like the taken one.

At a call, a parameter under a prefix (`md:pad 40`, `md:[pad 40]`,
`md:$b` with `pad` in it, `md:featured`) was forwarded to the root as an
unknown CSS property `pad` with a warning, or reported as an unknown
attribute. It is now an invalid-prefix error that says it is a
parameter. A parameter named like a style (`color`) is still a style for
the root under a prefix.

Two pseudo-elements (`before:after:`) suggested `after:before:`, which is
invalid too. It is now its own error, with no suggestion. The hint for
`children:width fill` and the other sizing words names the CSS for a row
and for a column, instead of `flex 1` alone. An unknown CSS property
that happens to be an HTML attribute's name (`hover:title x`) no longer
says "did you mean 'title'?".

Tests: a_parent_s_children_style_wins_over_a_child_s_defaults,
the_branch_a_prefixed_if_doesn_t_take_is_checked_under_its_prefixes,
a_parameter_takes_no_prefix, a_style_takes_one_pseudo_element. Tests
that matched a whole class rule now match the defaults rule and the own
rule separately, and "no keyed rule" checks look for `)>.hl-` rather
than any `:where(.hl-`, which the defaults now contain.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
A selector prefix is now a CSS pseudo-class or pseudo-element under its
own name, checked against a list in vocab.rs that also says which ones
CSS writes with `::`. The new ones include marker:, backdrop:,
only-child:, user-invalid:, placeholder-shown:, open:, popover-open:,
first-letter: and file-selector-button:. Pseudo-classes that take an
argument take it in balanced parentheses: nth-child(), nth-last-child(),
nth-of-type(), nth-last-of-type(), not(), is(), where() and has(). A
key ends at the first space outside parentheses, so
`has(> img):padding 0` and `is(:hover, .x):color red` are one key each.
The five renamed prefixes are gone: first: is first-child:, last: is
last-child:, odd: and even: are nth-child(odd): and nth-child(even):,
and nth:EXPR: is nth-child(EXPR):. An old name gets the ordinary
unknown-prefix error. children: stays as htmlang's one combinator word.
The list-marker recipe can now colour the markers with
`children:marker:color`.

How:
- vocab.rs: PSEUDOS (name, Class | Function | Element) replaces
  PSEUDO_PREFIXES. prefix_len reads a word and a `:`, or a word, a
  balanced (quote-aware) argument and a `:`. pseudo_selector writes a
  prefix as CSS (`::marker`, `:nth-child(odd)`), and CHILDREN is the
  combinator.
- syntax.rs: split_attribute ends the key at the first space outside
  parentheses. When a `(` is never closed, it uses the first space.
- parser.rs: unknown_prefix covers the three failure shapes. `hvoer:`
  gets the ordinary did-you-mean, restricted to names with the same
  first letter (`odd:` would otherwise suggest `md:`), or else a name
  that starts with the word and a `-` (`first:` suggests
  `first-child:`). `nth-chld(2):` suggests `nth-child(`.
  `first-child(2):` and `has:` get an error that says what the pseudo
  takes. An empty argument (`nth-child():`) is invalid-prefix. There is
  no migration table.
- codegen.rs: selectors come from pseudo_selector. Selector chains follow
  the PSEUDOS order, which is CSS's usual order: link and visited, then
  position and form states, then focus-within, hover, focus,
  focus-visible and active, with enabled and disabled last. Before,
  visited: and nth-child(odd): came after hover:, and focus: came after
  active:. Four snapshots changed only in that rule order, which is
  intended, and the four examples compile clean.
- LSP: completion offers every pseudo, and one with an argument is a
  snippet with the cursor inside its parentheses (`nth-child($1):`).
  Styles are completed after `has(> img):`, and hover reads a prefix
  with its argument as one word.
- Grammar: the state-prefix pattern takes a word with an argument (one
  level of nested parentheses). A @ul-markers snippet is added.
- Docs: DESIGN (the prefix table, the full list of names, the argument
  rule, the selector order, the marker recipe), README, CONTRIBUTING,
  the VS Code README, and demo.hl (with demo.html regenerated). A docs
  test checks that DESIGN lists exactly the compiler's pseudos.
- Tests: the old-prefix tests are rewritten to the CSS names, and
  expectations changed only for rule order. New tests: CSS names and
  their errors (snapshots.rs), key splitting (syntax.rs), the pseudo
  table (vocab.rs), and LSP completion and hover.

Left out: a pseudo's argument can't come from a variable
(`nth-child($n):` is attribute-from-variable, as any name is).
Pseudo-element legacy single-colon forms are not accepted.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
- A key now ends at the first space outside parentheses with quotes
  inside the argument taken into account, as closing_paren already did:
  `not([title=") x"]):color blue` was split at the quoted `)` and
  reported as an unknown attribute.
- A whitespace-only argument (`has( ):`) is an invalid-prefix error like
  an empty one; it was written into the CSS as `:has( )`.
- Language server: value completions (enum values and colours) work
  after a prefix whose argument holds spaces (`has(> img):cursor `,
  `is(:hover, .x):cursor `), because the key is split with
  vocab::split_prefixes. The colour list after a plain `color ` works
  again: the segment was trimmed before checking for the space after the
  name. The @ol hover mentions `children:marker:color`.
- Tests: the quoted-parenthesis split (syntax.rs and snapshots.rs), the
  whitespace-only argument, value completions after a spaced prefix, and
  the quick fixes for `nth-chld(`, `first:` and `first-child(2):`.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
Authors can now style every element of one kind inside an element with
an element prefix: `@table [@td:padding 8]` pads every cell of the table,
at any depth, including the cells a loop writes and the headings, code
and links Markdown writes. The prefix composes with the others: the ones
before it apply to the element itself (`hover:@link:color red`), the ones
after it to the elements inside (`@link:hover:color blue`), and at-rules
go anywhere (`md:@h2:font-size 24`). The nearest element's rule wins, and
an element's own attributes win over it. demo.hl, docs.hl and landing.hl
use it for table cells, headings, Markdown and repeated links.

The prefix names an element that writes an HTML tag of its own: @td,
@link, @paragraph, @h2, @code and so on. @el, @Row, @grid, @text and the
other elements that write a <div> or <span>, @script, @Fragment, @slot,
functions and unknown names are errors, with a suggestion where one fits
(`@tdd:` gives `@td:`, `@a:` gives `@link:`). Only CSS properties go
under it: htmlang's layout words (`spacing`, `width fill`, `center-x`)
and HTML attributes are errors, as are two element prefixes on one key
and one combined with `children:`.

How: vocab::prefix_len recognises `@name:`, and the parser checks it
against ElementKind::own_tag. Codegen writes such rules as
`@scope (.hl-x) { :scope td {...} }`. The CSS now has four layers,
`hl-reset, hl-kind, hl-inside, htmlang`: the element defaults moved from
htmlang to hl-kind, the scoped rules sit in hl-inside, and the elements'
own styles, `children:` included, stay in htmlang. DESIGN.md gives the
browser baseline for @scope (Baseline 2025: Chrome and Edge 118, Safari
17.4, Firefox 146); older browsers ignore the rules. The language server
completes, hovers and quick-fixes element prefixes, and the VS Code
grammar, snippets (Table, Styled Markdown) and README follow.

A function's scoped @Style, added in 74bc0c3 and documented in 74fc727,
stays, with two fixes: it is written only when the function is called,
once, and in hl-inside, so a caller's `[padding 20]` now wins over the
function's `& { padding: 12px; }`. Before, it was written for every
defined function, called or not, outside any layer, so it beat every
attribute of every element it reached.

Left out: identical scoped rules under different roots are not merged,
`@image:` does not reach an inline SVG, and a key reported as an error
can still be reported again as a duplicate.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
The language server's hover for an element prefix had runs of spaces in
the middle of its sentence, and said "has no HTML tag of its own" for a
name that isn't an element at all (`@zzz:`); it now says so, and a test
hovers an element prefix inside an attribute list. The @Style hover says
what a function's scoped @Style does now: it is written once the
function is called, and an element's own attributes win over it.

The contrast lint paired a colour and a background whatever element
prefix they had, so `[background #fff, @td:color #fefefe]` warned about
the cells' colour against the table's background, and
`[@th:background #000, @td:color #111]` about two different kinds of
cell. It now pairs them on the same elements: the element's own, or
those one element prefix picks out.

`[@td padding 8]`, an element prefix without its colon, said "did you
mean 'id'?"; it now says an element prefix ends in a colon and shows the
key written with it. A directive named as an element prefix (`@each:`,
`@markdown:`, `@page:`) got a suggestion from the element names
(`@search:`, `@pre:`); it now says it is a directive, with no
suggestion.

README said the generated classes are in `@layer htmlang` and the
defaults part of each class; it now names the four layers. DESIGN's
`@ul [@ul:...]` example said it leaves "its own items" alone, where it
means the list itself. StyleEntry's comment names the hl-kind layer.

Tests: a regression test that a caller's attributes win over a
function's scoped @Style and that a function called only in a branch
the page doesn't take writes nothing, the contrast pairs, the missing
colon, the directive, and the hover.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
A design token is read one way, `var(--name)`, and a colour variant of it
is CSS: `color-mix(in srgb, var(--brand), black 8%)` for darker, `white`
for lighter, `rgb(from var(--brand) r g b / 0.5)` for transparency and
`color-mix(in srgb, a, b 50%)` for a mix. Because the browser computes
them, variants follow a token that dark: or a stylesheet redefines, and
they take any CSS colour, not only hex. `in srgb` reproduces what the old
compile-time functions gave (darken(#3b82f6, 8) was #3678e2, the srgb 8%
mix); DESIGN suggests oklab for new tokens.

This reverses 4361500 ("Custom properties without magic"), which made
`$--name` the compile-time value of a token and documented
`${darken($--brand, 10)}`, and a67f200, which kept lighten/darken/alpha/mix
when the filters went. `$--name` also inlined token values and let numeric
tokens borrow px from the property; tokens now carry their units
(`@let --radius 8px`), as P8 already required.

How:
- expr.rs: lighten, darken, alpha and mix are gone from FUNCTIONS and
  call(), with their colour helpers; they are now unknown functions. The
  hex parser the contrast lint uses moved next to it in parser.rs.
- interp.rs: name_len no longer takes `--name`. Under P6's lexing `$--x`
  would silently be text, so `$--` + a name is a new Problem::
  CustomProperty, reported as an invalid-value error "`$--brand` is not a
  variable: a custom property is read with `var(--brand)`", with the
  subject and suggestion the LSP turns into a quick fix. In an expression
  (`${$--x}`, `@if $--x`, `@let y = $--x`) it is the same message as an
  invalid expression. `\$--x` and a `$--` before no name are text.
- parser.rs: `@let --name` declares only the :root property; it no longer
  binds a variable. syntax.rs: `@let --name` is not a visible `$name`
  (completion, hover, navigation), and its name follows the custom
  property rule rather than the variable rule.
- Examples (landing, demo; demo.html regenerated with -d), DESIGN (CSS
  custom properties: one way to read a token, a table of colour variants,
  a dark-mode example; the Color row of the expression table removed; the
  compile-time principle says CSS's own functions stay CSS), README, LSP
  (@let doc, color-mix()/rgb(from) completions after colour properties,
  no `$--` in variable completion), VS Code grammar, snippet and README.
- Snapshots: css_vars, stress_large and stress_deeply_nested rewrite
  `$--x` as `var(--x)`; their HTML now reads the tokens with var() instead
  of inlined hex (classes renumber where values stopped deduplicating).
  New snapshot colour_variants (the spec's After snippet with dark:).
- Tests: the four test_color_filter_* tests are replaced by
  colour_variants_are_css_and_follow_their_token,
  the_colour_functions_are_gone and
  a_custom_property_is_read_with_var_not_as_a_variable; expr and interp
  unit tests, an LSP quick-fix test and a completion test.

Left out: CSS colour functions still can't be computed inside `=`
expressions or `${...}` (they go in a value or a plain @let). The contrast
lint still sees only hex pairs, so colours read through var() are no
longer checked.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
- Completion offers the file's custom properties inside `var(`, the one
  place a token is read now; before, `$--brand` was offered after `$`,
  and nothing replaced it. The name scan is shared with the attribute
  completion of custom properties.
- `@let --name` takes the names `[--name value]` takes
  (vocab::is_custom_property), so `@let --a-` and `@let ---x` are no
  longer refused while `[--a- 1]` is accepted, and a bad token name says
  what a custom property's name is instead of "starting with a letter
  (`@let gap 8`)".
- Tests for both; the VS Code README lists the new completions.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
For someone writing htmlang:
- An @Style's lines are joined with a space in compact and minified
  output, so `margin: 0` over `auto;` is `margin: 0 auto`, not `0auto`.
- A call's `class=` adds to the root element's own classes, and the
  function's scoped-style class stays on it.
- A parameter passed again takes the later value, as any attribute does:
  after a bundle silently, twice in the call's own list with a warning.
- `@let --name` inside an element, loop or function body is an error
  that points to `[--name value]`: :root is one place for the whole page.
  Declaring a token twice at the top level warns.
- `&` at the top level of an @Style that isn't scoped (not at the top of
  a function body) warns: there it is the whole page.
- `@link /a > @image x` warns that `>` after a leading argument is text,
  and shows `@link [href=/a] > @image x`.
- `spacing`, `wrap` and the grid words on a text, native or void element
  are reported in code that doesn't run too.
- `@children` or `@slot` after an element's attributes, or in a verbatim
  body, is an error instead of printing as text.
- An unclosed inline `{@...` warns.
- A fixed width or height under a prefix undoes an unprefixed `fill`
  along a flex parent's direction (`width fill, lg:width 200` in a row).
- Name hints: `[card]` for a bundle suggests `$card`, `$fn` for a
  function says it is called `@fn`, `$i-label` with `$i` defined shows
  `${i}-label`, and `@svg`, `@math` and `@template` point to @raw.
- Prefixes written in another order (`md:dark:` and `dark:md:`) are the
  same key for duplicate warnings.
- `padding=4` (a style written as an HTML attribute) warns with the
  style's form, and so does `width=`/`height=` on an element that has no
  such attribute. `for=` on @output is no longer flagged.
- `@let --x` without a value no longer suggests `@let @--x`.
- A function named after a built-in element whose body calls that name
  outside an @if is one error, not a cascade of four.
- An inline SVG's width, height and fill go in its `style=`, so
  `var(--c)` and units work.
- `@image [inline]` reads a raster next to the file (not the working
  directory), and a file it can't read is an error; `favicon` reads its
  file the same way and falls back to a link.
- `--partial` warns that @page, @meta and @Head have nowhere to go, and a
  file without @page warns about its @meta and @Head.
- A list of records written into text or a value is an error, like a
  record; a list inside a list prints its items.
- A library may include other libraries and read @DaTa; it isn't built
  into a page, and its names aren't reported unused. An included file's
  unused names are never reported under the including file.
- Indentation that mixes tabs and spaces warns (mixed-indentation).
- A leading argument that starts with `{@` or an unclosed quote is an
  error.
- `htmlang fmt` formats every file it is given and prints its usage when
  given none.

Tests: new regressions for each; the inline-SVG expectations move to
`style=`, the scoped-style tests to the spaced compact form, and nine
snapshots change only by those spaces and, in responsive, by the flex
reset under sm:/md:/lg:/xl: (the sized widths now apply in the row).

Left as it was: a function body's problems stay reported at each call
(P2's settled "at the call, once"); a missing comma between styles stays
part of the value (values are CSS's).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
DESIGN.md:
- Diagnostics says what lexical scope means for code that doesn't run:
  calls are checked against every function, but a body sees only what is
  defined above its function, so two functions can't call each other,
  and a `$name` is checked only where the code runs.
- The principles name every layout (row, column, grid, text, HTML's own)
  and state the one thing left out without an error: a style whose value
  comes out empty.
- `@page` is called out as the directive that is also the root element.
- The HTML "Elements" heading is "HTML elements" and "Layouts" is "Page
  layouts", so the links to the layout table and to libraries land.
- Quoted text loses its quotes in expressions, not in parameters.
- Naming a function after a built-in is a warning (it replaces it), and
  one that wraps the element it is named after is an error.
- Forwarded attributes need a single root, a call's `class=` adds to the
  root's, and a parameter passed twice takes the later value.
- `@let --name` goes at the top level, once; only a top-of-body @Style is
  scoped; a clickable card is a link laid out as a column; a `$list` in a
  comma list is a nested list; a missing comma between styles makes one
  value; `padding=8` warns; `>` after a leading argument is text;
  `@children` after attributes is an error; mixed tabs and spaces warn.
- `@data $name env:NAME DEFAULT`, inline images read next to the page,
  an inline SVG's styles in its `style=`, libraries that include
  libraries, and fragments' @meta/@Head.

README.md: the prefixes are CSS's common pseudo-classes and elements (the
list is in DESIGN.md), and undefined names are errors where code runs.
The @let hover in the language server says the same as DESIGN.

Examples:
- demo.hl: chapter 5 is about `${...}` again (tags with `${if()}` in a
  value); the colour-mix swatches move to chapter 3 with CSS labels. The
  card bundle sets `color var(--ink)`, so the button and the popover
  follow dark mode; the @Behind gradient has a dark variant; the dashed
  rule has a width. demo.html is regenerated with -d.
- landing.hl: the nav is hidden on phones (the page no longer scrolls
  sideways at 390px), and each perk's check mark sits beside it.
- blog.hl passes `featured $post.featured`, and docs.hl writes its
  samples `@code-block > @code`; both compile to the same bytes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
For someone using the language server or the VS Code extension:
- The missing-alt and missing-type quick fixes insert `alt=` and
  `type=text`, which compile; before, they inserted `alt` and `type text`,
  both errors now. A style written as an HTML attribute (`padding=4`)
  gets a fix to `padding 4` from the compiler's suggestion.
- Extracting lines into a function or a bundle puts the definition above
  the top-level line the selection is in, not at the top of the file, so
  it sees the definitions the lines saw (a body sees only what is above
  it). A loop variable or parameter the lines read becomes a parameter of
  the function; a bundle that would need one isn't offered.
- Renaming a parameter renames its name at every call of the function,
  including inline calls and chain links, so the calls keep compiling.
- Custom properties have navigation again: go to definition, references
  and rename from `var(--brand)`, `[--brand ...]` or `@let --brand`, a
  code lens that counts them, and `--brand` (not `$--brand`) in the
  outline.
- Completion offers `inline` only on @image, nothing on @Fragment and
  HTML attributes only on @script; bundles after prefixes (`md:$card`);
  and nothing for the new name after `@let `/`@let @`.
- Hover on a parameter's name, where it is declared or passed, describes
  the parameter instead of an HTML attribute of the same name.
- Format Selection keeps a nested selection at its indentation.
- The TextMate grammar reads the leading argument after a list with a
  nested group (`@link [md:[color red]] /about`) and highlights `if` after
  a prefix (`hover:if(...)`).
- The VS Code README describes all of this.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
For someone writing htmlang:
- Mixed tabs and spaces are checked only in the indentation that places a
  line: a tab inside a verbatim body after the body's own indentation (a
  Go sample in @code, a script) and a continuation line of an open list or
  inline JSON are content, and no longer warn. A body indented with the
  other one than the lines around it is still reported.
- An inline SVG keeps the file's own `style=`: the element's width, height
  and fill are added after it, where before they replaced it.

For someone using the language server:
- Format Selection keeps each line at its level when the selection starts
  deeper than a later line, and keeps the blank lines between such runs;
  before, the shallower lines were moved to the first line's indentation.

Tests: the mixed-indentation, inline-SVG and range-formatting tests cover
each case.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
@iko-soy
iko-soy marked this pull request as ready for review September 27, 2026 01:46
@iko-soy
iko-soy merged commit 693bde9 into master Sep 27, 2026
4 checks passed
@iko-soy
iko-soy deleted the claude/project-thread-stq41b branch September 27, 2026 08:11
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.

2 participants