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.
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.
| Rule | Source | Status | |
|---|---|---|---|
| 1 | A 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 · §2 | Settled |
| 2 | profile 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. | §1 | Settled |
| 3 | style-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.1 | Settled |
| 4 | style-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.2 | Settled |
| 5 | style-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.3 | Settled |
| 6 | A 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 :. | §3 | Settled |
| 7 | Conflicts 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.1 | Settled |
| 8 | The 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. | §5 | Settled |
| 9 | One separator rule: name lists use spaces, selector lists use commas, because a space is the descendant combinator. | §6 | Settled |
| 10 | Closed 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=. | §7 | Settled |
| 11 | A 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). | §8 | Settled |
| 12 | geml 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 · §10 | Settled |
| 13 | Stability: 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 · §12 | Settled |
| 14 | Observed: 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 check | Observed |
| 15 | The 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.1 | Settled |
| 16 | Layers: 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.1 | Settled |
| 17 | Observed: 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 check | Observed |
| 18 | The 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 §4 | Settled |
| 19 | Observed: 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 · render | Observed |
The content document is the small codemap from the repo's test fixtures: two methods and a #calls table.
--json Settled=== 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
=== 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 ===
{
"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)
#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.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.--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.Seven errors and three warnings, counted against the §8 catalogue.
=== 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"} ===
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
#scr, because bindings are computed per screen.> were silently ignored the author would believe it worked.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=== 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
=== style-rule {#d-notes match="note" component=section} === === style-rule {#d-tables match="table" component=card-grid} === %% the default layer, keyed on types
=== style-rule {#hero match="#hero" component=hero} === %% the override layer, keyed on one block
=== note {#hero} GEML is an Agent-Native base document format. === === table {#facts} | k | v | |---|---| | spec | v1 | ===
{
"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.
#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.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.#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.#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.ambiguous-rule Observed=== style-rule {#d-notes match="note" component=section} === === style-rule {#hero match="#hero" component=hero} === %% {type=note} and {id=hero} contain neither
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.
match="note#hero"), or move it to a higher layer.default-style that resolves to nothing says so, instead of silently dropping a layer Settled=== 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
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.
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.The §11 example. It uses only the stable subset: one style-rule, match=, pass-through.
=== 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)
foldings.geml tunes build-time module naming, style.geml tunes display. Seeded on first build, never rewritten. See page 9.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.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.
| Probe | Result | Maps to |
|---|---|---|
| good.style.geml + content.geml | 0 diagnostics, --json view model as above | clean, board 12 |
| same with --components=edge-list | 1 warning unknown-component method-card | board 10 |
| bad.style.geml + content.geml | 7 errors 3 warnings | broken, board 11 |
| _index/index.geml (two layers) + page.geml | 0 diagnostics, #hero→hero, #facts→card-grid | entry |
| flat.geml (same two rules, one layer) + page.geml | 1 error ambiguous-rule | entry |
| _index/broken.geml (default-style resolves to nothing) | 1 warning style-embed-not-expanded | entry |
| geml check with the profile line removed | 5 warnings unknown-block-type | board 2 |