Skip to content

Theming

neodigm edited this page Oct 4, 2026 · 1 revision

Theming

One copy of the palette, in src/wc/machvive-chat-syncopation-services/theme.js. Every component interpolates THEME_CSS at the top of its own stylesheet.

Restyling

Custom properties inherit through shadow boundaries where ordinary styles do not, so you restyle everything by setting tokens once. No ::part surgery, no !important.

machvive-chat-syncopation {
  --mcs-accent: #7b2d8e;
  --mcs-accent-fg: #ffffff;
  --mcs-user-bg: #f3e8f7;
  --mcs-radius: 4px;
  --mcs-font: 'Inter', system-ui, sans-serif;
  --mcs-height: 40rem;
}

Or globally:

:root {
  --mcs-accent: #7b2d8e;
}

Tokens

Token Light Dark Used for
--mcs-fg #1a1a1a #e8eaed Body text
--mcs-muted #6e6e6e #9aa0a6 Timestamps, notes, system turns
--mcs-bg #ffffff #1f2125 Component surface
--mcs-surface #f6f7f9 #282b30 Headers, the CLI, inset areas
--mcs-user-bg #e8f0fe #1e3a5f The user's bubbles
--mcs-assistant-bg #f3f4f6 #2b2f36 Reply bubbles
--mcs-border #e2e4e8 #3c4046 Rules and outlines
--mcs-accent #1565c0 #5b9bf8 Send button, focus ring, CLI sigil
--mcs-accent-fg #ffffff #0b1220 Text on the accent
--mcs-danger #a4161a #ff9d97 Error turns, delete controls
--mcs-radius 12px — Corner rounding
--mcs-font system UI stack — Body font
--mcs-mono system mono stack — CLI and inspector

Layout tokens: --mcs-height (container, 32rem), --mcs-inspector-height (14rem), --mcs-history-height (14rem).

Dark mode

Follows prefers-color-scheme with no configuration. Override per element:

<machvive-chat-syncopation theme="dark">
<machvive-chat-syncopation theme="light">

An explicit choice wins in either direction — theme="dark" is dark on a light OS, and theme="light" is light on a dark one.

The cascade order is load-bearing

:host { /* light tokens */ }

@media (prefers-color-scheme: dark) {
  :host(:not([theme="light"])) { /* dark tokens */ }
}

:host([theme="dark"]) { /* dark tokens, again */ }
:host([theme="light"]) { /* light tokens, again */ }

Three things are doing work here, and a test asserts the ordering:

  1. The :not([theme="light"]) guard inside the media query is what lets a page force light on a dark OS.
  2. :host([theme="dark"]) comes after the media query. At equal specificity later wins, so declaring it earlier would make an explicit dark choice lose in a light OS — silently, and only for some users.
  3. color-scheme: light dark must stay declared, or the browser paints light scrollbars, select popups and checkboxes onto a dark panel.

Four rules with shipped defects behind them

If you add a component or restyle one, these are not style preferences. Each one is a bug that reached production in this family of packages.

1. Interpolate THEME_CSS; never redefine a token locally

A second copy of the palette drifts from the first, and the drift is invisible until someone switches themes. A test strips THEME_CSS from each component and fails on any remaining --mcs-* definition.

2. Form controls need an explicit color

Controls do not inherit color from :host. Without one, the UA supplies a per-theme default — which is how every button in the sibling package rendered white text on a white background, at 1:1 contrast, for everyone using dark mode. It passed 145 tests. CONTROL_CSS exists for this and sets color and background on button, input, textarea, select.

3. A component that themes its text must paint its own background

Analytics in the sibling package set a light color under a dark theme with no background. Inside a light page its text rendered at 1.21:1 — light on light. If a :host block sets color, it sets background too. A test enforces exactly that pairing.

4. Every colour token needs a dark value

A token defined only in the light block inherits the light value under dark, which is rule 3 arriving by a different road. A test compares the two token sets.

Contrast is not something to eyeball

jsdom has no layout engine and no computed style, so the test suite is structurally blind to contrast, overflow and stacking. Two real bugs shipped past a green suite in the sibling package for this reason.

Measure it, in a real browser, across all six combinations (OS light/dark × theme unset/light/dark). That audit is what caught --mv-faint: #888 sitting at 3.54:1 on white — below the WCAG AA threshold of 4.5:1, and already released.

A minimal harness:

import { chromium } from 'playwright';

// The cached Playwright Chromium is a version behind on some machines and fails
// to launch; use the installed Chrome.
const browser = await chromium.launch({ channel: 'chrome' });

for (const colorScheme of ['light', 'dark']) {
  for (const theme of [null, 'light', 'dark']) {
    const page = await browser.newPage({ colorScheme });
    await page.goto('http://localhost:4173/');
    if (theme) await page.evaluate((t) => {
      document.querySelector('machvive-chat-syncopation').setAttribute('theme', t);
    }, theme);

    const ratio = await page.evaluate(() => {
      const el = document.querySelector('machvive-chat-syncopation-canvas')
        .shadowRoot.querySelector('.bubble');
      const style = getComputedStyle(el);
      return contrast(style.color, style.backgroundColor);   // your WCAG helper
    });

    console.log(colorScheme, theme ?? 'auto', ratio.toFixed(2));
    await page.close();
  }
}

Motion and the rest

prefers-reduced-motion: reduce disables the streaming caret animation and smooth scrolling. If you add motion, honour it — a test fails on any animation: or transition: in a component that has no prefers-reduced-motion block.

Focus uses :focus-visible with a 2px --mcs-accent outline and an offset, because the UA default ring disappears against a dark surface.

Next: Data Agency.