Skip to content

feat(design): portal design documents, storage and the v2 engine behind a Labs flag - #832

Closed
mortondev wants to merge 8 commits into
feat/portal-design-v2-phase0from
feat/portal-design-v2-phase1
Closed

mortondev wants to merge 8 commits into
feat/portal-design-v2-phase0from
feat/portal-design-v2-phase1

Conversation

@mortondev

@mortondev mortondev commented Oct 30, 2026 •

Copy link
Copy Markdown
Member

The second of the stack that builds a new design engine for the portal. This one adds the design document, its storage and the engine that renders it, behind a Labs flag that is off by default. With the flag off nothing a visitor sees changes; the one change for everyone is that the portal and the admin now keep separate light or dark choices.

The design document

  • Schema. portalDesign is one zod document for a portal's look: mode, brand colours for light and dark, fonts, corners, layout, the pages, the menu and the words. Colours are normalised to lowercase hex or oklch(); named colours, var() and alpha are refused. defaultDesign() is today's default theme. With Home off, the menu must still show a page.
  • Conversion. convertLegacyBranding() reads today's branding settings, welcome card and menu as a design, purely and deterministically. Every preset and the accent sweep convert, and the resolved tokens match today's except for a listed set of delta tokens.
  • Resolver and contrast solver. resolveDesign() starts from today's theme expander, so a converted design renders as it does now, then lifts every pair the portal paints to its target: secondary text, links and accent text, error text, field edges and the focus ring. It records each change. Ink the brand chose is never moved: an unreadable ink is an error and the design is refused. The golden test runs all 16 presets and 11 accents in both modes and allows no failures, against 120 for today's theming.
  • Rendering. renderDesignCss() writes the tokens under [data-portal-theme], never :root, as semantic --qb-* tokens with the component library's names pointing at them. A design's own CSS goes in a new tenant cascade layer.

Storage

Migration 0305_portal_designs adds two tables. portal_designs holds each site's draft and points at its published revision; portal_design_revisions holds every published document, numbered and never edited. The migration is additive and replays as a no-op.

The design service checks every document with the schema and the solver before storing it. A draft remembers the revision it started from, and publishing refuses with DESIGN_CHANGED when another version was published in the meantime. Restore publishes an old document as the next revision and keeps a draft in progress. Each site keeps its newest 100 revisions. Publish and restore record design.published and design.restored audit rows, and the audit log can filter on both.

Engine selection

  • The portalDesignV2 Labs flag picks the engine. With it on, a published design wins, a workspace without custom CSS is converted on read with nothing written, and a workspace with CSS stays on today's theme.
  • PORTAL_DESIGN_ENGINE=legacy keeps every workspace on today's theme, whatever the flag says. It is documented in .env.example.
  • The chosen design travels in the workspace settings as a small view: the mode, the CSS and the body font. The portal, the widget, the auth pages and the access gate ask one helper, portalTheme(), what to inject; on today's engine it returns exactly what they computed before.
  • On v2, portal pages put data-portal-theme on <html>, so dialogs portaled to <body> carry the theme and admin pages never match it. The design's mode forces light or dark the way themeMode does today.

There is no Labs card for the flag yet; it arrives with the Appearance view in the next PR.

Separate light or dark choices

The portal and the admin shared one theme cookie and one storage key, so switching the portal to dark switched the admin too. The portal now keeps its choice in portal-theme and the admin in theme. A visitor who chose before keeps that choice on the portal until they choose again there. The theme provider remounts when a navigation crosses between portal and admin, because next-themes reads its storage key only when it mounts, and the visitor beacon, product analytics and the one-time-token handler now render outside it so they still mount once per document.

Verification

  • Typecheck, lint, the production build and the server-function manifest check pass.
  • Unit suite: 22,227 passed. The one failure, a role-change database test that raced under full-suite load, passes on its own.
  • The repo's policy checks know the new tables: the two principal columns are exempt from the anonymous-to-identified merge (only teammates write designs), the migrator gate lists 0305, and the contract ledger and domain graph are regenerated.
  • Style snapshots, 16 pages and 4,536 elements: with the flag off the portal matches the phase 0 baseline; the only differences were two neighbouring posts on the trending sort swapping places as their scores decayed. With the flag on, the default design changes 31 computed values, all contrast fixes: accent text darkened to 4.5:1 in light, and field edges raised to 3:1 in both modes.
  • The migration runs on a fresh database with no schema drift, and twice in a row on the test database.
  • Each new guard was broken on purpose to see its test fail: the stale-draft refusal, the flag and the override at both levels, the scope attribute on portal pages only, the separate storage keys, the cookie fallback and the forced mode.

