How to theme the editor

Copy Markdown View Source

How to make StatifierBlocks.Editor look like your product without forking it.

The contract is ADR-0005 decision 14 and its 2026-08-28 amendment. This page is the how-to; the record is in docs/adr/0005-liveview-editor.md, and the shipped surface is assets/css/statifier_blocks.css, whose header comment carries the tier of every token.

The rule

A host theme sets --sb-* custom properties. It writes no other declaration.

That is the whole bargain, and it is checked rather than asked for: test/statifier_blocks/theme_audit_test.exs reads the example on this page out of this file and fails the build if it ever declares anything but a --sb-* property.

The rule is what makes the surface honest in both directions. If a restyle you want turns out to need a structural declaration - a padding, a grid-template-columns, a rule against an sb- class - that is a hole in the token surface, not a licence to write the rule. File it; the fix belongs in the stylesheet, and every token in the surface today was found exactly that way by the spike's third theme.

Where to put it

The tokens are declared on the editor's own container, .sb-editor. There are two ways to override them and they differ in reach, not in power:

What it isUse it when
the theme assigna map of property name to value, rendered as an inline style on the canvas rootthe values are computed - a tenant's brand colour out of the database
a stylesheeta rule of your own that sets the properties on .sb-editorthe theme is static, which is the ordinary case

One gotcha, and it is the only one: a declaration on an ancestor of the editor does not work. .sb-editor declares every token on itself, and a declaration on the element beats an inherited value from its parent however specific the parent's selector is. Set the properties on .sb-editor itself, or on a selector that reaches that element:

