# Design note — the theme editor

**Dated 2026-08-04. Written before the code, per the provenance practice in
[ROADMAP.md](../../ROADMAP.md#proving-independence).**

The second of the three high-priority requests. This one is large enough that
the note starts by saying what is not in it.

---

## Scope, up front

The roadmap asks for six things. Four land here:

1. ✅ Multiple saved themes; switch the active one; per-member choice
2. ✅ A design-token surface — colour, typography, radius, shadow
3. ✅ Custom CSS with validation, and custom `<head>` HTML
4. ⏸ **Template override editor with diff-against-default and safe revert**
5. ✅ Import and export a theme as a portable file
6. ✅ Revision history so a bad edit is recoverable — with a **preview**
   rather than a live preview pane

**Template overrides are deliberately not in this pass.** A `.cvr` template
compiles to PHP and can contain `@php`, so a template override editor is an
"execute arbitrary PHP from a web form" feature wearing a friendly hat. That
may still be the right answer — an administrator can already install an
extension, which is the same power — but it deserves its own decision and its
own note, not a paragraph inside this one. Doing it badly is the single worst
thing this project could ship.

**Live preview is a preview, not a pane.** A side-by-side iframe re-rendering
on every keystroke is a lot of machinery for a question that "open the site
with this theme applied" answers completely. The editor renders the real
front-end under the unsaved theme, in a new tab, from a signed preview token.
Same answer, a fraction of the moving parts, and it shows real content rather
than a mock.

## The job to be done

Somebody wants their forum to look like theirs, without editing CSS files on a
server they may not have access to. They want to try something, see it, and put
it back if it was worse.

## What is there now

One accent colour, a corner radius and a light/dark mode, stored as three rows
in `settings` and turned into CSS custom properties by `Support/Theme.php`,
emitted server-side into `<head>` so a dark-mode visitor never sees a white
flash. That last part is the good bit and is kept exactly as it is.

The limits: one theme, three knobs, and no way back from a bad choice except
remembering what it was.

## Decision 1 — a theme is a row, and the tokens are a JSON column

`cv_themes`: key, name, an `is_active` flag, a `tokens` JSON column, custom CSS,
custom head HTML.

A column per token would mean a migration every time a token is added, and
tokens are exactly the thing that grows. JSON costs the ability to query by
token, which nothing wants to do.

**Every token is validated on read, not trusted from the column.** The JSON is
a bag of strings that lands inside a `<style>` block; a hex that is not a hex is
a way to close the block early and open a `<script>`. `Support\Theme` already
takes this position for the accent colour and it simply extends to the rest:
anything that does not match its expected shape falls back to the default
rather than being escaped.

## Decision 2 — the token surface is what the stylesheet actually reads

An editor offering knobs that do nothing is worse than one offering fewer.
The tokens are therefore drawn from the variables `convoro.css` already
consumes, plus two that are being introduced with the CSS to use them:

| Group | Tokens |
|---|---|
| Colour | accent, and the neutral ramp per mode — background, surface, hover, text, muted text, border |
| Semantic | success, danger, warning |
| Typography | body font stack, heading font stack, **base size** |
| Shape | corner radius, **shadow strength** |

Hover and tint shades stay derived from the accent rather than being separate
knobs. Deriving them is why picking one colour gives a coherent result, and
exposing them would mostly be a way to make a theme look broken.

**Type scale and spacing are one control, honestly labelled.** Nearly every
size in the stylesheet is in `rem`, so setting the root font size scales type
*and* spacing together, proportionally. That is a real, working control that
costs one line. Splitting them would mean rewriting 2,400 lines of CSS to
reference spacing variables, which is a much larger job than this feature and
should not hide inside it. So the control says what it does: it scales the
interface.

## Decision 3 — custom CSS is validated; custom head HTML is not, and says so

**Custom CSS** is checked for the things that turn a stylesheet into an
exploit: `</style>` closing the block early, `javascript:` and `expression()`,
and `@import` pulling in a stylesheet from somewhere else. The last is not an
injection so much as a privacy leak and a page-load cliff, and a site owner who
truly wants it can link it in the head HTML where the trust is explicit.

**Custom head HTML is arbitrary markup, including `<script>`**. There is no
useful way to validate it — the whole reason the field exists is analytics
snippets and verification tags, which are script tags. So it is not validated,
and the editor says exactly that next to the field, in the same plain language
the extension uploader uses. An administrator can already install an extension;
this is the same power, and pretending otherwise would be the dishonest part.

## Decision 4 — per-member choice is a column, and it degrades

`users.theme_id`, nullable. Null means the site's active theme, which is also
what every guest gets. A theme that is deleted leaves rows pointing at nothing,
so the resolver treats a missing theme as null rather than failing — losing your
theme choice is a small annoyance, a site that will not render is not.

Light/dark stays a separate axis, in the cookie it already uses. Mode is not a
theme: somebody who prefers dark wants dark in *whichever* theme is active.

## Decision 5 — every save is a revision, and restoring makes another

`cv_theme_revisions` holds a snapshot of the whole theme before each change,
with who made it and when. Restoring writes the old snapshot forward as a new
revision rather than rewinding the history — so the act of restoring is itself
undoable, and the record of what happened stays true.

Revisions are capped per theme, oldest pruned. A history nobody trims is a
table that only grows.

## Decision 6 — import and export is the same JSON, in a file

`.cvt`: the tokens, custom CSS and head HTML, plus a format version and the
name. Not a zip, because unlike a `.zip` there is nothing but data — a zip
would be a container around one JSON file.

**An imported theme is never activated by the import.** It arrives as a new,
inactive theme that somebody then chooses. An import that silently changed how
the site looked would be the same mistake as an upgrade re-enabling an
extension somebody had switched off.

Import validates through exactly the same path as everything else, so a
hand-edited `.cvt` full of nonsense produces a theme with defaults rather than a
broken page.

## Decision 7 — preview is a signed token, and it does not touch the site

Previewing an unsaved theme means rendering the front end with tokens that are
not in the database. The editor posts the draft, gets back a short-lived
one-use token, and opens `/?theme_preview=<token>` in a new tab. The token
resolves to the draft held in the previewer's own session.

- **It is per-session**, so nobody else can be shown a preview, and a leaked
  URL renders nothing.
- **It expires**, because a preview that outlives the tab it was opened from is
  a way to be confused later.
- **It never writes**, so previewing something terrible costs nothing.

## What this does not do

- **No template overrides.** See the top of this note.
- **No per-forum or per-page themes.** Nobody asked, and it multiplies every
  question here by the number of forums.
- **No CSS pre-processor.** Custom CSS is CSS.
- **No theme marketplace.** Import is a file, same as an extension.
