# Theming ClayJS's UI

> How the UI ClayJS injects into a hostile page keeps its styling, where its values come from, and which surfaces use them.

Source: https://clayjs.com/docs/theming/

ClayJS draws some UI of its own onto pages whose stylesheets it does not control: the conflict and refused-save notice, toasts, dialogs, the save indicator. That UI has to look the same everywhere, on a page whose CSS is free to fight back. This page explains how its styling holds, where its values come from, and which surfaces use it.

## Why every declaration is inline and `!important`

A page that restyles every `button` with `!important` is ordinary, and a plain inline style loses to it. Only an inline `!important` outranks an author `!important`, so every declaration ClayJS puts on its own UI goes on inline with `!important` (`src/lib/hostile-css.js`). The first browser run of the conflict notice had both of its buttons repainted in the host page's colours and font, which is the reason this is not negotiable.

Each injected element additionally:

- starts from `all:initial`, then restores `color-scheme`, `direction` and `unicode-bidi`, which that reset also wipes;
- carries `clay="no-save no-watch no-snapshot"`, so it never reaches the saved file and never triggers a save;
- uses no classes and no ids, so no author selector can single it out.

## Where the values come from

`src/ui/bevel.js` is generated, not written. Its source is Bevel's `bevel.css`, from the exact-pinned `@panphora/bevel` devDependency, reduced to the recipes in `scripts/bevel-manifest.mjs` by `scripts/build-bevel-subset.mjs`:

- `npm run build:bevel` regenerates `src/ui/bevel.js`.
- `npm run check:bevel` prints whether the checked-in module is in sync, and when the source is absent it validates the checked-in module instead and passes with `source not available, checked-in src/ui/bevel.js used`.
- The generators resolve the package's CSS, icons and integrations, so a clean checkout builds without a sibling Bevel checkout. `--source <path>` still overrides the CSS for experiments.

Every `var(--bevel-*)` is resolved to its literal `light-dark(...)` value before the module is written, so a host rule such as `* { --bevel-ink: red !important }` cannot recolour the UI through custom properties. Fonts are plain system stacks, not webfonts.

The manifest is the closed list of recipes; nothing else in `bevel.css` is copied. The generator refuses, naming the recipe it was working on, when it meets a `var()` it cannot resolve, a rule under `@supports`, `@container` or another conditional at-rule, a nested rule, or two rules for one selector that disagree about a declaration.

Never edit `src/ui/bevel.js` by hand: the next build overwrites it. To add a recipe, add an entry to `scripts/bevel-manifest.mjs`, run `npm run build:bevel`, and add a test to `tests/unit/bevel-subset.test.js`.

## Page themes

A page can restyle the UI ClayJS draws without weakening the rule above. `src/ui/theme-config.js` holds the configuration (`window.clayTheme`, `clay.theme()`), and `resolveTheme()` in `src/ui/theme.js` captures one surface's theme when that surface is built; `src/ui/theme-parts.js` turns it into declarations that the controls append after their Bevel recipe, still inline and `!important`.

Each value resolves in this order: explicit `tokens`, then `--clay-ui-*` custom properties read from `body`, then a sample of the page (`body`'s font family, and an opaque background with its text colour from `body` or the root; nothing from a translucent or image-backed background, a colour it cannot read, or a text colour with less than 3:1 contrast against it; a font list made only of the browser's default serif counts as no font), then the Bevel declaration. An absent token keeps Bevel's declaration for that part, so a theme can change one thing.

When the text and surface colours are known and no `accent` is given, the primary button takes the text colour, with the surface colour on it.

Order inside a control, every time it repaints: reset and Bevel recipe, the control's own layout, theme tokens for its role and state, then the page's parts (general to specific, each `base` then `hover`, `active`, `focus`, `disabled`), then what behaviour owns: forced colours, the 44px floor on narrow screens, and pinned values such as visibility.

Part names (`data-clay-part` on each element):

| Group | Names |
|---|---|
| Controls | `button`, `button.default`, `button.primary`, `button.quiet`, `button.danger`, `buttonLabel`, `input`, `text`, `mutedText`, `icon`, `well`, `close` |
| Dialogs | `dialog.` + `root`, `overlay`, `panel`, `header`, `title`, `body`, `footer`, `close`, `closeIcon`, `content`, `input`, `hint`, `actions`, `button`, `buttonLabel`, `well`, `code`, `copyButton` |
| Name prompt | `people.` + any dialog suffix, and `people.choices`, `people.choice`, `people.choiceLabel` |
| Crop, toasts, notices | Reserved: `crop.*`, `toast.*`, `notice.*`, `conflict.*`, `section.*`, `staleHost.*` are accepted but not drawn with a theme yet |

