super-productivity/docs/theming-contract.md
Johannes Millan 0ec8c207a8 feat(theme): add Plainspace built-in theme
A warm paper theme ported from plainspace.org: cream surfaces, a
terracotta primary and a teal accent, in light and dark. Registered in
BUILT_IN_THEMES (requiredMode: system) and listed in the theming docs.

Surfaces/ink primitives drive the semantic tokens; the primary/accent
palette ramps recolor the brand. Dark-mode Category-B tokens (subtasks,
notes, selected rows, schedule events) are re-derived from the warm
surface ladder so nothing leaks the base cold-grey ramp.

Signature paper touches:
- squared graph-paper backdrop (reuses the body::before layer)
- hand-drawn terracotta underlines on headings and collapsible headers
- a hand-drawn rule under each board column header in place of a panel box
- warm soft paper shadows (--card-shadow split per mode) and tighter
  3/6/8px radii

The ambient primary-tinted gradient wash is removed (repurposed to the
grid); remaining gradients are contextual and left untouched.
2026-06-15 14:06:21 +02:00

198 lines
11 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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…":
```css
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. |
## Recommended tokens
| 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.
```css
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:
```css
: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,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
```css
body {
--surface-1: #fef9f3;
--surface-2: #ffffff;
--ink: #2c1810;
--ink-on-channel: 44, 24, 16;
}
```
### Tuning state alphas
```css
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
```css
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.