# Appearance, for everyone

2026-08-10. Ernest:

> Add a front end theme editor where you can change fonts, logo, favicon,
> colors, display style (grid, feed, etc) post layouts etc. Have it sit in the
> user menu like what's going to be renamed Page Builder but this one will show
> to everyone so anyone can make the site look how they want it to.

---

## 🚨 Whose site does it change

"Anyone can make the site look how they want it to" reads two ways: everybody
personalising their own view, or administrators redecorating for everybody from
the front end instead of the admin panel.

**Built as the first, with the second as a button.** A member's changes are
their own view; an administrator additionally gets **Save for everybody**,
which writes to the active site theme. That covers both readings and nothing is
wasted either way — and it is the safe direction to be wrong in. If a control
that changes only your own view turns out to have been meant for the whole
site, an administrator presses one more button. The other way round, somebody
redecorates a live community by accident.

## It is a theme, because a theme is what this already is

`themes.user_id` — null is a site theme, non-null belongs to that person.

`users.theme_id` already existed, `Themes::forViewer()` already resolved it, and
`Tokens` already validated every value. So a personal theme is not a new
concept; it is an existing one with an owner. A parallel `member_themes` table
would have needed its own tokens column, its own validation and its own branch
in every read — and the second copy of the validation is the one that would
have been wrong.

Three rules fall out, and each is enforced rather than assumed:

- **A personal theme never appears in anybody else's list.** `Themes::all()`
  excludes them, which is what the admin screen and the member's theme picker
  both read. One member's colours showing up in everybody's picker would be a
  surprise and a way to find out who has been fiddling.
- **A personal theme can never be activated.** `activate()` refuses one, so
  "make this the site theme" cannot be pointed at somebody's private row.
- **One per person**, enforced in `savePersonal()` rather than by a unique
  index — see the migration for why a constraint would forbid the next feature.

## What is not in it, and why

- **Logo and favicon are site-level.** They are the site's identity, not a
  personal preference, and `BrandStore` already owns them in Admin → Settings.
  The page says so and links there rather than showing a control that would
  only work for one person in the building.
- **Grid or list is already a per-person switch** on the forum index, and it
  writes a cookie rather than a token. Duplicating it here would give one
  setting two homes that could disagree. The page points at it.
- **🚨 Post layouts are not built.** There is no token for them — the post is
  laid out by `convoro.css` directly, so offering a control would mean
  inventing the layouts first, and "the post has three arrangements" is a
  design decision with real work behind it, not a select box. Left out
  deliberately rather than half-built, which is the same call template
  overrides and custom databases got.

## Fonts stay a named list

`Tokens::FONTS` is a fixed set of stacks and this editor does not change that.
A free-text font family is a CSS injection point in exchange for a feature —
web font loading — that does not exist. Every value posted here goes through
`Tokens::from()`, which validates each one and falls back to its default,
because these are written into a `<style>` block and there is no escaping
inside a CSS value that makes an arbitrary string safe.

## The screenshot tool can sign in now

Unrelated to the feature and the reason it got built: `tests/shoot.js` shot
every page signed out, which the handoff already names as why three real bugs
survived a week of shooting — the report dialog only renders for members. This
page is behind the account menu and could not be looked at at all. It takes
`SHOOT_EMAIL` and `SHOOT_PASSWORD` now, and says out loud when the sign-in
failed rather than quietly producing a picture of the login page.
