Status: design partly shipped, partly in-flight. Slice 1 complete (commits b5ea69c..0e18292); slice 2.1 (6079464), 2.2 (85fa0f7), and 2.3 (0225491..48b6c36) complete; slices 2.4 / 3 / 4 deferred. Architectural decisions in §10 are binding for all future slices.
Reactwright templates should describe styling for any paginated document — IEEE conference papers, scholarly books, magazines, newsletters, screenplays, recipe cards, legal briefs, technical reports — without authors ever writing target-specific CSS. The author's surface is "our CSS": a stylesheet dialect that operates on our typed semantic IR using our concept vocabulary. The engine compiles that dialect into whatever the rendering target needs (today: CSS Paged Media + Paged.js).
The current state achieves this only partially. Most templates eventually reach for the customCss: "..." escape hatch and start writing raw CSS that targets engine-generated class names like .reactwright-section-title, figure figcaption, a.reactwright-cite::after. That couples templates to engine internals and exposes authors to CSS quirks (text-align-last, break-inside, vendor prefixes, Paged.js fallbacks) that should never have been their problem.
This spec defines a single layered system to replace customCss.
| Surface | Type | Emitted as | Used for |
|---|---|---|---|
style={...} on layout nodes |
Record<string, unknown> (untyped, with optional typed sub-groups merged via mergeTemplateStyleGroups) |
inline style="..." on the element |
one-off instance styling on page, region, stack, columns, fixed, header, footer, etc. |
page={...} on <page> |
TemplatePageProps |
@page { ... } rule |
page geometry (size, margins, orientation) |
typography={...} on <page> |
TemplateTypographyProps |
.reactwright-flow { ... } rule |
document-wide body text |
<role on=X match=Y apply=Z> inside <rules> |
RoleRuleNode |
[data-variant="Z"] { ... } CSS class |
semantic → presentation routing for a fixed set of IR kinds |
style.customCss: "..." |
string | appended verbatim to document <style> |
escape hatch — raw CSS the engine doesn't know about |
| Engine-emitted structural classes | engine-internal | inline class="reactwright-..." on rendered HTML |
indirectly target-able via customCss |
These classes are currently the de facto interface between custom CSS and engine-rendered content. They are documented nowhere and may rename at any time.
| Class | Applied to | Documented? |
|---|---|---|
reactwright-flow |
root content wrapper | no |
reactwright-document-title |
<h1> rendered from <document title> |
no |
reactwright-section-title |
<h2> / <h3> from <section title> |
no |
reactwright-chapter-title |
<h2> from <section role="chapter"> |
no |
reactwright-abstract |
div from <abstract> |
no |
reactwright-cite |
<a> from <cite> |
no |
reactwright-toc-entry, -toc-link, -toc-page |
TOC machinery (consumed by the <Toc> userland helper) |
no |
reactwright-list-of-entry, -list-of-link, -list-of-page |
list-of-figures machinery (consumed by <ListOf>) |
no |
reactwright-ref-number, -ref-page, -ref-title, -ref-number-and-page |
<ref> rendered forms |
no |
reactwright-running-{name}-source, reactwright-running-{name} |
running-string source / sink | no |
reactwright-footnote, reactwright-sidenote |
floating note markers | no |
reactwright-math-block |
block math wrapper | no |
reactwright-page-number, reactwright-page-count |
counter sinks | no |
reactwright-index-pageref, reactwright-index-pagerefs |
index page-ref machinery | no |
data-variant="Z" (attribute) |
nodes whose role matched a <role> rule |
partially |
Audit of mockups/ieee/template.tsx IEEE_CSS (32 lines of customCss). Categorised:
| Category | Lines | Examples |
|---|---|---|
| Targets engine class directly | ~14 | h1.reactwright-document-title, .reactwright-abstract, .reactwright-cite::before/after, .reactwright-bibliography li::before |
| Pure HTML/CSS selectors | ~12 | figure figcaption, table caption::before, table th, td { word-wrap }, h2 + p |
| Counter / generated-content plumbing | ~6 | counter-increment, counter-reset, content: counter(..., upper-roman) |
None of the customCss in any template uses concepts that are unique to that template. Every rule there could be expressed in a sufficiently rich declarative vocabulary.
- No named-class abstraction. Authors cannot define
class section-head { ... }once and reference it from multiple places. - Selector vocabulary is fixed.
<role>only matches(role, kind)pairs. Cannot express depth, sibling, descendant, positional, or boolean combinators. - Declaration vocabulary is leaky. Anything beyond the typed style record and the four enumerated role-rule fields (breakBefore, breakAfter, breakInside, numbering, dropCap) falls back to customCss.
- No promoted concepts. Generated content (prefix/suffix), wrap (anchor/figure/quote), indent-except-after-heading, text-flow-with-last-line — all CSS soup the author shouldn't have to assemble.
- Engine class names are public. Every template depends on
.reactwright-cite::after { content: target-counter(...) }etc. Renaming the engine's internals would break templates.
Three coordinated surfaces. The author picks the one that fits the binding.
<region style={{ padding: "8mm 12mm", backgroundColor: "#0f172a" }}>
…
</region>Used when an instance has a unique presentation. Existing API. Preserved as-is.
<styles>{`
.masthead {
padding: 8mm 12mm;
background-color: #0f172a;
color: white;
}
`}</styles>
<region className="masthead">…</region>
<fixed className="masthead" anchor="top-left">…</fixed>Used when a presentation is reused. Plain CSS-like blocks of named class definitions. Author references by name.
<rule match={{ kind: "section", depth: 1 }} className="section-head" />
<rule match={{ kind: "section", depth: 2 }} className="subsection-head" />
<rule match={{ kind: "cite" }} className="cite" />Used when a class should apply automatically to every IR node matching a pattern. No content tagging required.
The three surfaces share the same underlying type system and the same dialect. style={...} accepts any property the dialect defines. <styles> definitions use the same property names in CSS-block syntax. <rule> applies a class to a set of IR nodes.
Selectors operate on the resolved IR, not on rendered HTML. The vocabulary is what the IR exposes; there are no element-name selectors, no class selectors (except for the engine's own [className] system below), no pseudo-classes that don't have an IR analogue.
Anything that survives into the resolved tree is a valid selector target. Concretely:
Content kinds — every content-IR node:
document, section, paragraph, heading, figure, table, row, cell, blockquote, list, item, defs, def, code-block, pre, math, m, cite, footnote, sidenote, index, ref, abstract, title, author, em, strong, code, link, br, sub, sup, img, refs, ref-entry.
Template containers (resolved) — every layout container the template emits into the rendered tree:
region, stack, row (new, see below), columns, column, layer, fixed, header, footer, regime-flow (resolver-generated).
Back-matter generators — composed in userland from the
data-source primitives (bib-data, toc-data, list-of-data,
index-data) and rendered as ordinary content sections. Target the
emitted nodes via their content kinds (section, list, item,
paragraph, …); engine-internal classes
(reactwright-toc-entry, reactwright-list-of-link, etc.) supply
the leader / target-counter machinery.
Not selectable — these are control / structural artefacts that don't survive to render:
slot— replaced by its substituted content during resolution. Use theslotcombinator (4.2) to select content that landed in a named slot.rules,role-rule,page-rule— control structures.font— head-tag-only.page,page-set— geometry containers, not in-flow content. Use<page page={...}>props for page-level styling, not selectors.
A few kinds are nominally part of the IR but don't currently carry the fields the selector language needs. Slice 1 must extend them:
| Kind | Currently | Needs |
|---|---|---|
row (template-side, new) |
does not exist | add a RowNode { kind:"row", gap?, style?, children } to template-IR — horizontal-flex container symmetric to StackNode. Renderer: display:flex; flex-direction:row; gap:<gap>. JSX grammar disambiguates from content-side table row by parent context (one only appears inside <table>; the other never does) |
row (content-side, table) |
{ kind, children } |
role, variant, id optional fields; included in assignRoleVariants walk |
cell |
{ kind, header?, children } |
role, variant optional fields; header becomes selectable via attr: { header: true } |
item (list) |
already walked partially | confirm role/variant present |
def (defs) |
already walked partially | confirm role/variant present |
caption |
currently a string prop on figure/table |
promoted to a proper child node so it's selectable via parent: { kind: "figure" } (Slice 2; see §10 q.6) |
These are small content-IR changes (a few fields each, one expansion in assignRoleVariants), but they belong on the slice-1 critical path because the selector language is only as expressive as the IR it can reach.
| Key | Type | Matches |
|---|---|---|
kind |
IR kind string | content or template IR node kind (section, paragraph, figure, cite, table, region, etc.) |
role |
string | the role attribute the content author wrote |
variant |
string | the role-rule variant the resolver assigned |
depth |
number | { gte: N } | { lte: N } |
nesting depth for section |
index |
"first" | "last" | number |
positional within siblings of the same kind |
id |
string | exact id match (rarely useful; mostly for one-offs) |
attr |
Record<string, unknown> |
other IR attributes (e.g. attr={{ page: "cover" }} for page-set routing) |
| Combinator | Shape | CSS analogue |
|---|---|---|
follows |
{ follows: Match } |
adjacent-sibling A + B |
precedes |
{ precedes: Match } |
(no CSS direct analogue; engine-resolved) |
parent |
{ parent: Match } |
direct child A > B |
within |
{ within: Match } |
descendant A B |
has |
{ has: Match } |
:has() |
slot |
{ slot: SlotName } |
(no CSS analogue; matches content that resolved into the named slot — "title", "author", "abstract", "body", or a regime body) |
not |
{ not: Match } |
:not() |
and |
{ and: [Match, Match, ...] } |
implicit conjunction |
or |
{ or: [Match, Match, ...] } |
selector list A, B |
All combinators are composable. Match is the recursive type.
The slot combinator is the answer to "I want to style paragraphs in the abstract, but not in the body" — slots themselves aren't styleable (they're gone post-resolution), but where content ended up is a useful selector axis. The resolver tags each resolved-content node with the slot name it filled; the selector reads that tag.
// Every figure inside a multi-column region
<rule match={{ kind: "figure", within: { kind: "columns" } }} className="…" />
// First paragraph following a top-level section heading
<rule match={{ kind: "paragraph", index: "first", follows: { kind: "section", depth: 1 } }} className="lede" />
// Tables that contain numeric data (carry a marker role)
<rule match={{ kind: "table", role: "data" }} className="data-table" />
// Anything except the title block
<rule match={{ not: { kind: "title" } }} className="…" />For terseness when selectors are static and conditions stay simple:
section[depth="1"] { … } /* depth:1 sections */
section[depth="2"] { … }
paragraph[follows="section"] { … } /* first paragraph after section */
figure[role="numbered"] > caption { … } /* numbered-figure captions */
cite { … }Both syntaxes compile to the same internal representation. JSX <rule> form is required when the match is conditional on runtime values.
A declaration is a property: value pair. The dialect defines a typed property vocabulary. Three categories:
Standard CSS properties that map 1:1 to the target. The author writes the CSS name, the engine emits it unchanged.
font-family font-size font-weight font-style font-variant
line-height letter-spacing word-spacing color background-color
margin* padding* width max-width min-width
height max-height min-height
border* border-radius
display align-items justify-content flex-direction gap
opacity transform object-fit
All currently accepted in the typed style records. Stay as-is.
Concepts the engine knows about that don't have a clean CSS counterpart. Compile to whatever CSS soup makes them work on the current target.
| Our property | Replaces / compiles to |
|---|---|
numbering: counter(NAME, STYLE) FORMAT |
counter-increment + ::before { content: counter(...) } plus reset plumbing. STYLE is one of decimal, upper-roman, lower-roman, upper-alpha, lower-alpha. FORMAT is a literal string with $NAME substitutions. |
numbering-reset: counter1 counter2 … |
child counters to reset when this counter increments |
numbering-scope: parent-counter-name |
nest this counter inside a parent's count (Figure 3.2 = chapter 3, figure 2) |
prefix: "..." | content(...) |
::before { content: ... } |
suffix: "..." | content(...) |
::after { content: ... } |
wrap: anchor |
wraps element in an <a href> linking to its referent (citations, refs, toc entries) |
wrap: figure | quote | caption |
wraps element in a semantic container |
break: before(VALUE) after(VALUE) inside(VALUE) |
break-before + break-after + break-inside, plus the legacy page-break-* fallbacks Paged.js sometimes needs |
indent: first-line(LENGTH) except-after(MATCH...) |
text-indent + sibling-combinator overrides for headings |
text-flow: align(VALUE) last-line(VALUE) hyphens(VALUE) |
text-align + text-align-last + hyphens |
keep-with-next: true |
break-after: avoid (heading sticks to first paragraph) |
hanging-indent: LENGTH |
text-indent: -L; padding-left: L (bibliography entries) |
column-fit: shrink-to-column |
table-layout: fixed + width: 100% + word-wrap (tables in columns) |
caption-position: above | below |
caption-side + sibling order |
drop-cap: lines(N) font(F) |
already exists on <role>, promoted to general property |
decimal 1, 2, 3, …
decimal-leading-zero 01, 02, 03, …
upper-roman I, II, III, …
lower-roman i, ii, iii, …
upper-alpha A, B, C, …
lower-alpha a, b, c, …
upper-latin A, B, C, … AA, AB (after Z)
Maps to CSS <counter-style>. Author writes the conceptual name.
FORMAT := (LITERAL | "$" NAME)*
NAME := identifier matching /[a-zA-Z_][a-zA-Z0-9_-]*/
"Figure $chapter.$figure" → renders as "Figure 3.2" when chapter=3, figure=2.
Already implemented as numberingFormatToCssContent for <role> numbering. Generalises.
The author writes blocks of CSS-like text inside <styles> tags. Multiple blocks can appear anywhere in the template; the engine concatenates them in document order.
<styles>{`
.section-head {
font-size: 10pt;
font-style: normal;
text-align: center;
text-transform: uppercase;
letter-spacing: 0.04em;
numbering: counter(sec, upper-roman) "$sec. ";
numbering-reset: sub;
break: after(avoid);
keep-with-next: true;
}
.subsection-head {
font-size: 10pt;
font-style: italic;
text-align: left;
numbering: counter(sub, upper-alpha) "$sub. ";
keep-with-next: true;
}
.cite {
color: inherit;
text-decoration: none;
wrap: anchor;
prefix: "[";
suffix: counter(bib) "]";
}
.body-paragraph {
indent: first-line(1em) except-after(section, heading);
text-flow: align(justify) last-line(start);
}
.figure-caption {
font-size: 8pt;
text-align: center;
numbering: counter(fig, decimal) "Fig. $fig. ";
}
.table {
column-fit: shrink-to-column;
font-size: 8pt;
numbering: counter(tab, upper-roman) "Table $tab. ";
caption-position: above;
}
`}</styles>Properties not in the dialect are accepted as-is but flagged with a build-time warning (until a typed property promotes them). This is the gradual escape valve — start with 80% CSS, promote concepts as they earn their keep.
- Whitespace, comments (
/* */), and;-terminated declarations. - Selector syntax is a strict subset (see 4.4) — selectors that don't fit the IR match grammar are rejected at parse time with a clear error pointing back at the offending selector.
@-rulesare not part of the dialect. Page geometry stays on<page page={...}>. There is no@media, no@import, no nesting in v1.- Vendor prefixes (
-webkit-*,-moz-*) are rejected. The engine emits them when needed.
The engine compiles <styles> blocks into:
- Named-class definitions stored in a per-document table.
- Pattern→class bindings collected from
<rule>JSX nodes. - The compiled-output CSS injected into the document
<style>block, replacing the role-applied class names with stable engine-generated tokens.
An author who writes className="masthead" references the same table. Unknown class names produce a build error with the list of declared classes.
For reference, here is what the IEEE template looks like after the spec lands. Compare to current mockups/ieee/template.tsx.
import "reactwright/jsx";
export function Template() {
return (
<page
page={{ size: "letter", marginTop: "19.05mm", marginBottom: "25.4mm", marginLeft: "15.875mm", marginRight: "15.875mm" }}
typography={{ fontFamily: "'Times New Roman', Times, serif", fontSize: "10pt", lineHeight: 1.15 }}
>
<styles>{`
.title {
font-size: 24pt;
font-weight: normal;
text-align: center;
line-height: 1.1;
margin: 0 0 12pt 0;
}
.abstract {
font-size: 9pt;
font-weight: bold;
font-style: italic;
text-flow: align(justify);
}
.section-head {
font-size: 10pt;
text-align: center;
text-transform: uppercase;
letter-spacing: 0.04em;
numbering: counter(sec, upper-roman) "$sec. ";
numbering-reset: sub;
break: after(avoid);
}
.subsection-head {
font-size: 10pt;
font-style: italic;
numbering: counter(sub, upper-alpha) "$sub. ";
break: after(avoid);
}
.body-paragraph {
indent: first-line(1em) except-after(section);
}
.figure {
break: inside(avoid);
}
.figure-caption {
font-size: 8pt;
text-align: center;
numbering: counter(fig, decimal) "Fig. $fig. ";
}
.table {
column-fit: shrink-to-column;
font-size: 8pt;
break: inside(avoid);
numbering: counter(tab, upper-roman) "Table $tab. ";
caption-position: above;
}
.table-caption {
font-size: 8pt;
text-transform: uppercase;
letter-spacing: 0.04em;
text-align: center;
}
.table-header-cell {
font-style: italic;
border-top: 0.5pt solid #000;
border-bottom: 0.5pt solid #000;
}
.code-inline {
font-family: 'SFMono-Regular', Consolas, Menlo, monospace;
font-size: 0.92em;
}
.cite {
color: inherit;
text-decoration: none;
wrap: anchor;
prefix: "[";
suffix: counter(bib) "]";
}
.bibliography {
font-size: 8pt;
}
.bibliography-heading {
font-size: 10pt;
text-transform: uppercase;
letter-spacing: 0.04em;
text-align: center;
break: after(avoid);
}
.bibliography-entry {
hanging-indent: 1.4em;
prefix: "[" counter(bib) "] ";
text-flow: align(justify);
}
`}</styles>
<rule match={{ kind: "title" }} className="title" />
<rule match={{ kind: "abstract" }} className="abstract" />
<rule match={{ kind: "section", depth: 1 }} className="section-head" />
<rule match={{ kind: "section", depth: 2 }} className="subsection-head" />
<rule match={{ kind: "paragraph" }} className="body-paragraph" />
<rule match={{ kind: "figure" }} className="figure" />
<rule match={{ kind: "figure", has: { kind: "caption" } }}/cap className="figure-caption" />
<rule match={{ kind: "table" }} className="table" />
<rule match={{ kind: "table", has: { kind: "caption" } }}/cap className="table-caption" />
<rule match={{ kind: "cell", attr: { header: true } }} className="table-header-cell" />
<rule match={{ kind: "code", attr: { inline: true } }} className="code-inline" />
<rule match={{ kind: "cite" }} className="cite" />
<rule match={{ kind: "section", role: "bibliography" }} className="bibliography" />
<rule match={{ kind: "section", role: "bibliography", has: { kind: "heading" } }} className="bibliography-heading" />
<rule match={{ kind: "ref-entry" }} className="bibliography-entry" />
<header anchor="top-center" when="not-first-page" style={{ fontSize: "8pt", fontStyle: "italic" }}>
<running name="document-title" />
</header>
<footer anchor="bottom-center" style={{ fontSize: "8pt" }}>
<page-number />
</footer>
<stack gap="0">
<region style={{ textAlign: "center", paddingBottom: "4mm" }}>
<slot name="title" />
<slot name="author" />
</region>
<region style={{ columns: 2, columnGap: "4.24mm", textAlign: "justify" }}>
<slot name="abstract" />
<slot name="body" />
<Bibliography title="References" />
</region>
</stack>
</page>
);
}Net change from current IEEE_CSS:
- 32 lines of customCss string → 0
- Hand-written counter plumbing → declarative numbering
- Targeted engine class names (
.reactwright-cite,figcaption) → never appears - Two CSS bugs that bit us (
text-align-last,table-layout: fixed) → captured as our-dialect properties (text-flow last-line,column-fit shrink-to-column)
The compile pipeline at HTML-emit time:
- Parse
<styles>blocks into an IR of named class definitions. - Resolve
<rule>JSX into pattern → class-name bindings. - Walk the resolved content IR, applying class-name bindings to every matching node. Output: per-node list of class names.
- Lower the class definitions from the dialect to target CSS:
- Pass-through properties: emit unchanged.
- Our-dialect properties: lower to the appropriate CSS subset for the current target. The
numberingproperty expands to counter-increment + ::before content + counter-reset plumbing. Thewrapproperty mutates the renderer to wrap the element. Thecolumn-fitproperty expands towidth:100% table-layout:fixed word-wrap:break-word.
- Emit the compiled CSS into the document
<style>block, alongside the existing engine machinery defaults.
Compile output is deterministic and ordering-stable for byte-diff testing.
Application order, from lowest to highest CSS specificity in the emitted output:
- Engine
STATIC_DEFAULTS_CSS(machinery). - Rule-applied classes (from
<rule match className>). - Element-applied classes (from
className="..."on a JSX node). - Inline
style={...}on a JSX node.
Familiar to anyone who has worked with CSS. The same mental model carries over.
After the spec lands, engine-emitted internal class names (reactwright-section-title, reactwright-cite, …) are not part of the public API. Templates should never target them directly. The dialect's kind, role, variant, depth selectors are the contract.
The internal names become free to rename, namespace, or hash. A future engine version could prefix them with a build hash for cache busting; templates won't care.
The spec is large; ship it in slices.
- Implement
<styles>block parsing. - Implement
<rule match className />(no implicit role/apply field). - Implement
className="..."prop on layout and content nodes. - Define dialect grammar for selectors (4.1–4.3).
- Pass-through CSS properties only — no our-dialect properties yet.
- All existing
<role>,style={...},customCsscontinue to work.
After slice 1: authors can replace customCss with <styles> for any rule that's expressible in pass-through CSS. The IEEE template's "targets engine class directly" lines (~14 of 32) start migrating.
- Add
numbering,numbering-reset,numbering-scope. - Add
prefix,suffix(replaces ::before/::after). - Add
wrap: anchor. - Add
break: before/after/inside(VALUE).
After slice 2: the IEEE template's "counter / generated-content plumbing" lines (~6 of 32) migrate.
- Add
indent: first-line/except-after. - Add
text-flow: align/last-line/hyphens. - Add
column-fit: shrink-to-column. - Add
caption-position,hanging-indent,keep-with-next.
After slice 3: the IEEE template's "pure HTML/CSS selectors" lines (~12 of 32) migrate.
- Engine-emitted class names become internal-only (prefix with
_or hash). customCssdeprecated with a build-time warning.- All mockups + examples migrated to the new system.
<role on=X match=Y apply=Z style={...}>desugared to<rule match={...} className="...">for backwards compatibility.
After slice 4: zero customCss in any shipped template. customCss removed or kept as a documented last-resort escape.
Twelve decisions, locked in. Each replaces what would otherwise be an open question. Reverse-able pre-v1.0 if implementation reveals a flaw, but the implementation work proceeds against these defaults.
-
Parser implementation — hand-rolled. ~300 lines, zero dependencies. Library tolerance would accept input the engine can't lower; the dialect's value is precisely its narrowness.
-
Dialect form — string CSS for v1. Typed JSX form deferred to v2 if value emerges. Familiar surface, lowest bar to entry.
-
Parse timing — build-time. Parsing happens during
renderResolvedToHTML. Runtime JSX reconciliation stays free of styling work. A separatevalidateStyles()helper can be added later for editor tooling. -
Class-name collisions — error. Two
<styles>blocks declaring.fooproduce a build error listing both source locations. Explicitreplace="foo"attribute on the redefinition will be added if and when need arises. -
Cross-file stylesheet sharing — string composition for v1. Authors export a string from a
.styles.tsmodule and compose via<styles>{base + extension}</styles>. Typed import handle (<styles use={base}>) deferred. -
Match disambiguation — both apply, CSS cascade decides. Multiple
<rule>s matching one node attach all their class names; the cascade resolves. Author can usestyle={...}inline to win against any class.<rule priority={N}>deferred until cascade produces surprises in practice. -
MatchTypeScript shape — flat type for v1. Optional fields,attr: Record<string, unknown>,depth: number | { gte?: number; lte?: number }. Discriminated-union narrowing per kind deferred. -
Caption-as-node — slice 1. Captions promoted from
caption?: stringprops onfigure/tableto first-class IR nodes. Unblocks the IEEE template's caption-styling lines immediately. -
wrap: anchorsemantics — IR-transform pass before render. Slice 2 adds a phase between resolver and renderer that walks the rule map and mutates IR for wrap/prefix/suffix/anchor concerns. The renderer stays declarative. -
Engine class names — public through slice 3, prefixed-internal at slice 4. Existing
customCsskeeps working until slice 4; then engine classes get a_prefix or hash to signal don't-target. -
customCsslifecycle — deprecated at slice 4 with build warning, removed at v1.0. Slow migration, real escape valve preserved through 0.x. -
Engine defaults in cascade — keep as raw CSS for slice 1, migrate to
<styles>form at slice 4. Today'sSTATIC_DEFAULTS_CSSrules don't move during slice 1. Slice 4 dogfoods the dialect by re-expressing engine defaults in the same form templates use.
- Not a CSS replacement. Authors who want to write raw target CSS can —
style.customCssremains for now. - Not a runtime style engine. All compilation happens at engine build time. The reader of the PDF or HTML never sees any of this dialect; they see compiled target CSS only.
- Not a styling-only refactor. Slices 2 and 3 require IR work (captions as nodes, depth-aware section matching, position-aware "first paragraph after heading"). The styling spec drives those IR changes, but the IR changes are the substantive engineering.
- Not portable across rendering targets without effort. We lower to CSS Paged Media + Paged.js today. A future LaTeX, ConTeXt, or InDesign target would need a lowering implementation for each dialect property. The dialect's value is precisely that templates don't need to be rewritten when the target changes — only the engine's lowering does.
The user (template author) writes once. The engine (us) maintains the translation forever. Every property promoted from "pass-through CSS" to "our dialect" is a permanent maintenance commitment in exchange for one less CSS quirk leaking into authors' templates.
The trade is worth it where the concept is genuinely cross-target (numbering, breaks, indent, generated content) or where the CSS surface is a known footgun (text-align-last, table-layout, column-fit). It's not worth it for properties that are clean CSS-as-is (color, font-size, padding).
Slice 1 ships ~no new maintenance commitments. Slices 2 and 3 commit us to ~15 promoted properties. The IEEE template, currently 32 lines of customCss, ends up with 0.