/* No: the editor's own declarations win over anything inherited. */
[data-theme="library"] { --sb-bg: #1a1f27; }

/* Yes: the same element the package declares its defaults on. */
[data-theme="library"] .sb-editor { --sb-bg: #1a1f27; }

Load your stylesheet after the package's, so a tie in specificity goes your way.

The Map's tokens follow the same rule on a different element. The Map region is not inside .sb-editor, and the package declares its --sb-map-* tokens on .sb-map__region itself, so a host overrides one of them on .sb-map__region, or on a selector that reaches that element; a declaration on an ancestor of the region loses to the package's own:

/* No: the region's own declarations win over anything inherited. */
.myapp-page { --sb-map-edge: #93a0b2; }

/* Yes: the same element the package declares the Map's tokens on. */
.myapp-page .sb-map__region { --sb-map-edge: #93a0b2; }

The three tiers

Not three sets of tokens - three answers to "what am I taking on by setting this?". Every token in the stylesheet's header comment carries its tier.

  1. The palette. Colour, space, shape, type. A couple of dozen lines, and a host that stops here has a themed editor.
  2. The treatments. One specific mark - the drop affordance, the drag seam, the focus ring, a canvas metric, the canvas's dotted ground (--sb-canvas-grid, which carries the whole background shorthand so its colour and its spacing move together), the editor's own height (--sb-editor-height, below), the shaping of a per-type accent - that you can disagree with without overriding a rule.
  3. --sb-color-scheme. Its own tier, because it is not a value this package paints with. See below.

A candidate is not a token. The amendment's 14f lists names the spike needed - the syntax roles, the path underlines, the drag ghost, the run mark, the scroll shadows - and they are deliberately not declared until the rule that reads them ships. A declared token nothing consumes is worse than an absent one: you would set it, nothing would move, and there would be no way to tell that from a bug in your own stylesheet.

--sb-color-scheme is not optional

Half of a control is painted by the browser and reachable by no colour token at all: a <select>'s drop-down, the scrollbar troughs, the text caret, the selection highlight, an input's clear button. A dark theme that restates every colour and omits this one line opens a white menu over a dark editor.

So every theme states it, light or dark. The package reads it as color-scheme: var(--sb-color-scheme) on .sb-editor and nowhere else - telling the host page which scheme it is in would be the editor reaching outside its own box, which is the one thing decision 14 says it does not do.

Bounding the editor's height

By default the editor is as tall as the document in it and your page scrolls. That is the right default for a page whose only content is the editor, and the wrong one for an application shell: on a long document the drawer - the findings and truth-table strip along the bottom - ends up below the fold, and an author scrolls the whole page to reach it.

Set --sb-editor-height to a length and the editor scrolls as a pane instead. The canvas takes the slack, the canvas panel scrolls inside it, and the drawer stays pinned at the bottom of the editor:

.myapp-page .sb-editor { --sb-editor-height: calc(100vh - 4rem); }

Any length works - 40rem, or 100% inside a box your own layout has already sized. The default is auto, so a host that never sets it sees exactly the arrangement it has today. In the bounded mode each pane scrolls in its own box rather than spilling down the page: the canvas, the palette and the inspector all get their own scrollbar when their content is taller than the editor.

This is one of the tokens that has to be a token, and it is the clearest case of the rule at the top of this page. Writing height on .sb-editor yourself is the structural declaration the bargain rules out, and it does not work anyway: it bounds the editor's outer box and stops there, while the grid one level in is still as tall as the document and simply overflows you. Setting the token does both halves - the editor takes the height, and the grid inside it is allowed to be shorter than the tree it holds, which is what hands the scrolling to the panes.

Per-block-type accents

A palette entry may declare accent_token: the name of a --sb-* custom property, never a colour.

%{type: "myapp.check_details", label: "Check details", accent_token: "--sb-accent-myapp-check-details"}

The renderer stamps that name onto the block's card and its palette row as a --sb-block-accent reference with the editor's accent as the fallback arm. Three things follow, and they are why this is worth a key rather than a rule per type in your own CSS:

  • The editor never learns a type name. Two rules read --sb-block-accent - an icon tile and a card stripe - and adding a block type with its own identity adds no CSS at all.
  • The theme decides the value. A descriptor carries a name; what colour that name means is yours, and it can mean different colours in your light and dark themes.
  • It degrades. The name is validated against an anchored pattern before it reaches a style attribute (StatifierBlocks.ViewModel.accent_token/1), so a malformed value falls back to the editor's accent rather than injecting. A well-formed name you forget to define also falls back - silently - so the example below defines every name its registry points at, and the audit has a check for exactly that mistake.

The stripe is drawn on cards whose type declared a token, and on no others. A type that declared nothing keeps the plain card it has always had, so the stripe reads as "this one is yours" rather than as decoration every card carries - painted on all of them it would say nothing, an undeclared type resolving to the editor's own accent. The tile is on every card either way, which is why a type that declares nothing still has a coloured icon.

Group the names to get grouping for free: below, myapp.check_details resolves through --sb-accent-myapp, so the family reads as one, and the theme still pulls myapp.librarian_review out of it with a colour of its own.

Icons are markup, and a theme does not touch them

The package ships a default icon set - StatifierBlocks.Editor.Icons, inline SVG for the names the core block types declare, no font and no CDN - and the editor uses it whenever the host passes no icon component. It is markup, not styling, and the split is the same one this whole document is about: a glyph's shape is the package's, a glyph's colour and size are the theme's.

So there is no --sb-icon-* token, and there is nothing here to override:

  • every path paints with currentColor, and the <svg> fills its tile;
  • the tile - .sb-node__icon on a card, .sb-palette__icon on a palette row - is the only rule with an opinion, and it reads --sb-block-accent and --sb-block-accent-tint, which the section above already covers;
  • so restyling the icons is restyling those two tokens, and a per-block-type accent_token moves a type's tile with its stripe, for free.

Two states are deliberate rather than absent, and neither is a hole a theme is expected to paper over:

  • a block type that declares no icon gets no tile at all. The chrome closes up around the label. A type that declared nothing is not missing something.
  • a name the shipped set does not have gets a neutral mark - three dots, in the tile, with the name in data-icon. A host block type declaring icon: "identification" gets a chip that reads as deliberate. The right fix is to pass your own icon component, which wins on every tile on both surfaces; the mark is what the editor looks like until you do.

Neither state is the white square the editor used to render (sb-jja). If you see one, you are looking at a font, not at this package.

A complete host theme

A library's patron registration host, dark, brand accent, two block types with identities of their own. It restates every colour token the package declares plus the scheme, and it declares nothing else. That is the file the audit holds to the rule.

/*
 * library: an example host theme for StatifierBlocks.Editor.
 *
 * Every declaration is a `--sb-*` custom property. If a restyle needs
 * anything else, that is a hole in the token surface upstream.
 */

[data-theme="library"] .sb-editor {
  /* Tier 3 first, because it is the one that gets forgotten: the browser's
   * own chrome inside the editor's subtree. */
  --sb-color-scheme: dark;

  /* Surfaces, back to front. */
  --sb-bg-canvas: #12151b;
  --sb-bg: #1a1f27;
  --sb-bg-muted: #212733;
  --sb-bg-sunken: #0e1116;

  /* Text. Each of these clears 4.5:1 on the WORST of the four surfaces, not
   * on the one it happens to sit over in the screenshot. */
  --sb-fg: #eef1f6;
  --sb-fg-muted: #a8b2c2;
  --sb-fg-subtle: #93a0b2;

  /* Lines. `--sb-border-strong` carries meaning - a boundary box, an
   * undeclared slot - and clears 3:1. `--sb-border` divides two panes of one
   * surface and is decoration, so it is deliberately held to no ratio. */
  --sb-border: #2c3442;
  --sb-border-strong: #6e7b8f;

  /* Brand accent. */
  --sb-accent: #4da3ff;
  --sb-accent-hover: #7bbcff;
  --sb-accent-muted: rgba(77, 163, 255, 0.16);

  /* Findings. A text colour and a tint each, because a finding row reads as
   * text on its own tint. */
  --sb-error: #ff8a7a;
  --sb-error-bg: rgba(255, 138, 122, 0.12);
  --sb-warning: #e8b551;
  --sb-warning-bg: rgba(232, 181, 81, 0.14);
  --sb-info: #4da3ff;
  --sb-info-bg: rgba(77, 163, 255, 0.12);

  /* The drop affordance. Tier 2: the mark stays, its colour is ours. */
  --sb-drop-ok-border: #57c98a;
  --sb-drop-ok-bg: rgba(87, 201, 138, 0.12);

  /* The done outcome of an invoke mark, also tier 2. A dark theme has to
     restate this even though the shipped hue is already "a green": #0e7a5f
     clears 3:1 on four near-white surfaces and reaches 2.83:1 on the worst of
     these four, which is a boundary under the threshold it is held to.
     Lifting the same hue rather than changing it is the point - a theme
     retunes the mark, it does not redefine what green means here. 8.33:1 on
     the worst of these four, and held off `--sb-drop-ok-border` above the way
     the shipped pair is: the drop affordance is the grassier green and the
     run mark the colder one, so a token list read on its own does not suggest
     the two are one idea. */
  --sb-run-done: #4fd6bd;
  --sb-run-done-bg: rgba(79, 214, 189, 0.12);

  /* Tier 2 shaping, and the reason it is a token: 14% of a pale accent over
   * near-black is not a tint. */
  --sb-block-accent-mix: 24%;

  /* The nesting bands. The package derives these from `--sb-bg` and
   * `--sb-bg-sunken`, so a theme that restates the four surfaces bands
   * correctly without saying anything here at all. They are restated anyway,
   * and that is the point worth showing: the separation this theme wants
   * between one nesting level and the next is a little wider than the gap
   * between two of its own surfaces, and these two names are where a host
   * says so rather than reaching for a rule. Opaque, both of them - two
   * translucent bands stacked by the nesting they describe accumulate into
   * "deeper is darker", which is not what a band is for. */
  --sb-band-even: #1a1f27;
  --sb-band-odd: #0e1116;

  /* Per-type identity. These are ours, not the package's: a name a palette
   * entry points at is defined by whoever writes the theme. The family reads
   * as one, and the step that waits on a librarian is pulled out of it. */
  --sb-accent-myapp: #4da3ff;
  --sb-accent-myapp-check-details: var(--sb-accent-myapp);
  --sb-accent-myapp-review: #e8b551;
}

Running the same checks yourself

The package audits its own stylesheet and this page's example in the gate: token coverage in both directions, contrast, override purity, tier coverage, and accent names that nothing defines. The arithmetic is in test/support/theme_audit.ex and it is pure - it takes stylesheet text and returns values, with no browser and no file access - so a host with a theme of its own can copy it and hold that theme to the same numbers.

What it will not tell you is whether the result looks right. That still takes eyes.