Skip to content

feat(design): component tokens, one vote box, fonts and the design editing API - #834

Closed
mortondev wants to merge 9 commits into
feat/portal-design-v2-phase1from
feat/portal-design-v2-phase2-foundations
Closed

mortondev wants to merge 9 commits into
feat/portal-design-v2-phase1from
feat/portal-design-v2-phase2-foundations

Conversation

@mortondev

@mortondev mortondev commented Oct 30, 2026 •

Copy link
Copy Markdown
Member

The third of the stack that builds a new design engine for the portal. This one gives the engine everything the Appearance view in the next PR builds on: component tokens and layout attributes, one vote box, the styles in the document head, one font registry with three new families, and the API for editing a design. Behind the portalDesignV2 flag, which stays off by default. With the flag off nothing a visitor sees changes, apart from the embed card's vote box, which now matches the others.

Component tokens and layout attributes

  • The engine derives the portal's component tokens from the solved palette and the shape and layout choices. It 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, or pills on the subtle accent.
  • Contrast: 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.
    • The header layouts and the band arrive with the page sections in the next PR, because they restructure the header.

One vote style

The embed card's vote box had drifted from the others: it was narrower, kept its count in the ink colour when voted, and had no hover edge or chevron lift. It now matches the feed card, the post page and the widget. Each keeps its own classes. A shared helper sits in several route bundles, so the bundler split it into a chunk of its own and every page with a vote box paid one more request. The admin inbox, the inbox-to-roadmap navigation, the widget and the portal load exactly the chunks they did before this stack.

Styles in the head

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, read from the settings the root context already carries. The portal loader no longer sends a second copy of the theme CSS. The widget and the portal auth pages keep PortalDesignStyle.

Fonts

  • The engine reads one registry of bundled fonts: 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.
  • Headings read --qb-font-heading from the base layer, so a utility on a heading still wins. 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 a design's body and heading families by id, so a family outside the legacy list loads too.

Editing a design

One set of actions sits behind every surface. They read the design being edited: the draft, else the published design, else the branding settings converted on the spot. They save and discard the draft, publish it, list versions and restore one. Each refusal a caller must explain comes back as a value with its code: an invalid or unreadable design, a stale revision, an overtaken draft, nothing to publish, or a missing version.

  • 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.

Harness

Style snapshots now key elements only by the elements they walk, so moving a style element between the head and the body no longer shifts every later path.

Verification

  • Typecheck, lint, the production build, the server-function manifest check and the widget bundle budget pass (382 KB of 420 KB gzipped).
  • Unit suite: 22,243 passed. The run found two maps the new tools and routes were missing from (the MCP scope map and the REST route words), fixed in the last commit. The other ten failures, all in unrelated database and timing tests, pass when run on their own.
  • Style snapshots, 16 pages and 4,536 elements, compared after every step:
    • flag off: unchanged throughout;
    • flag on: the default design differs from today only in contrast fixes, accent text and field edges.
  • The authorization matrix lists all six server functions and four routes as gated on the branding permission, and the ungated count is unchanged.
  • The new guards were broken on purpose to see their tests fail: the font hook, the merge patch's prototype guard, and the scope and attribute rules.

@mortondev
mortondev added this pull request to stack #833 October 30, 2026 17:58
@mortondev mortondev changed the title feat/portal design v2 phase2 foundations feat(design): component tokens, one vote box, fonts and the design editing API Oct 30, 2026
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.
@mortondev
mortondev force-pushed the feat/portal-design-v2-phase2-foundations branch from 2082323 to 92ed776 Compare October 30, 2026 20:19
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.
@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