clay.js
  1. ClayJS
  2. Reference
  3. Setup
  4. Theming ClayJS's UI

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.

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

GroupNames
Controlsbutton, button.default, button.primary, button.quiet, button.danger, buttonLabel, input, text, mutedText, icon, well, close
Dialogsdialog. + root, overlay, panel, header, title, body, footer, close, closeIcon, content, input, hint, actions, button, buttonLabel, well, code, copyButton
Name promptpeople. + any dialog suffix, and people.choices, people.choice, people.choiceLabel
Crop, toasts, noticesReserved: 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#

SurfaceFileStatusNotes
Lost edit and refused-save noticesrc/core/conflict-notice.jsBevel controlsBottom-anchored bar, panel and toast.
Stale host warningsrc/core/stale-host-notice.jsBevel controlsKeeps view-mode and stale-host gating, and its dismiss.
Presence avatars, count, tooltipsrc/sync/presence.jsBevel controlsParticipant colours keep their meaning; faces are square, in the mono face.
Save indicator chipsrc/plugins/indicator.jsBevel controlsError 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.
Toastssrc/ui/toast.jsBevel controlsKeeps caller options and timing; the dashboard’s toast style; markers are data-clay-toasts and data-clay-toast.
Modal shellsrc/ui/modal.js, src/ui/bevel-dialog.jsBevel controlsKeeps 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 dialogssrc/ui/dialogs.jsBevel controlsKeeps promise and callback behaviour.
AI edit chromesrc/plugins/ai-edit.jsBevel controlsRing, panel, chip, bubble and the bottom status bar only; the contenteditable focus rule is unchanged.
RichClay toolbar, menus, floating toolbar, link dialogsrc/plugins/richclay.js, src/ui/skins/richclay.jsvendor skinEdited prose is never restyled. RichClay has no image toolbar; Squire’s resize handles sit inside the prose.
Quickcrop framesrc/plugins/quickcrop.jsBevel controlsFrames the vendored cropper; crop geometry untouched.
Quickcrop stagesrc/plugins/quickcrop.js, src/ui/skins/quickcrop.jsvendor skinCrop geometry untouched.
CMS shell and controlssrc/plugins/cms.js, src/ui/skins/cms.jsvendor skin, pinsExisting tokens map to the generated values, in the Bevel mono face.
Sortable drag decorationsrc/plugins/sortable.jsnothing to drawClayJS adds no decoration: the ghost is a clone of the authored item and Sortable’s classes are the page’s to style.