Skip to content

fix(branding): close the theme CSS injection, audit branding saves, add a design test harness - #831

Closed
mortondev wants to merge 8 commits into
mainfrom
feat/portal-design-v2-phase0
Closed

mortondev wants to merge 8 commits into
mainfrom
feat/portal-design-v2-phase0

Conversation

@mortondev

@mortondev mortondev commented Oct 30, 2026 •

Copy link
Copy Markdown
Member

The first of a stack that builds a new design engine for the portal. This one closes a CSS injection, adds audit rows for branding saves, and puts in place the tests the later PRs are measured against. Apart from the color-scheme fix, nothing a visitor sees changes.

Fixes

  • CSS injection through branding colours. The settings page saved branding as an open record, and the generator wrote each value straight into a declaration, so a colour such as red; } body { display: none rewrote the portal's CSS. The save now refuses any value that could end its declaration, open or close a block, start a comment or at-rule, escape a character, close the style element or load a resource. It still accepts everything the editor has produced: named colours, var(), color-mix(), legacy tokens and retired presets. The generator treats an unsafe stored value as absent, so rows saved before this still load and the base palette fills the gap.
  • Native controls followed the OS, not the page. * { color-scheme: light dark } overrode the scheme <html> carries, so scrollbars and form controls in a forced-dark portal, and in the admin, followed the visitor's OS. Elements now inherit the document's scheme. Three tokens and rules nothing reads are gone too: --elevated, --font-family-sans and the widget's html.system.

Audit

Saving the theme or the custom CSS from the settings page records branding.theme.changed or branding.css.changed, with the person, the request and the value before and after. A save that leaves the value as it was records nothing. The audit log page can filter on both.

Harness

  • Style snapshots. bun run perf:style-snap boots the production build against the bench database and records every element's computed colour, border, radius, shadow, type and spacing on eight portal pages (feed, roadmap, changelog, an entry, a post, help center, an auth page and the widget), in light and dark. Comparing two snapshots lists every difference, so a theme or CSS change that should look the same can prove it.
  • Golden contrast. contrastRatio() gives the WCAG 2.x ratio of the hex and oklch() values the theme writes. The golden test measures all 16 presets and 11 accents that have broken readability, in light and dark, on 13 pairs the portal paints. Today 120 pairs fall short, mostly field edges and link text on bright accents. They are recorded in contrast.golden.known.json, and the test holds the list in both directions, so it can only shrink.
  • Design options and reference designs. lib/shared/design/options.ts lists every choice a portal design offers. Eight reference designs, four with a Home page, use every option and both sides of every switch between them. Adding an option fails the test until a design uses it.

Refactor

PortalDesignStyle writes the workspace theme and custom CSS for the portal, the widget, the portal auth pages and the access gate, which each wrote the same two style tags by hand.

Verification

  • Typecheck, lint, the production build and the server-function manifest check pass.
  • Unit suite: 22,031 passed. The one failure, an MCP auth test that timed out under full-suite load, passes on its own.
  • Style snapshots: two runs of the same build compare with no differences, and the refactor changes nothing. The color-scheme fix changes only color-scheme, on every element below <html> (4,520 across the 16 pages), from light dark to the page's own scheme.
  • Each new guard was broken on purpose to see its test fail: the injection check, the no-change audit rule, the contrast list, the option coverage and the Home block widths.

…ation

The settings page saved branding as an open record, and the generator wrote
each value straight into a declaration, so a colour such as
'red; } body { display: none' rewrote the portal's CSS.

The page save now refuses any value that could end its declaration, open or
close a block, start a comment or at-rule, escape a character, close the
style element or load a resource. It still accepts every value the editor
has produced: named colours, var(), color-mix(), legacy tokens and retired
presets. The generator treats an unsafe stored value as absent, so rows saved
before this check still load and the base palette fills the gap.
The settings page writes branding in two steps, the theme and the custom CSS
remainder, and neither left an audit row. Each now records the person, the
request and the value before and after, as branding.theme.changed and
branding.css.changed. A save that leaves the value as it was records
nothing. The audit log page can filter on both.
…hange

perf/style-snap.ts boots the production build against the bench database and
records every element's computed colour, border, radius, shadow, type and
spacing on eight portal pages (feed, roadmap, changelog, an entry, a post,
help center, an auth page and the widget), in light and dark. Comparing two
snapshots lists every difference, so a theme or CSS change that should look
the same can prove it. Two runs of the same build compare with no
differences, and the comparison checks that it can report one.
… CSS

The portal, the widget, the portal auth pages and the access gate each wrote
the same two style tags by hand. PortalDesignStyle writes them now, theme
first and custom CSS second, with the same markup, so the next change to how
a workspace's design reaches the page happens in one place. The computed
styles of every snapshot page are unchanged in light and dark.
…tokens

Every element set color-scheme: light dark, which overrode the scheme <html>
carries, so scrollbars and form controls in a forced-dark portal followed the
visitor's OS instead. Elements now inherit the document's scheme.

Also removes tokens and rules nothing reads: --elevated (three definitions),
--font-family-sans (a name Tailwind v4 does not use for font-sans) and the
widget's html.system rules (no code sets that class).

The style snapshots change only in color-scheme, on every element below
<html>, from 'light dark' to the page's own scheme.
…ntrast

contrastRatio() gives the WCAG 2.x ratio of the hex and oklch() values the
theme writes, measuring a translucent colour as it shows over its background.

The golden test measures all 16 presets and 11 accents that have broken
readability, in light and dark, on the pairs the portal paints: ink,
secondary text, button text, links, error text, field edges and the focus
ring. Today 120 pairs fall short, most of them field edges and link text on
bright accents. They are recorded in contrast.golden.known.json and the test
holds the list in both directions, so a new failure fails it and a fixed one
must be removed, and the list can only shrink.
DESIGN_OPTIONS lists every choice a portal design offers: mode, corners,
heading weight, font kind, logo display, the layout switches, sorts, Home's
block types and grid widths, and the words visitors see. BLOCK_WIDTHS holds
the widths each Home block can take.

The eight reference brands are complete designs, four of them with a Home
page. Their test checks each one uses only documented options and keeps Home
and the menu within their rules, and that between them they use every
option and both sides of every switch. An option added to DESIGN_OPTIONS
fails the test until a fixture uses it, so nothing ships untested.
@mortondev mortondev changed the title feat/portal design v2 phase0 fix(branding): close the theme CSS injection, audit branding saves, add a design test harness Oct 30, 2026
@mortondev
mortondev added this pull request to stack #833 October 30, 2026 16:22
…share

PortalDesignStyle had a module of its own, which the portal, the widget, the
auth pages and the lazy access gate all imported, so the bundler gave it a
chunk of its own and every portal page and the widget requested one more
script. It now lives beside escapeInlineStyle in safe-inline-content, which
those pages already load, and the widget load and the notifications page
request exactly the scripts they did before.
@mortondev

Copy link
Copy Markdown
Member Author

Condensed into #878. The stack is now five PRs, #878 to #882; this branch is kept as it was.

@mortondev mortondev closed this Nov 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant