The stylesheet

Since the CSS-native rework, a ScriptPub document keeps every visual choice in one real stylesheet — the document sheet. In the .spub.html spelling it is the <style id="scriptpub-style"> element; a bare file opened in any browser renders styled by it, with no tooling (the zero-tooling property now covers appearance, not just structure). The config JSON keeps only what CSS cannot say: block identity and flow, inference grammar, sections, indexes, metadata, libraries.

You author the sheet the way you style a web page: rules, selectors, real @media queries. The app’s format editor edits it as CSS; the engine parses it back into a structured model (the SPub CSS profile below), so forms, validation, print, EPUB export, and the on-screen paginator all read the same truth.

The head, after the rework

<script type="application/json" id="scriptpub"> … non-visual config … </script>
<style id="scriptpub-style">
@layer sp-base, sp-app, sp-user, sp-doc, sp-tweaks;

@layer sp-base { /* machine prelude — regenerated on save, skipped on parse */ }

@layer sp-doc {
  main { font-family: var(--font-serif); max-width: 56ch; --spub-outline: right; }
  h1.act { text-transform: uppercase; font-weight: bold; text-align: center; }
  p.dialogue { margin-left: 2ch; }
  @media (max-width: 40rem) { main { --spub-outline: overlay; } }
  @media print { @page { size: a4; margin: 2cm 2cm 2cm 2.5cm; } }
  html[data-spub-intent~="edit"] main { --spub-editor-current-line: highlight; }
}
</style>

Non-HTML syntaxes carry the same sheet as CSS text in the config block: a stylesheet string key in md’s JSON frontmatter, or in the companion .spub.json for syntaxes that can’t embed a config at all.

Layers

The sheet resolves by the ordinary CSS cascade — there is no extends, no abstract, no scoring engine. Defaults come first, later layers override:

Layer Owner Holds
sp-base the serializer (machine) role tokens (--paper--mono, light-dark() pairs), color-scheme, the document-surface floor, structural grid/media rules. Regenerated on every save; parsers skip it.
sp-app the app (never in a file) app-level defaults.
sp-user the app (never in a file) App-provided defaults that fill in beneath a document’s own styling.
sp-doc the document the format’s own styling: block rules, typography, conditions, @page.
sp-tweaks the app (never in a file) Runtime tweaks and the reader’s explicit Appearance preferences, which override document defaults.

A file may declare only sp-base and sp-doc. “Delete an inherited value” is just re-declaring it (or revert-layer).

Selectors — the file spelling

Selector Styles
:root / html theme custom properties; condition guards (below)
body, main the page / the document surface — root typography, measure (max-width), padding, --spub-* settings
tag.kind (p.dialogue, h1.act) a block kind. The serializer always writes a non-default kind’s tag + class (write.class, else the id), so these selectors match in a bare browser
tag.markid (u.underline, em.em) a mark
section.id / header.id / footer.id a section wrapper
section > p.paragraph:not(p.paragraph ~ p.paragraph) the first semantic paragraph block in a section, used by reader preferences such as first-paragraph indent; other <p>-backed block kinds do not match
…::first-letter / …::first-line the pseudo-elements a paragraph lead uses — a drop cap (initial-letter/float) or a small-caps opening line

When the app embeds a document it re-scopes the sheet under the host element (main/body/html collapse onto the scope; guards become attribute tests on it). The file spelling stays document-native.

Conditions

Environment conditions are real media features — the browser evaluates them natively, and the app evaluates the same queries in JS where it can’t (pagination, EPUB, print): screen / print, min-width / max-width, orientation, monochrome / color, pointer, hover, resolution. App-applied conditions are data-spub-* attribute guards on html, inert in a bare browser — see Conditions.

Settings — --spub-*

Everything visual that standard CSS can’t express rides the same cascade as namespaced custom properties (a browser simply ignores them): flow (--spub-flow: continuous | paged), panes (--spub-outline, --spub-progress), page furniture templates (--spub-numbering-format: "{n}.", --spub-page-header, --spub-show-nonprinting), and editor behavior (--spub-editor-current-line, --spub-editor-input, --spub-ribbon, …). The full machine-readable catalog is SETTINGS in the engine (sheet/settings.ts); Layout documents the vocabulary.

Print

@page is stored natively — size, margins, :first/:left/:right — usually inside @media print. The furniture templates (--spub-numbering-*, --spub-page-header/-footer) are the source; the @page margin-box content rules are compiled from them at render time ({n}counter(page), {title}/{author} → escaped literals, {division} → a CSS named string). Hand-authored margin boxes pass through and cascade.

The SPub CSS profile (what parses as structure)

The engine’s parser recognizes: one @layer order statement; @layer <name> blocks; flat style rules over the selector vocabulary above; @media with the enumerated feature set; @page (+ pseudo pages, margin boxes); @font-face.

Foreign CSS is preserved verbatim — an unrecognized rule or at-rule round-trips byte-identically and surfaces read-only in the editor. Nothing is ever dropped for being unknown; validation warns. The only errors are the security bans:

  • @import — never.
  • url() — only inside @font-face { src: … }, and only as a relative asset reference or a data: URI (fonts are first-class assets: resolved through the host’s asset store, embedded by the EPUB encoder). Everywhere else url() stays banned, so media can never enter via CSS.
  • expression(), javascript:, and < / > anywhere (a literal </style would terminate the HTML element regardless of CSS strings). Braces are fine inside quoted strings — templates like "{n}." are legitimate.

The old curated property catalog survives as the known vocabulary driving the format editor’s forms and validation’s warnings — it is no longer an allowlist that rejects.

The known font vocabulary includes the CSS Fonts variant longhands for numeric forms, ligatures, emoji presentation, East Asian glyph forms, and capital forms (font-variant-numeric, -ligatures, -emoji, -east-asian, and -caps). Their initial value is normal. Numeric, ligature, and East Asian longhands accept compatible space-separated keywords; emoji and caps take one mutually exclusive value.

Theming

Each theme role is declared once with light-dark() under :root { color-scheme: light dark } — a bare file follows the OS scheme; the app’s light/dark toggle flips color-scheme via html[data-spub-scheme]. The machine-known roles are paper · surface · ink · ink-soft · ink-dim · rule · accent · accent-dim · accent-bright · mono (extra custom properties are legal foreign CSS).

sp-base also declares one user-assignable font token for every CSS generic family: serif · sans-serif · monospace · cursive · fantasy · system-ui · ui-serif · ui-sans-serif · ui-monospace · ui-rounded · math · fangsong. A format references the corresponding token (for example font-family: var(--font-serif)), and the reader can choose the preferred fallback stack for that family without touching the document.