super-productivity/docs/theming-contract.md
Johannes Millan 8131320220 feat(theme): primitive token layer + theme contract validator
Restructure CSS variables into three layers: ~15 primitives under
body / body.isDarkTheme (surface ramp, ink ramp, ink-on-channel triplet,
separators, scrim, brand, focus-ring), zero-specificity semantic aliases
under :where(body, body.isDarkTheme), and per-mode Cat-B tokens that
genuinely differ between light and dark. Promote interaction-state
tokens (--state-hover, --state-focus, --state-pressed, --state-selected,
--state-disabled-alpha) with velvet legacy-name var() fallbacks in the
base, so all 13 shipped themes are untouched.

Add a non-blocking contract-presence pass to validateThemeCss: missing
required/recommended tokens surface as warnings (presence-only — no
selector parsing), persist on the StoredTheme record, and a SnackService
toast lists them after install. The scanner blanks string-literal and
url-token contents before matching so `content: "--surface-1:"` cannot
suppress a real missing-token warning. Author docs/theming-contract.md
covering the selector contract, special tokens, and a fork-from-existing
example.

Two demo state-token migrations (dialog-user-profile-management,
dialog-restore-point); bulk sweep is a follow-up.
2026-05-08 22:31:00 +02:00

11 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)

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.

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 velvet legacy bridge

Velvet (the shipped accent theme) historically declared --hover-bg-opacity, --focus-bg-opacity, --pressed-bg-opacity, and --disabled-opacity directly. The base now declares the canonical names with the velvet 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 velvet 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. If you declare --surface-1 only at :root (specificity 0,1,0):

  • In light mode → wins over base body (0,1,0 > 0,0,1) ✓
  • In dark mode → loses to base body.isDarkTheme (0,1,0 < 0,1,1) ✗

That's a mode-inconsistent theme. Always declare primitives under body (for light) and body.isDarkTheme (for dark).

Themes overriding semantic aliases can use any selector with non-zero specificity (body, body.isDarkTheme, :root). Aliases live at :where(...) (specificity 0,0,0), so anything beats them.

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 it's mode-inconsistent. 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,nord-polar-night,nord-snow-storm,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 produced at install time are persisted alongside the theme record in IndexedDB and re-surfaced from the stored snapshot — themes are NOT re-validated on cold load. If the contract changes between releases, existing themes' warnings reflect the contract at their install time until the user re-uploads.

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(...)
  • @import "https://..." and @import url(...) with absolute URLs
  • image-set("https://...") and bare image-set(http://... 1x) — same rules
  • 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.

Legacy migration note

If you already have a theme that worked before the token-model refactor: nothing required. The 13 shipped themes are not edited, and the validator's warning pass is non-blocking. If your theme used the velvet legacy names (--hover-bg-opacity, --focus-bg-opacity, --pressed-bg-opacity, --disabled-opacity), they continue to work via 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.