@mortondev
mortondev added this pull request to stack #833 October 30, 2026 16:22
@mortondev mortondev changed the title feat/portal design v2 phase1 feat(design): portal design documents, storage and the v2 engine behind a Labs flag Oct 30, 2026
PortalDesign is one zod document for a portal's design: brand colours, fonts
and logos, corners, layout, the Home page and its blocks, the feedback page,
the menu and the words visitors see. Every choice comes from DESIGN_OPTIONS,
and the eight reference designs parse.

Colours are stored in one form: hex, rgb() and hsl() become lowercase hex and
oklch() keeps its space. Named colours, variables and translucent colours are
refused, because contrast has to be measured on the exact colour. Home blocks
are held to the widths each type allows, links must be http or https, and
with Home off the menu must show a page.

defaultDesign() is today's portal as a design.
resolveDesign() expands a design with today's theme expander, so a design
made from today's settings starts from the same tokens. A contrast solver
then lifts every pair the portal paints to its target and records each
change with the value it replaced:

- button text picks near-black or near-white, and moves the accent fill only
  when neither reaches 4.5:1;
- secondary text, links, error text, field edges and the focus ring move in
  lightness, keeping chroma and hue, until they reach 4.5:1 or 3:1 on every
  background they sit on;
- ink is the brand's own choice, so ink that cannot be read on its
  background is reported as an error rather than changed.

renderDesignCss() writes the tokens under [data-portal-theme], with a .dark
block for designs shown in both modes, so only portal documents match them.

The palette gains an optional raised colour for chips and the vote box, and
the bundled fonts include the system monospace stack.
convertLegacyBranding() maps branding, logos, the welcome message and the
menu to a design without writing anything: colours to the palette, the radius
to one of four corner styles, the font to its bundled id by leading family,
the header display mode to the logo display, and the saved menu with
Feedback always shown and missing pages appended, as today.

For every preset, and for configs that set only a brand colour, the resolved
design renders today's tokens except where the contrast solver lifted a pair
or a token is one a design works out (secondary and hover surfaces, error
and success colours, the focus ring and the radius).

Every preset and the 11-accent sweep, converted and resolved, now pass every
contrast pair in light and dark, where today's theming fails 120 of them.
Two tables hold each site's design: portal_designs keeps the draft and points
at the published revision, and portal_design_revisions keeps every published
document, numbered and never edited. The migration is additive and replays as
a no-op.

The design service checks every document with the schema and the contrast
solver before storing it. A draft remembers the revision it started from,
and publishing refuses when another version was published in the meantime,
so two people cannot overwrite each other. Restore publishes an old document
as the next revision and keeps any draft in progress. Each site keeps its
newest 100 revisions. Every publish and restore records an audit row, and
the audit log can filter on both.
…igns

A new portalDesignV2 Labs flag, off by default, picks the engine. With it
on, a published design wins; a workspace without custom CSS is converted from
its branding settings on read, with nothing written; a workspace with CSS
stays on the legacy theme. PORTAL_DESIGN_ENGINE=legacy keeps every workspace
on the legacy theme whatever the flag says.

The chosen design travels in the workspace settings as a small view: the
mode, the scoped CSS and the body font. Portal pages, the widget, the auth
pages and the access gate ask one helper what to inject, which returns
exactly what they computed before when the engine is legacy. On v2, portal
pages put data-portal-theme on <html>, so dialogs portaled to <body> carry
the theme and the admin never matches it, and the design's mode forces light
or dark the way themeMode does today.

The rendered CSS now names semantic --qb-* tokens and points the component
library's names at them, writing the aliases once since the dark block only
changes the tokens. A design's own CSS goes in a new tenant cascade layer.

With the flag off the portal renders the same: a computed-style snapshot of
16 pages matches the baseline. With it on, the default design changes 31
values, all contrast fixes: accent text darkened to 4.5:1 and field edges
raised to 3:1.
…in's

The portal and the admin shared one theme cookie and one storage key, so
switching the portal to dark switched the admin too. The portal now keeps its
choice in portal-theme and the admin in theme. A visitor who chose before the
split keeps that choice on the portal until they choose again there.

next-themes reads its storage key only when it mounts, so the provider is
keyed by the page kind and remounts when a navigation crosses between portal
and admin. The cookie handler, the visitor beacon and product analytics now
render outside the provider, so they still mount once per document.
The two new principal columns are exempt from the anonymous-to-identified
merge: only teammates with the branding permission write a draft or publish
a revision. The migrator gate lists 0305 in its post-0248 span, and the
contract ledger counts one more migration with no destructive DDL.
The settings read imported the design service, which built its site filter
when the module loaded, so every test that mocks the database module without
that operator failed on import. The filter is built per query now, and the
settings read loads the design modules only when the portalDesignV2 flag is
on. The domain graph gains the design node.
@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