Skip to content

feat(design): portal design v2, its Appearance tab and Quackback AI looks - #878

Closed
mortondev wants to merge 47 commits into
mainfrom
feat/portal-design-v2-1-engine
Closed

mortondev wants to merge 47 commits into
mainfrom
feat/portal-design-v2-1-engine

Conversation

@mortondev

@mortondev mortondev commented Nov 1, 2026 •

Copy link
Copy Markdown
Member

The portal's design becomes a document: one validated description of a workspace's brand, layout and pages, resolved to readable theme tokens and stored as a draft with numbered revisions. Appearance in Settings › Portal edits it, with a live preview and Quackback AI.

In this PR

  • The design document schema, its resolution to theme tokens with contrast checks, and component tokens and layout attributes
  • Drafts and numbered revisions, edited from the app, REST and MCP; branding and custom CSS saves are audited
  • A design engine per workspace behind a Labs switch; new workspaces start on it
  • The Appearance tab: presets, header layouts, a top band and headline, the feed's default sort, and clicking a section in the preview to edit it
  • One font registry with heading fonts
  • Quackback AI in Appearance, starting from a logo or a screenshot

Replaces

#831, #832, #834, #836, #837, #838, #839, #840, #842, #843, #844, #845

The first of five stacked PRs; the next builds on this one.

…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.
…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.
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.
The design engine now derives the portal's component tokens from the solved
palette and the shape and layout choices, and points the variables portal
components already read at them: the header, the menu, the voted colour and
the button, field and panel radii. The default design reproduces the values
the stylesheet hard-codes today, so a converted workspace keeps its header,
menu and cards.

- Corners set the control, field and panel radii; density sets the card
  padding, the gap between cards and the post title size; width sets the
  page width, which every portal page now reads.
- Menu styles: tabs as today, underline under the current page, and pills on
  the subtle accent. Accent text is now solved against the subtle accent as
  well as the page and cards, so pills and links stay readable on it.
- The voted vote box follows accent text, which the solver keeps at 4.5:1.
- Structure travels as data-qb-* attributes on <html>: the header, menu,
  band, card style and filter side. Raised and list cards and filters on the
  start side are attribute variants, so legacy pages keep their classes.

Computed styles on 16 portal pages are unchanged with the flag off. With it
on, the default design differs only in contrast fixes.
The feed card, the post page and similar posts, the widget and the embed
card each built their own vote box classes, and they had drifted: the embed
was narrower, kept its count in the ink colour when voted and had no hover
edge. They keep their own behaviour and now take their look from
voteBoxClasses(), so there is one vote style. The embed card gains the
standard size and voted count.
The snapshot keyed each element by its index among all its siblings,
scripts and style elements included, so moving a style element between the
head and the body shifted every later path and read as thousands of
differences. Paths now count only the elements the snapshot walks.
The portal layout wrote its theme and custom CSS as style elements in the
body, and the access gate wrote them again. The layout's head() now emits
both, after the app stylesheet so they still override it, read from the
settings the root context already carries. The portal loader no longer
sends a second copy of the theme CSS, and the gate no longer injects its
own. The widget and the portal auth pages keep PortalDesignStyle.

Computed styles on 16 portal pages are unchanged, with the design engine off
and on.
The design engine reads one registry of bundled fonts, with each family's
label, category, stack and the weights its stylesheet loads. Today's families
keep their stacks. IBM Plex Sans, Fraunces and Bricolage Grotesque join them,
each in its own stylesheet that loads only when a design uses it.

A design's heading font now reaches the portal: headings read
--qb-font-heading from the base layer, so a utility on a heading still wins,
and the heading weight beats the stylesheet's unlayered heading rule. The
body font follows --font-sans without the !important rule the legacy theme
needs. Portal pages and the widget load the design's body and heading
families by id, so a family outside the legacy list loads too.

Computed styles on 16 portal pages are unchanged with the engine off and on,
and the widget stays within its bundle budget.
The actions behind every surface read the design being edited (the draft,
else the published design, else the branding settings converted on the
spot), save and discard the draft, publish it, list versions and restore
one. Each refusal a caller must explain (an invalid or unreadable design, a
stale revision, an overtaken draft, nothing to publish, a missing version)
comes back as a value with its code.

