GEML Illustrated · profile · geml-style/v1 (landed 2026-08-30, EXPERIMENTAL) · 中文

GEML Illustrated · geml-style

A stylesheet is an ordinary .geml: it declares profile = "geml-style/v1", holds three kinds of empty-bodied blocks, selects into a content document with CSS-flavoured selectors without touching it, and binds blocks to component names the host registers. No scripts, ambiguity is a build error, nothing depends on source order. Its conformance surface is the view model that geml style check --json emits. This page runs one clean sheet and one deliberately broken sheet and walks through the 13 diagnostics.

Board

19 rules, each with its source and status

Settled fixed by the profile document. Observed not prescribed, this is what the tool does today. No implementation gap found on this page. Mind §0.1: only the subset the codemap display knobs use is promised stable.

Settled 16Observed 3
RuleSourceStatus
1A stylesheet is an ordinary .geml; all three block kinds are empty-bodied: everything sits in the attribute object, because an unregistered type's body is raw and the core does not parse it, while the attribute object is parsed for every type. Rules select into the document instead of a template wrapping it, because content is often machine-generated and not editable. No scripts: component / handler are names only; the host supplies implementations.§0 · §2Settled
2profile is a space-separated list; several profiles take the union of their vocabularies; validation asks only "is the name allowed", never what it means. Without the declaration every style-rule earns one unknown-block-type warning (5 in the probe), exactly the kind of thing that trains people to ignore warnings.§1Settled
3style-rule: match= required; component= handler= show= filter= screen= (space-separated) are all the keys the profile consumes itself; every other key passes through verbatim to the component, so a rule has no unknown-attribute check.§2.1Settled
4style-state: match= (the producer selector) and on= required, on is a closed vocabulary, currently just select; type= block-ref | scalar unchecked; value-from= carries a direction; init-value=. Unknown keys warn. Several producers writing one state is assignment over time, not a conflict.§2.2Settled
5style-screen: slots= required, comma-separated, ordered, each a selector or $state; layout= unchecked (the host's business); no route=, routing belongs to the host framework; unknown keys warn.§2.3Settled
6A selector is §4's own vocabulary: type, .class, #id, [key], [key=value], plus the one descendant combinator (space) and comma branches. Rejected CSS is named rather than silently unmatched: > + ~, pseudo-classes, *, substring matching → selector-unsupported error. The scan is partitioned: pseudo-classes are looked for only outside brackets, substring operators only inside, because attribute values legitimately contain :.§3Settled
7Conflicts merge per attribute; when two rules set the same attribute on the same block, the one whose condition set is a strict superset wins, otherwise ambiguous-rule error. No specificity arithmetic, no !important, no source-order fallback: a sheet relying on order would be silently changed by geml set / add --before. Judged only on blocks that actually co-occur in the corpus. The one exception is rule 16: across layers the layer number decides, and a layer is not source order.§4 · §4.1Settled
8The binding pipeline interaction → state → view is one-way in three stages, a state never reads a state, so there is no graph and no cycle, and no binding-cycle in the catalogue. Three consumer operators: show="$s", filter="k=$s", title="$s.caption"; executed by the runtime, not the component; at check time only "every $name has a state declaration" is verified.§5Settled
9One separator rule: name lists use spaces, selector lists use commas, because a space is the descendant combinator.§6Settled
10Closed vocabularies (interpreted by the runtime itself, like on=): unknown member → error. Open registries (registered by the host, like component= handler=): unknown member → warning + inert; and unknown-component / unknown-handler are checked only when the caller declares a registry with --components= / --handlers=.§7Settled
11A catalogue of 13 diagnostics, the profile's own, not in Appendix A: 7 errors (selector-unsupported, ambiguous-rule, unknown-state, unknown-screen, unknown-value-source, unknown-interaction, style-missing-attribute), 6 warnings (unmatched-rule, unmatched-producer, unknown-component, unknown-handler, style-unknown-attribute, style-embed-not-expanded).§8Settled
12geml style check <sheet> <corpus…> [--json] [--components=] [--handlers=]; exit 0 clean or warnings only, 1 with errors, 2 usage. --json is the view model, the conformance surface: states, screens, bindings, diagnostics; a binding carries doc (ids are unique only within a document); bindings are per screen; slots come resolved into block lists, not raw selectors.§9 · §10Settled
13Stability: only the profile declaration, style-rule, match=, attribute pass-through, plus the style entry's path and default-style are promised stable, because the codemap display knobs already use them and they have "escaped": every build seeds _index/style.geml and the _index/index.geml that points at it into a user's repository, and the renderer finds a stylesheet only through that entry. The #sitemap column exists but no real map uses it yet, so it can still move. style-state, style-screen, show / filter / handler / screen, on / value-from / init-value / type / layout are "specified, checked, unused" and move with the first real use case. v1 deliberately has no scripts, URLs, routing, theming beyond design tokens, or bodies.§0.1 · §12Settled
14Observed: the clean sheet yields 0 diagnostics; declaring --components=edge-list makes method-card an unknown-component warning; the broken sheet yields 7 errors and 3 warnings, each matching the catalogue.geml style checkObserved
15The style entry: any root has exactly one fixed path, <root>/_index/index.geml, and hosts probe only it; the name finds it, meta.profile recognises it — anything else at that path is "no entry". Two keys say which sheets load: default-style (a meta key) and #sitemap (a table, columns document/template, exact match, no glob, no cascade). Both are one implicit embed each, so cycle detection, the nesting cap and the diagnostics all come from embed unchanged; embed {src=index.geml} therefore means "give me this root's default, whatever it is called".§1.1Settled
16Layers: the entry orders sheets as default-style (0) → the #sitemap match (1) → the entry's own rules (2). Across layers the higher layer wins; within a layer rule 7 is unchanged. A layer number is a declared order, not a score computed from selectors — the CSS @layer model, not specificity. An explicit embed does not open a new layer. Excluding source order still holds: no order within a layer, #sitemap is an exact match so row order changes nothing, and the layer count is fixed by those two keys. The cost is written down: "arbitration is independent of which file a rule came from" now holds within a layer only.§4.1Settled
17Observed: two layers through the entry give 0 diagnostics, with #hero matched by both rules while component takes the higher layer's value and #facts still governed by the default layer; the same two rules squeezed into one layer raise ambiguous-rule immediately; a default-style resolving to nothing gives a style-embed-not-expanded warning, exit 0, with the file's own rules loaded as usual.geml style checkObserved
18The second real page (2026-09-10, "chrome goes home"): a selector may end in an inline part (link image code-span strong emphasis — last step only, after a block step); the built-in words cover axis on blocks and view / editable for showing a block’s source; when= takes five built-in conditions — @hover @focus @invalid @disabled @checked; a parameter the merged binding has no component= to receive is style-unknown-attribute. The test is still §12.3's: does it mean the same thing on any block?design 2026-09-10 §4Settled
19Observed: with the GitHub blob replica's chrome rewritten as lists of inline links inside text blocks, geml style check reports 0 errors 0 warnings; viewer components 7 → 2 (tree, segments), hex colours in the host CSS's page section 22 → 0, private stylesheet keys 10 → 0, and all 59 colours in the page's CSS come from the stylesheet.geml style check · renderObserved
Clean

A clean sheet and its view model

The content document is the small codemap from the repo's test fixtures: two methods and a #calls table.

state · rule ×3 · screen → --json Settled
good.style.geml
=== meta
profile = "geml-style/v1"
===
=== style-state {#sel type=block-ref match="table#calls" on=select value-from=to}
===
=== style-rule {#edges match="table#calls" component=edge-list selectable}
===                                  %% selectable is not a profile key: passed through to the component
=== style-rule {#methods match="code[anchor]" component=method-card}
===
=== style-rule {#leaves match="code.leaf[anchor]" collapsed badge="leaf"}
===                                  %% a strict superset of #methods' conditions: both apply on a leaf, no conflict
=== style-screen {#overview layout=split slots="table#calls, $sel"}
===                                  %% comma-separated; the $sel slot renders the block the state points at
content.geml · fixture
=== meta
profile = "geml-codemap/v1"
module = "geml-parser/core"
===
=== code {#renderHtml anchor="ts:render-html.ts#renderHtml(Document,RenderOptions)"}
===
=== code {#esc .leaf anchor="ts:render.ts#esc(string)"}
===
=== table {#calls format=csv}
from,to,kind,confidence
renderHtml,esc,call,high
===
geml style check good.style.geml content.geml --json0 errors 0 warnings
{
 "states": [ { "id": "sel", "type": "block-ref", "on": "select", "valueFrom": "to" } ],
 "screens": [ {
   "id": "overview", "layout": "split",
   "slots": [
     { "kind": "blocks", "selector": "table#calls",
       "blocks": [ { "doc": "content.geml", "block": "#calls" } ] },   ← resolved
     { "kind": "state", "state": "sel" }
   ] } ],
 "bindings": [
   { "doc": "content.geml", "block": "#renderHtml", "rules": ["methods"],
     "params": { "component": "method-card" } },
   { "doc": "content.geml", "block": "#esc", "rules": ["methods", "leaves"],
     "params": { "component": "method-card", "collapsed": true, "badge": "leaf" } },
   { "doc": "content.geml", "block": "#calls", "rules": ["edges"],
     "params": { "selectable": true, "component": "edge-list" } }
 ],
 "diagnostics": []
}

$ geml style check good.style.geml content.geml --components=edge-list
warning: unknown-component: component `method-card` is not registered — renders inert (#methods)
Merging
§4: #esc is selected by both #methods (code[anchor]) and #leaves (code.leaf[anchor]), but they set different attributes, and the latter's conditions are a strict superset of the former's, so params are the sum of both. Only the same attribute under incomparable conditions is ambiguous-rule.
View model
§10: this JSON is what a second implementation must agree with. A binding carries doc because §4 only guarantees id uniqueness within a document, and a sheet governing a directory can see #budget in two documents. Slots come resolved to block lists so the host need not redo selector matching at runtime.
Registries
§7: without --components= unknown-component is not checked at all, because a diagnostic that never fires is worse than none; once edge-list is given, method-card is reported and falls back to inert.
Broken

A deliberately broken sheet, ten diagnostics

Seven errors and three warnings, counted against the §8 catalogue.

bad.style.geml against the same content.geml Settled
GEML
=== style-rule {#a match="code[anchor]" component=card-a}
===
=== style-rule {#b match="code.leaf" component=card-b}
===                                  %% both set component, conditions incomparable: they collide on #esc
=== style-rule {#c match="table > code" component=x}
===                                  %% child combinator: the block model has containment, not adjacency
=== style-rule {#d match="table#nowhere" component=y show="$ghost"}
===                                  %% a state nobody declared; the selector also matches nothing
=== style-state {#s match="table#calls" on=hover value-from=nope}
===                                  %% the closed on vocabulary has only select; nope is not a column of #calls
=== style-screen {#scr slots="table#calls" route="/x"}
===                                  %% no route=: routing belongs to the host
=== style-rule {#e match="code" screen="missing-screen"}
===
geml style check7 errors 3 warnings
error: selector-unsupported: `>` is not supported (supported: type, .class,
  #id, [attr], [attr=val], descendant) (#c)
error: unknown-interaction: `on=hover` is not an interaction this profile
  defines (known: select) (#s)
warning: style-unknown-attribute: unknown attribute `route` for `style-screen` (#scr)
error: unknown-screen: rule `#e`: `screen=missing-screen` names no
  `style-screen` block (#e)
error: ambiguous-rule: `#a` and `#b` both set `component` on
  `content.geml#esc` — neither is more specific; write a rule matching
  the union of both selectors (#b)
error: ambiguous-rule: … in screen `#scr` … (#b)
warning: unmatched-rule: rule `#d` matched no block in the corpus (#d)
warning: unmatched-rule: rule `#e` matched no block in the corpus (#e)
error: unknown-state: `$ghost` is not declared by any `style-state` block (#d)
error: unknown-value-source: state `#s`: `value-from=nope` is not a column
  of `content.geml#calls` (has: from, to, kind, confidence) (#s)
7 error(s), 3 warning(s)   exit 1
Errors
Structural mistakes: unsupported CSS named; an interaction outside the closed vocabulary; references to a screen, state or column that does not exist; two incomparable rules on one attribute. ambiguous-rule is reported twice, once globally and once inside screen #scr, because bindings are computed per screen.
Warnings
Drift and surplus: a rule that matched no block (the sheet is internally consistent but detached from the corpus, the style layer's bad-source-range); an unknown key on a screen.
Why name it
§3: CSS resemblance is a ramp, not a trap. If > were silently ignored the author would believe it worked.
entry

How a directory says how it renders: the style entry, and layers

A single stylesheet can be handed to a tool directly. For a whole directory to say "here is how my documents render" there has to be an agreed location: <root>/_index/index.geml. Two keys on it order stylesheets into layers — §4.1, the one place in this profile where origin decides.

default-style loads either way; the #sitemap match layers on top Settled
_index/index.geml · the style entry
=== meta
profile = "geml-style/v1"
default-style = "base.geml"
===                                  %% layer 0: loads whether or not anything matches
=== table {#sitemap}
| document  | template  |
|---|---|
| page.geml | hero.geml |
===                                  %% layer 1: exact match, no glob, no cascade
_index/base.geml · layer 0
=== style-rule {#d-notes match="note" component=section}
===
=== style-rule {#d-tables match="table" component=card-grid}
===                                  %% the default layer, keyed on types
_index/hero.geml · layer 1
=== style-rule {#hero match="#hero" component=hero}
===                                  %% the override layer, keyed on one block
page.geml · content
=== note {#hero}
GEML is an Agent-Native base document format.
===
=== table {#facts}
| k | v |
|---|---|
| spec | v1 |
===
geml style check _index/index.geml page.geml --json0 error 0 warning
{
 "bindings": [
  {
   "doc": "page.geml",
   "block": "#hero",
   "rules": [
    "d-notes",
    "hero"
   ],
   "params": {
    "component": "hero"
   }
  },
  {
   "doc": "page.geml",
   "block": "#facts",
   "rules": [
    "d-tables"
   ],
   "params": {
    "component": "card-grid"
   }
  }
 ],
 "diagnostics": []
}

The entry is the template — both keys are one implicit embed each, so this command needs no manual table lookup first. A host therefore writes no resolution logic at all.

Layered, not replaced
#facts is covered only by layer 0 and still renders as card-grid. An implementation that swapped sheets on a match would leave it with no binding at all.
Both rules stay in rules
#hero is matched by d-notes (layer 0, match="note") and by hero (layer 1, match="#hero"), and both ids are recorded on the binding; the layer decides the component value. Keeping every matching rule is what lets a reader see why an attribute has the value it has.
A layer number is not specificity
#hero does not win because an id selector is "worth more" — §4 rejects that arithmetic explicitly. It wins because #sitemap put it in a higher layer. This is the CSS @layer model: the number comes from the entry's two keys and the selector contributes nothing to it.
Source order still stays out
A layer is not line order in a file: within a layer there is no order, #sitemap is an exact match so reordering rows changes nothing, and the number of layers is fixed by those two keys. Block-level agent edits (geml set, geml add --before) therefore still cannot silently re-render a document — which is what §4 excluded source order to protect.
Put the same two rules in one layer: straight into ambiguous-rule Observed
flat.geml · one file, one layer
=== style-rule {#d-notes match="note" component=section}
===
=== style-rule {#hero match="#hero" component=hero}
===                                  %% {type=note} and {id=hero} contain neither
geml style check flat.geml page.geml1 error
error: ambiguous-rule: `#d-notes` and `#hero` both set `component` on
  `page.geml#hero` — neither is more specific; write a rule matching the
  union of both selectors (#hero)
1 error(s), 0 warning(s)   exit 1

This is why layers exist, not a corner case: "default layer by type plus an override layer by block" is this profile's most common shape, and those two selector kinds have condition sets that never contain one another. Without layers, all five playground homepage documents errored.

Within a layer §4 is unchanged
The error above is still an error once layers exist — layers only settle cross-layer conflicts. The remedy is unchanged too: write the override as a strict superset (match="note#hero"), or move it to a higher layer.
The cost, stated
§4's opening claim that arbitration is independent of which file a rule came from now holds within a layer only. That is a trade-off §4.1 writes down, not an implementation leak.
The 13th diagnostic: a default-style that resolves to nothing says so, instead of silently dropping a layer Settled
_index/broken.geml
=== meta
profile = "geml-style/v1"
default-style = "gone.geml"
===
=== style-rule {#only match="table" component=card-grid}
===                                  %% this file's own rules load as usual
geml style check _index/broken.geml page.geml1 warning
warning: style-embed-not-expanded: `embed` of `gone.geml` contributed no
  rules: cannot resolve `gone.geml` (#default-style)
0 error(s), 1 warning(s)   exit 0

The diagnostic's id is #default-style — the id of the synthesised embed block. An implicit embed and a written one are indistinguishable downstream, so the reporting path is the same one.

Why a warning
Same nature as style-unknown-attribute: we ignored something the author wrote, and should say so. The message carries the cause — unreadable, anchor absent, cycle, or the caller supplied no document resolver.
Why silence is not an option
A stylesheet that looks composed while only its own handful of rules apply, a page missing a large piece of itself, and nobody saying anything. That happened before this diagnostic existed: a document that should have produced 26 bindings produced 3, with zero diagnostics.
Knobs

The first real sheet: the style.geml a codemap seeds

The §11 example. It uses only the stable subset: one style-rule, match=, pass-through.

Every knob is a component parameter, not profile vocabulary Settled
<codemap>/_index/style.geml
=== meta
profile = "geml-style/v1"
title = "codemap graph style"
===

=== style-rule {#graph match="diagram[format=geml-code-graph]" \
                fold=1 depth=6 hide-accessors=true \
                palette="#e3f2fd #e8f5e9 …"}
===                                  %% palette is a name list: space-separated (§6)
The rules it embodies
profile declaration
+
one style-rule
+
match= selecting the code-graph diagram
+
fold / depth / hide-accessors / palette passed through
§0.1: these four are the only subset v1 promises stable, because every codemap build has already seeded them into user repos
Why
§11: the display half used to be hard-coded in the renderer, so "tweak how it looks" meant changing a renderer that serves everyone. Now the numbers come from the sheet, the renderer is unchanged, each default equals the old behaviour, and a missing file falls back to the built-in defaults.
With foldings.geml
A pair: foldings.geml tunes build-time module naming, style.geml tunes display. Seeded on first build, never rewritten. See page 9.
The rest
§12: filter= has never run against real noise, handler= has no real host, on= is a closed vocabulary with one member because one interaction is wired. They are "specified, checked, untested in anger", which is why geml style check is marked EXPERIMENTAL in --help: correct today, spelling not guaranteed next year.
Evidence

Probes

Run in a short-path temp directory; the content document is geml-parser/test/fixtures/style/codemap-content.geml. Current in-repo build, 1.10.1.

ProbeResultMaps to
good.style.geml + content.geml0 diagnostics, --json view model as aboveclean, board 12
same with --components=edge-list1 warning unknown-component method-cardboard 10
bad.style.geml + content.geml7 errors 3 warningsbroken, board 11
_index/index.geml (two layers) + page.geml0 diagnostics, #hero→hero, #facts→card-gridentry
flat.geml (same two rules, one layer) + page.geml1 error ambiguous-ruleentry
_index/broken.geml (default-style resolves to nothing)1 warning style-embed-not-expandedentry
geml check with the profile line removed5 warnings unknown-block-typeboard 2