Repository navigation
Conversation
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
force-pushed
the
feat/portal-design-v2-phase1
branch
from
October 30, 2026 20:19
b9b9e80 to
1352234
Compare
Member
Author
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 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
portalDesignis 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 oroklch(); named colours,var()and alpha are refused.defaultDesign()is today's default theme. With Home off, the menu must still show a page.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.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.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 newtenantcascade layer.Storage
Migration
0305_portal_designsadds two tables.portal_designsholds each site's draft and points at its published revision;portal_design_revisionsholds 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_CHANGEDwhen 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 recorddesign.publishedanddesign.restoredaudit rows, and the audit log can filter on both.Engine selection
portalDesignV2Labs 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=legacykeeps every workspace on today's theme, whatever the flag says. It is documented in.env.example.portalTheme(), what to inject; on today's engine it returns exactly what they computed before.data-portal-themeon<html>, so dialogs portaled to<body>carry the theme and admin pages never match it. The design's mode forces light or dark the waythemeModedoes 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-themeand the admin intheme. 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
0305, and the contract ledger and domain graph are regenerated.