- Server functions for Settings › Portal, each gated on the branding
  permission. Publish and restore record the request in their audit rows.
- REST: GET and PATCH /api/v1/settings/portal_design (a JSON merge patch onto
  the draft, with If-Match), DELETE .../draft and GET .../versions, documented
  in OpenAPI.
- MCP: get_portal_design and update_portal_design_draft.

A key or an agent edits only the draft. Publishing and restoring stay with a
person in Settings › Portal, where the draft shows what is waiting, so no
change reaches visitors without one. The authorization matrix, the domain
graph and the MCP tool counts are regenerated.
The MCP scope map now lists get_portal_design and update_portal_design_draft,
so the draft tool is audited as a write, and the REST route words include
portal_design, draft and versions, so their audit rows keep the route's
name.
Embed cards render inside the admin inbox, and importing voteBoxClasses
there split the helper into a chunk of its own that the inbox then
requested. The embed card keeps its own copy of the classes, and a test
holds it equal to voteBoxClasses in every state, so there is still one vote
style. The inbox loads exactly the chunks it did before.
A shared vote box helper sits in different route bundles, so the bundler
splits it into a chunk of its own, and moving it only moved the extra
request: first to the admin inbox, then to the roadmap. The four vote boxes
keep their own classes, as before. The embed card's are brought into line
with the others (the standard size, the voted colour on the count, the hover
edge and the chevron lift), so visitors see one vote style. The inbox, the
inbox-to-roadmap navigation, the widget and the portal load exactly the
chunks they did before this stack.
Settings › Labs gains a switch for the portalDesignV2 flag, next to Copilot
on Home, with its label and description in all 13 catalogues. The two
switches share one component.
On the design engine, Settings › Portal opens on Appearance, with today's
menu and welcome message under a Navigation tab until they move into the
design. The panel edits the design draft:

- Brand: mode, the accent, background and text colours with more behind a
  toggle, dark colours, heading and body fonts, heading weight and corners.
- Sections: menu style, card style, density, filter side and page width.

Every choice is a drawn tile. The draft saves shortly after each change, so
leaving the page loses nothing. The preview shows the draft: the admin
resolves it with the code the server uses and posts the CSS, the data-qb-*
attributes and the fonts into the frame, which puts the saved values back
when the draft ends.

The draft bar says how many changes are waiting, measured against what
visitors see now, and offers to discard the draft or review it. Review and
publish lists each change with its value now and in the draft, notes the
colours the solver adjusted, refuses a design whose text cannot be read, and
publishes with an optional note. History lists versions and restores one
after a confirmation.
A route's loader ships in the entry chunk, so the Settings › Portal loader's
import of the design queries (and the server functions they call) reached
every page; the help center's script budget tipped over. The loader now
imports them only for a workspace on the design engine. The preview frame's
design channel (reading a draft design and setting its attributes) moves
into a module the frame loads when the first design arrives, so visitors do
not download it either.
On the design engine the feedback page reads its welcome message from the
design, where it can be switched off, rather than from the portal settings.
A converted workspace keeps its text, since the converter carries the
welcome card over. Appearance edits it under Sections with the editor the
settings page used, now a component of its own, and the preview shows it as
it changes. The Navigation tab no longer offers the legacy welcome card on
the design engine, so there is one place to edit it.
A design chooses a band for the top of the feedback page: none, a tint of
the subtle accent, a fade from it into the page, or the accent itself. Its
look comes from band tokens and data-qb-band, with its text solved to 4.5:1
on the band. A headline and subtitle sit in it, in the visitor's language,
falling back to its base language, then English, then any. A plain band with
no headline adds nothing to the page, so a converted design keeps today's
markup.

Appearance edits both under Sections, and the preview shows a draft headline
as it changes. solveContrast moves into contrast.ts, which the component
tokens now share with the resolver.

Computed styles on 16 portal pages are unchanged with the engine off and on.
This was referenced Nov 1, 2026
@mortondev

Copy link
Copy Markdown
Member Author

Superseded by the re-cut builder stack on current main: #917 (engine) → #918 (server) → #919 (portal) → #920 (builder) → #921 (AI) → #922 (tooling), with the critique's wave 1 in #924. The new stack merges main once, renumbers the migrations to 0310+, and has CI green with all review threads addressed.

@mortondev mortondev closed this Nov 7, 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