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.
@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 adata:URI (fonts are first-class assets: resolved through the host’s asset store, embedded by the EPUB encoder). Everywhere elseurl()stays banned, so media can never enter via CSS.expression(),javascript:, and</>anywhere (a literal</stylewould 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.