Repository navigation
Conversation
…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
Closed
Closed
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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
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.