geml-style/v1 · dimension by dimension · 2026-09-13 · 中文 · all illustrated pages · GEML home
Ten dimensions, CSS on the left and geml-style on the right, every example taken
from the stylesheet under examples/style-demo that actually runs.
The finding first: on eight of them it is a restricted subset of CSS, and
someone who reads CSS will not trip; the other two are things CSS does not
have at all — the stylesheet decides where content goes, and an ambiguity is an
error. Those two are bound to break your expectations, and they should.
This comparison itself turned up three differences that were gaps rather than
positions (tokens, shorthands, inheritance); all three are now written into the profile.
All measured on the current branch, not estimated. Stylesheet = examples/style-demo/_index/github.style.geml, a 1:1 reconstruction of a GitHub blob page — a real use, not a demo.
meta feed 75 {{…}}; 8 bare colour values leftWhat the tags mean: near enough = a different spelling, your CSS intuition carries over; deliberately different = another system on purpose, and the reason holds; counterintuitive = someone who writes CSS stops here for a second.
/* another language, another file,
another grammar and parser */
.sidebar {
width: 296px;
}# the "stylesheet" IS a GEML document; # every rule is an addressable block === style-rule {#w-side match="text#sidebar" width="296px"} ===
CSS is its own language; geml-style has no grammar of its own — it is a profile of GEML.
The gain is concrete: geml get sheet.geml '#w-side' fetches one rule, geml set
replaces that one rule, .gemlhistory rolls back that one rule — this is built for an agent
to edit, not for a person to hand-write. The cost is concrete too: it is wordier. One rule is
one block, so 104 rules are 104 blocks.
#api table.kpi /* descendant */ #api > table /* child */ h2 + p /* adjacent */ li:nth-child(2n) /* position */ a:hover /* state */ [href^="https"] /* prefix */ * /* universal */
#api table.kpi ✓ the one combinator text#nav link ✓ inline part (last step) * ✓ but a whole step only [key] [key=value] ✓ presence / equality > + ~ selector-unsupported :hover :nth-child() selector-unsupported ^= $= *= |= selector-unsupported
The vocabulary is well under half of CSS's: type + .class + #id +
attribute presence/equality, plus one descendant combinator. What it adds is the inline-part step —
link image code-span strong emphasis —
which are elements in CSS, while in GEML an inline run inside a block has no address of its own, so five
names were given to them. The difference that matters is not what is missing but that what is missing
raises an error. Writing #api > table does not fail to match; it is
selector-unsupported, and check fails. "Looking like CSS is a ramp, not a trap" — the
profile's own words.
/* four tiers, always a winner */ !important > specificity arithmetic (1,0,0) vs (0,2,0) > @layer order > source order (the last resort) /* two rules tie? the later one wins. Silently, with no notice. */
# one relation: strict superset of conditions {type, .class, #id, [attr], screen:x, when:s=v} A ⊃ B → A wins else → ambiguous-rule (error) # a shorthand and a side share one key: border vs border-left same layer, different rules → ambiguous-rule # no specificity arithmetic # no !important # no source order # layers come only from style-entry (0/1/2)
This is the widest divergence. CSS guarantees there is always a winner, and pays for it
with a winner that may not be the one you meant; geml-style guarantees it will not guess, and pays for it
with two defensible rules failing check until you separate them yourself.
Ruling out source order has a stated reason: a stylesheet whose outcome depends on order is one that
block-level agent edits like geml add --before can silently re-render — and supporting that
kind of edit is the whole reason this format exists.
Layers remain (default / sitemap / the entry itself, 0-1-2), but a layer number is declared and the
selector plays no part in it — that is CSS's @layer, not specificity.
"No source order" is meant literally, down to the shorthands. Arbitration runs per
property name, and border and border-left are two names, so they would never
meet: both rules survive, whichever sits later in the file wins, no diagnostic. That is precisely the case
geml add --before would re-render in silence. A shorthand and its four sides are now one
arbitration key: in the same layer, the same when group, from different rules, that is
ambiguous-rule. The choice was "do not guess", not "quietly sort the box before emitting CSS"
— the latter is a precedence rule smuggled in.
/* hundreds of properties, open-ended */ display position z-index transform transition opacity box-shadow filter clip-path grid-template-areas aspect-ratio /* an unrecognised property → dropped silently */ colr: red; /* nothing happens */
# 38 box words, closed width max-width min-width height max-height min-height padding margin gap font-size line-height font-family font-weight text-align color background underline border border-top/right/bottom/left border-radius axis item-align item-justify wrap anchor place visible grow sticky scroll hide-below view editable fade-out fade-in colr="red" → passed to the component; no component → style-unknown-attribute
The 38 were sifted by one test: does it still mean the same thing on a different block?
width means the same on a paragraph, a table and an image, so it is a box word;
fold only makes sense for a tree control, so it is a component parameter.
What is absent deserves saying out loud: no display, no position, no
z-index, no transform, no transition, no opacity,
no box-shadow. anchor=parent|viewport with place, plus
sticky=top|right|bottom|left, are the slice of position that survived encapsulation, and
visible=no is the slice of display. This is not "not built yet" — it is
"block layout needs this much" — but the moment you want a shadow, a transition, any decorative effect,
today you go back to the host's CSS.
:root { --border: #d1d9e0; }
.a { border-color: var(--border); }
.b { border-color: var(--border); }
width: calc(100% - 2rem);
color: color-mix(in srgb, ...);# tokens: the sheet's own meta is the table === meta line = "#d1d9e0" === border="1px solid {{line}}" # a theme change touches this one block # a dangling token → unknown-token (error) # a value is still an opaque safe string: # [A-Za-z0-9 #%.,()+-/_] # calc() rgb() pass (parens are on the list) # url( and /* are refused by name
The open-valued words (width color border) reach the
CSS text as written, and a stylesheet is untrusted input — a single
width="0} body{display:none} .x{" would rewrite the page — so there is a character allowlist,
and what it refuses is reported rather than dropped.
Variables are answered by tokens: every key in the sheet's own meta can be written
{{key}} inside any attribute, expanded at load, keeping its type (so a numeric word can
be fed one), and expanded before safeCssValue — which is why "a value is an opaque string"
survives intact. Measured: 13 keys feeding 75 references, with bare colour values down to 8.
The cost is that this is not the equivalent of a CSS custom property — there is no scope and no cascading
override. It is textual substitution at load time, one table per sheet.
/* CSS never decides where content appears.
DOM order = whatever the HTML said.
order / grid-area can rearrange,
but cannot move a block into
a different container. */=== style-frame {#header axis="row" slots="text#logo, text#nav link, #search-box"} === # the slots decide which blocks enter this # region and in what order. Not one line in # the content document says it belongs here.
This dimension does not exist in CSS, so "does it match expectations" does not apply — it is
closer to XSLT or a template engine than to a stylesheet. The measured effect: page.geml
holds content only, not one word of chrome text, and 27 frames assemble it into that GitHub page.
This is the most valuable thing in the design, and the one most easily misread by someone coming from
CSS — the word "stylesheet" is already inaccurate here. It is a layout sheet.
a:hover { color: blue }
input:checked ~ .panel { display: block }
/* anything richer → :has(), the checkbox
hack, or hand it to JS */=== style-state {#tab match="..." on="click" init-value="preview"} === === style-rule {match="#pane" when="$tab=code" visible="yes"} === === style-rule {match="text#nav link" when="@hover" underline="yes"} ===
@hover @focus @invalid @disabled @checked are built
in and translate straight to :hover / :focus-visible / :invalid /
:disabled / :checked; your CSS intuition transfers unchanged. The last three are the
control’s own state, and the profile does not define when a control is in one — validity is the
handler’s verdict; a stylesheet says only what it looks like. What is genuinely
added is named state: $tab=code is an explicit cell with an initial value and a declared
interaction feeding it, not something reconstructed out of DOM structure. The data flow is pinned to
interaction → state → view and state never reads state, so there are no cycles — and
correspondingly no binding-cycle in the diagnostics catalogue. What CSS does with the checkbox
hack is a first-class citizen here.
@media (max-width: 767px) { ... }
@supports (display: grid) { ... }
@layer base, theme;
@keyframes fade { ... }
@font-face { ... }
@container (min-width: 40em) { ... }hide-below="768" # the only responsiveness fade-out="1.2" # the only animation # layers → from style-entry, not an at-rule # no equivalent of @supports, @container, # @font-face or @keyframes. # The prefers-reduced-motion concession is # hard-coded in the host's static sheet.
Responsiveness is down to one breakpoint in one direction (below N px, hide), and animation
to one kind (drawn, then fading itself out). Both came from "fence it to the first real use, add nothing
more". The honest reading: this is not a design position, it is not having got there yet. The first
time someone wants two columns to become one on a narrow screen — rather than one of them disappearing —
hide-below is not enough.
/* every property declares whether it inherits.
color does, border does not.
inherit / initial / unset / revert
are available at all times. */
.child { color: inherit }# §10: "inheritance is the host's to decide; # this profile does not describe it." # The box is flat; inheritance belongs to # the host's medium, not to the profile. a host that emits CSS color="{{fg}}" → children follow border="1px ..." → they do not a host drawing to canvas / setting a PDF → its own call; allowed to differ
The view model ({states, screens, frames, bindings, diagnostics}) has a flat
box; inheritance is not in it. Swap in a host that does not emit CSS — drawing to a canvas, setting a PDF —
and the same stylesheet may render differently. What matters is that this is now written down
rather than overlooked: §10 says plainly that inheritance is the host's to decide and the profile does
not describe it. That is stating the gap, not closing it: pinning inheritance down would mean re-deciding
"inherits / does not" for all 38 words, and no consumer needs that today.
So it stays counterintuitive, but the reason moved from "not thought through" to "thought through, and
deliberately not governed" — for a specification, that difference is the whole thing.
/* forgiveness is a design goal:
an unrecognised declaration, selector or
at-rule is dropped and parsing continues.
The page always renders.
The price: a typo never tells you. */# 20 named diagnostics (13 error / 7 warning) selector-unsupported error ambiguous-rule error unknown-state/screen/frame error frame-cycle / frame-too-deep error screen-nested error unknown-token error style-invalid-value error unmatched-rule warning unused-frame warning unknown-component warning style-unknown-attribute warning style-embed-not-expanded warning
The severity line is drawn clearly: structural faults are errors (ambiguity, dangling
references, cycles); unknown names are warnings with a lazy fallback. The second half protects forward
compatibility — a name you do not recognise must degrade, not reject the whole document.
geml check runs the entire set, whereas the nearest thing on the CSS side (stylelint) is a
third-party tool and not part of any specification. geml-style wins this dimension outright.
The first ambiguous-rule reads like the tool being pedantic. But this format's reader is an
agent: geml set and geml add --before reorder blocks all day, and any arbitration
resting on source order silently re-renders under that kind of edit. Refusing to guess is correct.
The one thing still owed is diagnostic prose good enough to show you how to pull the two rules apart.
slots= has no counterpart in CSS, and this step walks out past the definition of "style".
The measured gain is hard: zero chrome text in page.geml, and all 27 regions of a GitHub page
assembled by the sheet. The cost is a misleading name — worth saying at the top of the profile that
this is not CSS's equivalent but a layout sheet that also carries style, so nobody arrives holding CSS
expectations.
CSS's open property set is why it became a universal language, and also why it cannot be checked
statically. Thirty-six words plus the test "does it still mean the same on a different block" is a
position that holds together. The slope to guard is this: two more words per new page, and by word eighty
the benefit of "closed" is gone while the cost remains. view, editable,
fade-out and underline arrived that way — each for one page's one need.
The eight that came later are a different case, and settling why sharpened the
test. height, min-height, max-height,
min-width, font-weight, fade-in, item-align and
item-justify each complete an axis: the width axis carried three words and the
height axis none; type could say size, leading and family but not weight; a thing could fade out and
not in; and axis opened an axis that gap spaced along but nothing aligned on.
An axis laid on one side only is an asymmetry, not restraint. So the gate is not "wait for a
second use" — that would block every piece of foresight; the gate is whether a word completes an
axis or serves one situation.
The three gaps this comparison turned up — tokens, shorthands, inheritance — are all written into the profile now. What is left is not misjudgement; it is use that has not pushed hard enough yet.
hide-below="768" is the whole of it: narrower than N px, hide. No @container,
no breakpoint ranges, no "two columns become one" — only "one of the two columns disappears".
It is the product of "fence it to the first real use, add nothing more", and the position itself holds.
But it will not survive the next use: any layout that must reflow rather than hide on a narrow
screen asks immediately for something beyond hide-below. What is owed then is not one word but
a breakpoint model — and a breakpoint model and placement (the slots of §06) are two faces of
one thing, which cannot be designed apart.
The profile's own §0.1 lists them in its right-hand column: filter= has never run against
real noise (every edge in the codemap corpus is kind=call with empty confidence — nothing to
filter), and handler= has no real host.
This counts as a problem not because those words might be buggy, but because the entire force of the "closed vocabulary" position comes from every word having been forced out by a real use. A word no use forced out is indistinguishable from a CSS property that was defined and left waiting for someone to need it — which is exactly where the slope in ③ above begins. To its credit, the profile breaks this column out on its own and states what it buys.
geml-style is not "another CSS"; it is a layout sheet for an agent to edit, which also carries style. On selectors, properties and at-rules it is a restricted subset of CSS and a CSS reader will not get lost; on the cascade and on placement it is a different system, and both departures have reasons that hold. The three gaps this comparison forced out — tokens, shorthand arbitration, and inheritance written down as the host's to decide — are in the profile now. What is left that is "not like CSS" is not a problem; it is the part no use has pushed on yet.