The colour scheme of a themed surface is the root's declared `color-scheme`, else the page's `<meta name="color-scheme">`, else light or dark from the surface colour, else `"light dark"`.

`clay.theme(false)` (or `window.clayTheme = false`) bypasses all of this: every control writes exactly its Bevel declarations, as before theming existed.

## Configuration is synchronous, painting is not

`src/ui/theme-config.js` is the whole configuration surface, and it is the only theme module the loader reads at boot. It imports nothing, so `clay.theme()` answers or throws before anything has been painted: no stylesheet, element or module has to arrive first. `clay.theme()` returns a copy of the current theme, `clay.theme(options)` replaces it and returns the new one, and invalid input throws on the spot.

The modules that paint a theme arrive with the surface that needs them, not with the loader: `src/ui/theme.js` and the Bevel controls it builds with (`src/ui/bevel-controls.js`) come in through the dialogs and notices that paint with them. A Document's reader paints none of those, so it starts with `theme-config.js` and none of the theme UI, while `clay.theme` still answers. The conflict notice fetches its own surface and theme the first time it has something to show.

## Controls

`src/ui/bevel-controls.js` builds the elements.

- `RUNTIME_ONLY` is the `clay` attribute value every control carries (`no-save no-watch no-snapshot`).
- `bevelButton(label, { variant, small, onClick, extra, labelExtra, onState })` returns a `<button>` wrapping a label `<span>`. `variant` is `default`, `primary`, `quiet` or `danger`; `small` shrinks type and padding; `onClick` runs on click while the button is not disabled; `onState(flags)` runs after every rebuild with a copy of the hover, press, focus-visible and disabled flags, for a button whose children follow its state. The element also carries `setLabel(text)`, `setDisabled(bool)`, `isDisabled()` and `pin(props)`: declarations that must outlive every rebuild, such as where a floating button sits or whether it shows.
- `bevelIconButton(svg, { label, onClick })` is a small square button whose label span holds the given SVG markup. `label` becomes the `aria-label` and the `title`.
- `bevelSurface(tag, rules)` is a Bevel surface: background, ink, border and shadow, on any tag, for panels and popovers.
- `bevelWell(rules)` is a recessed `<div>`, for the lighter container inside a surface.
- `bevelText(tag, rules, text)` is a text element that inherits the surface's font and ink; `text` is set when it is not `null`.
- `pageScheme()` is the scheme a UI root gives its subtree: the page's own when it declares one, else `light dark`.
- `bevelInput(tag, { rules })` is a text field (`input` or `textarea`) in Bevel's input material, with hover and focus kept in flags like a button's. `paintInput(el, { rules })` does the same to a field that already exists. A textarea keeps the height a person dragged it to.
- `bevelBox(tag, rules)` is a plain box: the reset, then only the given rules, for rows, stacks and backdrops that carry no material of their own.
- `bevelCornerClose({ label, onClick })` is Bevel's overlay close: a 68px corner cut on the diagonal with the pixel X, lifting on hover and filling brass on keyboard focus.
- `setShown(el, shown, display)` shows or hides through both the `hidden` attribute and an `!important` display, because `hidden` alone loses to an inline display. On a Bevel button it goes through `pin`, so the next rebuild keeps it.
- `protectIcon(svg, size)` restates an icon's own presentation attributes as inline `!important` declarations, so the page cannot hide, resize or repaint it, and pins a path's geometry through the `d` property as well.

`src/ui/bevel-dialog.js` builds the dialog frame in the dashboard's style: `bevelDialog({ zIndex, width, closable, titled })` returns the root (`data-clay-modal`), the backdrop, the `<form>` panel, the header and its heading (when titled), the body, the footer and the close. It holds no behaviour; the modal and the crop adapter keep their own focus, Escape and settling rules.

Inline styles have no `:hover`, `:active` or `:focus-visible`, so a button keeps hover, press, focus-visible and disabled in flags and rebuilds its whole inline style from them on every change. A property one state set can therefore never survive into the next. Style a button only through `extra` and `labelExtra`: anything set on it afterwards is rebuilt away. Disabled wins over hover and press, focus-visible is decided from `:focus-visible` plus the keyboard modality of the last input, reduced motion replaces the pressed label offset, and `prefers-reduced-motion` and `forced-colors` are read at build time for that element.

The UI root sets `color-scheme` to the page's scheme; descendants inherit it, so `light-dark()` values resolve to the scheme the person is actually looking at.

## Testing note

