geml-style/v1 · dimension by dimension · 2026-09-13 · 中文 · all illustrated pages · GEML home

geml-styleagainstCSS

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.

Readings

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.

Style blocks
1041 screen · 27 frame · 6 state · 70 rule
Box words
38a closed list; CSS has hundreds of properties
Diagnostics
2013 error · 7 warning
Selector steps
40 / 47one step / two steps — there is no third
Tokens
1313 keys in meta feed 75 {{…}}; 8 bare colour values left
Combinators
1descendant (a space), and nothing else

The ten dimensions

What 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.

01

What a stylesheet is

deliberately different
CSS
/* another language, another file,
   another grammar and parser */
.sidebar {
  width: 296px;
}
geml-style
# 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.

02

Selectors

counterintuitive
CSS
#api table.kpi        /* descendant */
#api > table          /* child */
h2 + p                /* adjacent */
li:nth-child(2n)      /* position */
a:hover               /* state */
[href^="https"]       /* prefix */
*                     /* universal */
geml-style
#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.

03

The cascade

counterintuitive
CSS
/* 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. */
geml-style
# 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.

04

Properties

deliberately different
CSS
/* 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 */
geml-style
# 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.

05

Values

counterintuitive
CSS
:root { --border: #d1d9e0; }
.a { border-color: var(--border); }
.b { border-color: var(--border); }

width: calc(100% - 2rem);
color: color-mix(in srgb, ...);
geml-style
# 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.

06

Placement — the dimension CSS does not have

counterintuitive
CSS
/* 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. */
geml-style
=== 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.

07

State

near enough
CSS
a:hover { color: blue }
input:checked ~ .panel { display: block }
/* anything richer → :has(), the checkbox
   hack, or hand it to JS */
geml-style
=== 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.

08

At-rules

deliberately different
CSS
@media (max-width: 767px) { ... }
@supports (display: grid) { ... }
@layer base, theme;
@keyframes fade { ... }
@font-face { ... }
@container (min-width: 40em) { ... }
geml-style
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.

09

Inheritance

counterintuitive
CSS
/* every property declares whether it inherits.
   color does, border does not.
   inherit / initial / unset / revert
   are available at all times. */
.child { color: inherit }
geml-style
# §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.

10

Error philosophy

deliberately different
CSS
/* 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. */
geml-style
# 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.

Three places it breaks your expectations, and should

① An ambiguity is an error, not "the later one wins"

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.

② The stylesheet decides where content goes

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.

③ The property table is a closed 38 words

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.

Two still open — both "not there yet", not "got it wrong"

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.

① Responsiveness runs in one direction only

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.

② Part of the vocabulary is specified and checked but never exercised

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.

In one sentence

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.