super-productivity/docs/theming-contract.md
Johannes Millan a9932f9f71
fix(theme): polish, atomic activation, and validator hardening (#9157)
* docs(agents): require reviews to ask if a feature earns its place

* build: add output folder to gitignore because of playwright writing there

* style(themes): polish built-in visual states

* fix(theme): make stylesheet activation atomic

* fix(theme): harden custom theme contracts

* fix(theme): reject unterminated url() and exempt commented keywords

Two validator correctness fixes found in review of the theme-polish branch:

- Reject any url()/src() that runs to end-of-input without a closing ')'.
  The CSS tokenizer still emits a fetchable url-token at EOF (Syntax
  §4.3.6), so a theme file ending mid-url (e.g. `background:url(http://x`)
  beaconed on every load — the argument regex requires the ')' and missed
  it. Proven to fetch in Chromium.

- Move the @import / image() / image-set() keyword-presence bans onto the
  comment-stripped view. Scanning the unstripped source rejected benign
  themes that merely mention these tokens in a comment; because stored
  themes are re-validated on every load, such a theme silently reverted to
  Default after updating. url()/src() stay on the unstripped view so a
  disguised token can never hide a later live fetch.

Docs and specs updated (+5 cases: EOF-fetch payloads, line reporting,
comment exemption, and real-rule-next-to-comment still rejects).

* fix(theme): restore plainspace board-panel header underline

The token-model pass removed the data:-URL `--underline-hand` mask and
migrated its consumers to `clip-path: var(--underline-hand-shape)`, but the
board-panel `header::after` consumer was missed. Its `mask: var(--underline-
hand)` then resolved to an undefined variable, collapsing the hand-drawn
ribbon to a solid 10px bar. Convert it to the shared clip-path polygon like
the other two consumers; the percentage-based polygon scales to the box.
2026-07-18 18:49:00 +02:00

12 KiB
Raw Blame History

Theming Contract

Public contract for authoring custom themes for Super Productivity. This document is authoritative — the validator's warning pass keys off the same contract (src/app/core/theme/theme-contract.const.ts).

TL;DR

Drop a CSS file with at minimum these four declarations into Settings → Theme → "Install theme…":

body {
  --surface-1: #f8f8f7;
  --surface-2: #fff;
  --ink: rgb(44, 44, 44);
  --ink-on-channel: 0, 0, 0;
}

For a polished theme, declare the recommended tokens too (see table below). Themes are pure CSS — no scripts, no remote URLs, no bundled assets.

How theming works

The CSS variable architecture has three layers:

  1. Primitives — surface ladder (--surface-0 through --surface-4), ink (--ink, --ink-strong, --ink-muted, --ink-on-channel), --separator, --divider, --scrim, --bg-overlay, --brand, --focus-ring. These are the knobs themes turn to feel different.
  2. Semantic aliases — high-level tokens like --bg, --card-bg, --text-color. Most of them resolve to a primitive, so changing one primitive ripples through dozens of semantic tokens automatically.
  3. Category-B tokens — true light/dark splits whose relationship genuinely differs between modes (e.g. --close-btn-bg, --scrollbar-thumb). Themes that want to override these must declare both light and dark values.

Every theme builds on top of the base. If your CSS doesn't declare a token, the base value applies.

Required tokens

Token What it controls Notes
--surface-1 App background Base of the surface ladder.
--surface-2 Card / task / panel background One step up from --surface-1.
--ink Body text color Most text uses this directly.
--ink-on-channel RGB triplet (no rgb() wrapper) for overlay tokens E.g. 0, 0, 0 for light, 255, 255, 255 for dark. Used as rgba(var(--ink-on-channel), α) to make hover/focus overlays mode-correct from a single declaration.
Token What it controls
--surface-0 Slightly darker than --surface-1 (used for --bg-darker on toolbars).
--surface-3 Elevated surface (current task, drag-drop targets).
--surface-4 Highest surface (banner, mobile bottom panel).
--ink-strong Maximum-contrast text (used for emphasized labels).
--ink-muted Muted text (helper labels, placeholders).
--separator Soft separator color (between rows).
--divider Default divider color (used by Material).
--scrim Backdrop / overlay scrim color.

If any of these are missing, the validator emits a warning listing the token names and surfaces a snackbar after install. The theme still installs — the warning is informational.

Optional tokens

Token What it controls Default
--state-hover-alpha Hover overlay opacity 0.06
--state-focus-alpha Focus overlay opacity 0.10
--state-pressed-alpha Active/pressed overlay opacity 0.14
--state-selected-alpha Selected-row overlay opacity 0.10
--state-disabled-alpha Disabled element opacity 0.40
--focus-ring Focus-ring color (defaults to --brand). var(--brand)
--system-surface Native Android system-bar backdrop. var(--bg)

These are alpha scalars (or single colors), not rgba colors. The base composes them with --ink-on-channel to produce the actual overlay color, so a theme tuning --state-hover-alpha to 0.10 automatically gets a stronger hover in both light and dark modes.

--system-surface must resolve to an opaque #rgb, #rrggbb, or integer-channel rgb(...) color without alpha. Transparent values, percentage channels, and gradients fall back to the Default-theme surface because Android's native color parser cannot use them.

Special tokens

--ink-on-channel

This is the keystone primitive. It's an RGB triplet — not an rgb() value, not a hex literal — so it can be slotted into rgba(var(--ink-on-channel), 0.06) to produce mode-correct overlays from a single declaration.

body {
  --ink-on-channel: 0, 0, 0; /* light mode → black overlays */
}
body.isDarkTheme {
  --ink-on-channel: 255, 255, 255; /* dark mode → white overlays */
}

--state-*-alpha and the legacy bridge

Older themes historically declared --hover-bg-opacity, --focus-bg-opacity, --pressed-bg-opacity, and --disabled-opacity directly. The base declares the canonical names with those legacy names as var() fallbacks:

:where(body, body.isDarkTheme) {
  --state-hover-alpha: var(--hover-bg-opacity, 0.06);
  --state-focus-alpha: var(--focus-bg-opacity, 0.1);
  --state-pressed-alpha: var(--pressed-bg-opacity, 0.14);
  --state-selected-alpha: var(--selected-bg-opacity, 0.1);
  --state-disabled-alpha: var(--disabled-opacity, 0.4);
}

If your theme already uses the legacy names, they continue to work — you do not need to rename. New themes should prefer the --state-*-alpha names.

Selector contract

This part is load-bearing. Read it before debugging "my theme works in light mode but not dark."

Layer Where it lives Specificity
Primitives (e.g. --surface-1, --ink-on-channel) body (light), body.isDarkTheme (dark) (0,0,1) and (0,1,1)
Semantic aliases (e.g. --bg, --card-bg) :where(body, body.isDarkTheme) (0,0,0) — :where() is the zero-specificity wrapper
Category-B tokens (per-mode) body (light), body.isDarkTheme (dark) (0,0,1) and (0,1,1)

Themes overriding primitives MUST use body and/or body.isDarkTheme selectors. A declaration at :root is inherited by body, but the base declares the same property directly on body. A direct declaration always wins over an inherited value; selector specificity is never compared across those two elements. A :root-only primitive therefore has no effect on the body in either mode.

Always declare light primitives under body and dark primitives under body.isDarkTheme.

Themes overriding semantic aliases should use the same body selectors. Aliases live at :where(...) (specificity 0,0,0), so a later body or body.isDarkTheme rule wins normally. A :root alias remains inherited and cannot replace an alias declared directly on the body.

The validator's warning pass is presence-only in v1: it does not parse selectors. A theme that declares --surface-1 only at :root will pass validation even though that declaration is ineffective on the body. Selector-aware warnings are a tracked follow-up.

Forking instructions

  1. Pick the closest shipped theme as a starting point: src/assets/themes/{arc,catppuccin-mocha,cybr,dark-base,dracula,everforest,glass,lines,liquid-glass,nord-polar-night,nord-snow-storm,plainspace,rainbow,velvet,zen}.css.
  2. Copy it to a new file. Rename .css to whatever you want — the picker uses the filename slug as the theme id.
  3. Edit the primitive declarations under body and body.isDarkTheme. Start with --surface-1, --surface-2, --ink, --ink-on-channel. Leave everything else default.
  4. Drop the file into Settings → Theme → "Install theme…". The file lives in IndexedDB; nothing leaves your machine.

Examples

Minimal six-line theme

body {
  --surface-1: #fef9f3;
  --surface-2: #ffffff;
  --ink: #2c1810;
  --ink-on-channel: 44, 24, 16;
}

Tuning state alphas

body {
  --surface-1: #f8f8f7;
  --surface-2: #fff;
  --ink: rgb(44, 44, 44);
  --ink-on-channel: 0, 0, 0;
  /* Subtler hover, more dramatic pressed */
  --state-hover-alpha: 0.04;
  --state-pressed-alpha: 0.18;
}

Light + dark pair

body {
  --surface-1: #fef9f3;
  --surface-2: #fff;
  --ink: #2c1810;
  --ink-on-channel: 0, 0, 0;
  --separator: #e0d6c8;
  --divider: rgba(0, 0, 0, 0.12);
}
body.isDarkTheme {
  --surface-1: #1a1410;
  --surface-2: #2c1810;
  --ink: rgb(245, 230, 215);
  --ink-on-channel: 255, 255, 255;
  --separator: rgba(255, 255, 255, 0.1);
  --divider: rgba(255, 255, 255, 0.12);
}

Validation rules

The validator (src/app/core/theme/validate-theme-css.util.ts) runs at install time. Warnings are persisted alongside the theme in IndexedDB so the picker can display them without another read. Stored CSS is also re-validated before every load; a theme accepted by an older client therefore cannot bypass newer safety rules. Contract warnings remain the snapshot from installation until the user re-uploads the file.

Hard rejects (theme will not install):

  • url(...) arguments that resolve to a remote URL (http:, https:, //host/..., data: URIs, schemeless absolute, or any other protocol)
  • Relative url(...) paths (no bundled assets in v1)
  • src(...) arguments (CSS Fonts L4 form) — same rules as url(...)
  • Any @import rule
  • Advanced image functions: any image(...) or image-set(...)
  • Files larger than 500 KB
  • Unterminated /* comments (malformed CSS)

Soft warnings (theme installs, snackbar shown):

  • Any required or recommended token missing — the snackbar lists token names. Optional tokens are not warned about (they always inherit from the base layer).

The validator handles \xx-escape attempts on keywords (u\72l(, \55RL(, s\72\63(, --surf\61ce-1, etc.) and /* */ injection inside string literals or url-tokens — see validate-theme-css.util.spec.ts for the full attack-surface test list.

Security keywords are matched conservatively. url( and src( are scanned on the raw (decoded) source, so they are rejected even inside a comment or CSS string — a disguised token must never be able to hide a later live fetch. The keyword-presence bans (@import, image(, image-set() are scanned on the comment-stripped source instead: they are allowed inside comments (a theme may document the restriction) but still rejected inside CSS string values, since blanking strings safely is not possible after escape decoding. Avoid these literal sequences in theme string values and generated labels.

Legacy migration note

If you already have a theme that worked before the token-model refactor, nothing is required. The validator's warning pass is non-blocking, and the 15 built-in CSS themes provide examples that satisfy the minimum contract. If your theme used the legacy names (--hover-bg-opacity, --focus-bg-opacity, --pressed-bg-opacity, --disabled-opacity), they continue to work through the var() fallback bridge in the base.

If you want the contract warnings to be quiet, declare the four required tokens (--surface-1, --surface-2, --ink, --ink-on-channel) under body (and body.isDarkTheme if your theme has a dark mode). The recommended tokens are nice-to-have but not required.