jsdom drops `light-dark()` and `color-mix()` values, and the `translate` property, from `el.style`. Tests assert those through a `CSSStyleDeclaration.prototype.setProperty` spy (see `tests/unit/bevel-controls.test.js`). jsdom has no cascade, so the real question, whether these declarations beat a hostile stylesheet, needs a browser.

## Vendor skins

RichClay, Quickcrop and the CMS draw their own UI with their own classes, and ClayJS never edits a vendor. Each gets a skin instead: `scripts/build-vendor-skins.mjs` reads the CSS the vendored bundle ships, plus Bevel's integration file where there is one, and writes `src/ui/skins/<name>.js`, one `@layer clay-skin { ... }` block in which every declaration is `!important`. `src/ui/vendor-skin.js` puts it first in `<head>`, runtime-only, when the plugin loads. Important declarations in the earliest cascade layer beat a page's unlayered `!important` rules.

- The vendor's custom properties are pinned to Bevel's literal values on its roots and every descendant.
- RichClay and Quickcrop also get Bevel's integration rules and their own chrome rules, re-emitted at `!important`. The CMS gets pins and the Bevel mono face only: its theme sits in cascade layers on purpose, so a page's utilities can restyle it.
- The CMS's "Edit content" toggle also gets Bevel's button face, ink and hover through HyperCMS's documented --hcms-toggle-bg and --hcms-toggle-color hooks, from the hand-written src/ui/skins/cms-toggle.js.
- Every selector in a source is listed in `scripts/skin-manifest.mjs` or the build fails, and edited prose is never in a skin.
- `npm run build:skins` regenerates; `npm run check:skins` fails when a skin is stale (it validates the checked-in modules when the Bevel source is unavailable).

## The page theme ships compiled

A Document's own stylesheet is not assembled in the browser. `src/page/themes/light-1.css` is an `@import` chain in the repository, and the release serves the compiled result as one stylesheet: no `@import`, no second request for a font sheet, and no Google Fonts. Its fonts (Inter, Newsreader, Work Sans and IBM Plex) sit in the release's `fonts/` folder, and the build fails if a theme names a family it cannot serve from there.

The compiled theme and every font file are listed in the release manifest (`assets`, with a hash and a size each), so a local or hosted copy serves the same appearance from the same inventory, offline included.

## Surfaces

| Surface | File | Status | Notes |
|---|---|---|---|
| Lost edit and refused-save notice | `src/core/conflict-notice.js` | Bevel controls | Bottom-anchored bar, panel and toast. |
| Stale host warning | `src/core/stale-host-notice.js` | Bevel controls | Keeps view-mode and stale-host gating, and its dismiss. |
| Presence avatars, count, tooltip | `src/sync/presence.js` | Bevel controls | Participant colours keep their meaning; faces are square, in the mono face. |
| Save indicator chip | `src/plugins/indicator.js` | Bevel controls | Error and offline wear the ox tone, unless clay-ui's toasts are loaded to carry them. With clay-ui's clay.saveToast = true, saved is a toast instead. |
| Toasts | `src/ui/toast.js` | Bevel controls | Keeps caller options and timing; the dashboard's toast style; markers are `data-clay-toasts` and `data-clay-toast`. |
| Modal shell | `src/ui/modal.js`, `src/ui/bevel-dialog.js` | Bevel controls | Keeps focus, Escape and settling; `title`, `html`, `yes` and `no` take markup or a node, and caller content is never styled. |
| Ask, confirm, tell and snippet dialogs | `src/ui/dialogs.js` | Bevel controls | Keeps promise and callback behaviour. |
| AI edit chrome | `src/plugins/ai-edit.js` | Bevel controls | Ring, panel, chip, bubble and the bottom status bar only; the contenteditable focus rule is unchanged. |
| RichClay toolbar, menus, floating toolbar, link dialog | `src/plugins/richclay.js`, `src/ui/skins/richclay.js` | vendor skin | Edited prose is never restyled. RichClay has no image toolbar; Squire's resize handles sit inside the prose. |
| Quickcrop frame | `src/plugins/quickcrop.js` | Bevel controls | Frames the vendored cropper; crop geometry untouched. |
| Quickcrop stage | `src/plugins/quickcrop.js`, `src/ui/skins/quickcrop.js` | vendor skin | Crop geometry untouched. |
| CMS shell and controls | `src/plugins/cms.js`, `src/ui/skins/cms.js` | vendor skin, pins | Existing tokens map to the generated values, in the Bevel mono face. |
| Sortable drag decoration | `src/plugins/sortable.js` | nothing to draw | ClayJS adds no decoration: the ghost is a clone of the authored item and Sortable's classes are the page's to style. |
