Repository navigation
Conversation
Settings > Portal was a text-only card, so an admin could not see what their portal looks like without opening the builder. It now shows a small, inert picture of the live portal (the same preview frame the settings preview uses), the logo, the brand colours as swatches and the font names, with Match your brand and Open builder. Match your brand opens the builder with brand=match, which opens Theme and leads to the Brand kit's match row, as onboarding's arrival does. /admin/settings/appearance returned 404 although people guess it. It now redirects to Settings > Portal the way /branding does. The new route file needs the route tree regenerated.
…surface Links on a tinted Home block read at 4.42:1 on the live portal: the solver lifted links only against the page, cards and the subtle accent, and the focus ring only against the page, cards and buttons, while Theme > Colours reported the tint pair without anything fixing it. surfaces.ts now lists every other surface the site's colours are drawn on (tinted blocks in each colour in use, vote boxes in a tint of the second colour, the footer's credit pill), and the solver adds each to the sets for secondary text, links and the focus ring, and refuses ink that fails on one. Colour pairs read the same list, so what the solver fixes is what the panel reports. Tints are measured as Chromium paints color-mix in oklch (mixOklch: straight lightness and chroma, shorter hue, a hex below 0.02 chroma taking the other hue), from one constant the portal's inline style also uses (tints.ts). No fixture's design CSS changes: none of them drew a failing tint. The new test tints every fixture's blocks in each colour and checks 0 failing pairs.
… publish review Theme says it styles the widget, but the builder never showed it, so an admin could not check the brand reached it. The page switcher's General column now lists a read-only Widget sample. It frames the real widget app with ?preview=true and posts the draft design into it the way the canvas posts it to the portal. The widget loads the small listener for that channel lazily, only in that frame, and holds clicks so it reads as a picture. The canvas bar marks it a sample and links to Widget settings, where its words and buttons are set. The publish review gains a Widget choice beside the pages, showing the widget live now and after publishing. The widget's feedback icons were a lightbulb, which reads as "ideas" rather than posts. Write a post now uses a pencil, and the Feedback tab and post lists use the speech bubble the admin uses for posts.
A headline's colour was solved to 3:1 whatever its size, while styles allow sizes down to 12px, so a small headline could ship at 3.01:1. WCAG 1.4.3 allows 3:1 only for large text. largeHeadline() reads every headline size the design sets, in each scope and on phones (a size of 28px or more scales down to 60% there), and says a headline is large only when its smallest size is at least 24px, or 18.66px at weight 700 or more. Unset, a headline never draws under 24px. Element styles, an exact headline colour and the light headline on a bright band now read at 3:1 only then, and at 4.5:1 otherwise. No fixture's design CSS changes.
The portal header drew the Brand kit's icon and title while the tab, the favicon and every shared link still showed the workspace name, the duck and an English description about feature requests. The brand is first seen when a link is shared, so this is where it has to hold. - Favicon: design favicon, then the brand icon (an SVG as it is, any other image fitted into a square on the server), then the workspace favicon and logo, then the default. - Titles use the Brand kit title, falling back to the workspace name as the header does. - The Automatic share image is now a real 1200x630 card (page colour, icon, title, page name, brand colour along the foot), drawn in plain TypeScript with the bundled faces' outlines and the in-repo PNG encoder, served at /brand.png with summary_large_image. Its address carries a hash of what it shows, so it is cached for good and drawn once per version per workspace. - Per-page descriptions are written in the workspace's main language and speak of posts and feedback. - theme-color follows the page colour for light and dark. - Theme > Site images says what really happens (the favicon falls back to the icon first) and shows a link preview drawn from the draft. New route file: src/routes/brand[.]png.ts (the route tree needs regenerating).
Visitor emails used Quackback gold whatever the portal looked like, and took their logo from the legacy workspace logo only, so the most frequent brand touchpoint ignored the Brand kit. The published design now gives each visitor email a palette: the button in the brand colour with the text the portal's contrast solver picks for it, links darkened until they read on the white card, and the accent bars in the brand colour. The header image is the brand icon where mail clients draw it (PNG, JPEG or GIF), else the workspace logo; SVG logos are never sent. Without a published design, or while the portal shows the legacy theme, emails keep gold as before. The palette rides the existing send path: hook-context reads it once per event and passes it through the hook configs; sign-in, sign-up refusal, address check, password reset and portal invites read it beside the settings they already load. Templates draw buttons, links and bars through small brand parts that read the palette from context. The full publish review shows a read-only sign-in email sample, rendered by the server from the draft, under "Visitors also see".
Hero images were stored and served at whatever size was uploaded, so a phone could download a 6000px photo for a 390px band, and the band's image, the page's largest paint, was not fetched first. The repo has no server-side image transforms, so the browser prepares the image on upload, as page backgrounds already do: an image wider than 2560px is redrawn at 2560 (WebP, or JPEG for a JPEG where WebP cannot be written, quality 0.82), and smaller WebP copies at 640 and 1280 are stored beside it when they save something. GIFs and anything unreadable go up as they are. The design records the stored width and the copies on brand.bandImage as optional width and variants, so designs with a single key keep working unchanged. The view turns them into one compact srcset only when the width is known, and the side image draws it with sizes and fetchpriority="high". The preview channel and the portal both accept a srcset of stored files only. Re-cropping a hero drops the old crop's copies.
…er on the canvas's mode An exact colour in an element style drew the same hex in both modes, and white and black stayed white and black, so a design that shows dark mode kept light panels and black text on its dark pages. The colour field also always opened on Light, and its half swatch drew the dark token's light colour. In a design that shows both modes, a colour set for light now takes a dark counterpart (style-css.ts darkExact): white is the card and black the text; a colour within a just-visible step of a brand role is that role's dark colour; any other has its lightness mirrored from the light page and text to the dark ones, in its own hue, and text on it is solved as before. A dark value of its own still wins, and a design that shows only dark keeps the colour as set. The colour field opens on the mode the canvas shows (CanvasModeContext) and draws the dark half in the dark colour. A new check names a panel the site's styles still draw light on the dark page, with a one-step fix in the draft menu that draws it in the card colour. No fixture's design CSS changes.
…e route The share card cache was a bare module Map keyed by a namespace the caller passed, which the module-state policy rightly refuses: in a pooled process one workspace could be served another's card. It is now a WorkspaceKeyedCache with a ledger entry, and the authz matrix records /brand.png as an intentionally public route (it serves only what the portal head already links to).
Publishing ended in a toast that said the change was live but not where, and gave no way to the address or to a domain of its own. Webflow and Framer both end publishing on the URL. Publish now opens a popover anchored to the button with the live address, Copy and Open, and "Use your own domain" (to Settings > Domains) on a Cloud workspace with no custom domain yet, for someone allowed to set one up. The publish server function returns the address with its result, read from the workspace's base URL and its cloud identity; a self-hosted portal is already on its owner's domain, so it is never offered. Copy, Open and the domain link are a new typed builder event.
The solver's maths and the browser's painting could drift: a tint is a color-mix in oklch, which the solver now measures the same way, and only a browser proves the two agree. axe-core is not a dependency of the repo, so e2e/utils/a11y-audit.ts measures what axe's color-contrast rule measures in the page (each visible run of text against what elementsFromPoint says is painted behind it, colours read back from a canvas, 3:1 for large text), plus the type and target floors. design-contrast.spec.ts renders each design fixture, as it is and with a tinted and a brand-colour block in each of its colours, from its own design CSS onto a page holding every surface the portal draws, and audits it at 1280px and 375px in each mode the design shows. It needs no server. With the solver's surfaces switched off it fails on five fixtures (links on tinted blocks at 4.02 to 4.47:1); with them it passes all 43 cases. The tint's CSS now comes from one helper (blockTintCss) that the portal's block and the spec both use.
The portal's own UI set text at 11px (roadmap card badges, tag chips, counters, status labels and more) and the dialog's Close button was a 16px target, under the floors the designer holds a design's own sizes to. Every text-[11px] in the portal's components and routes is now text-xs (12px); a new test fails on any arbitrary size under 12px there. The dialog's and sheet's Close buttons gain 4px of padding, moved up and in by as much, so the icon stays where it was and the target is 24px; the unsubscribe banner's dismiss does the same, and the header menu's scroll arrow is at least 24px tall. portal-floors.spec.ts audits Home, the roadmap, the header menu and the sign-in dialog at 375px for both floors (e2e/utils/a11y-audit.ts); a chip's qb-hit-24 area counts as its target. The roadmap snapshot changes only in its badge class (text-[11px] to text-xs).
…the stored one The element panel showed stored values: a headline's Size read 56px on every device while the canvas drew 49.78, 33.95 and 30.8px, and a text colour read #FEFEFE while the contrast solver drew it grey. Quackback AI was told the same stored numbers, and effectiveValues ignored the element looks (style.<element>.base) that the canvas draws over the brand's exact values. The builder now reads what the picked element is drawn with (its numbers and computed colours) a moment after each change, state, width or mode switch. Where that differs from the row's value, the row gets a muted second line, such as "56 max · 31 on this phone" or "Shown as #8F8F8F so it reads", with Use this, which stores the value in use at the current scope. It stays quiet while the canvas is dark, where light's stored colours are not the ones drawn, and where a narrower place's value reaches the element instead. effectiveValues now reads each element's own look before tune and the choices. The brand-only reading the cascade's "From brand" and the static reader need moves to brandValues, so a look is never counted as its own fallback. The AI's "values now" uses the corrected values and says the headline size is the widest screen's, smaller on phones.
A missing post or an error inside the portal showed Quackback's duck at full screen height, and an unknown address dropped the portal's header, footer and design CSS altogether, so the brand vanished exactly when a visitor was lost. - The portal shell now provides its own not-found page and the brand icon to the shared error pages: inside the portal they show the brand's icon or no image, sit inside the header and footer, and never the duck. - A missing post leads to its board and to a search of the posts. - Unknown portal addresses render through a portal splat route that throws notFound, so they get the shell too (404, noindex). Addresses that belong to the admin, the API, sign-in or the widget still get the plain page, decided before the portal loads. - The builder's page switcher lists a "Not found" sample under General, drawn at /design-sample/not-found with words only; the panels stay on the page underneath, since the page has nothing of its own to set. - Two new strings, translated in every catalogue. New route file: src/routes/_portal/$.tsx (the route tree needs regenerating).
…publish review The target model shows what the brand reaches beyond the page, read only, in the publish review: the sign-in email and a shared link. The review now draws the draft's link preview (the share card or image, the address, the title and the description) beside the email sample, from the same component as Theme > Site images.
…sage rules The portal's not-found page links home through PORTAL_LANDING like every other portal link, and is registered as a user of the errorPage message area, whose words it shares. The quick find focus test mocks the root context the Site images link preview now reads.
The shell keeps the widget sample view from 4C and takes 4B's canvas-mode provider (so colour fields open on the canvas's mode) and the checks' fix action.
The page switcher offers both samples (Widget from 4C, Not found from 4A); the shell keeps the canvas-mode provider and gains the published popover.
The brand summary on Settings > Portal imported designAssetUrl from design-view and the font list from a module that loaded every design option, which pulled the portal's design view (rich words, page choices, options) into admin settings: seven more scripts. It writes the asset address itself now, and fonts.ts keeps its own heading weights, held equal to the design options by a test.
The portal head's page names and descriptions for every language lived in portal-head.ts, which route heads pull into the entry, so every page's script carried about 8 KB of words it never used, admin and widget included. The table moves to the server as templates; the workspace settings carry the main language's, and the client fills them in, with English as the fallback for settings cached before.
The portal shell imported the not-found page for every page, and with it the search field, which then split into one more script on the post page, changelog, help center and others. It loads on demand now.
…glish The workspace settings are serialised into every page, the widget's included; English is the client's own fallback, so only another language's words need to travel with them.
This branch has not been deployed
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.
Wave 4 of the portal builder critique: carry the brand beyond the portal page, and make what the builder says match what visitors see. Stacked on #926.
Brand beyond the page
Tab, favicon and link previews (G01).
/brand.png?kind=icon), then the workspace favicon or logo, then the default./brand.png(page colour, icon, title, page name, brand bar), usingsummary_large_image. It uses the in-repo font rasterizer and PNG encoder, with no new dependency and no capture service. Each address is keyed by a hash of what the card shows, so it's cached for good.theme-colormeta tags for light and dark.Visitor emails in the brand (G02). An email palette is derived from the published design:
The palette reaches 19 visitor templates. Gold stays as the fallback, and team emails stay gold. A read-only sign-in email sample appears in the publish review.
Missing pages and errors stay inside the portal (G03). They keep the header and footer and show the brand icon instead of the duck.
_portal/$.The address after publishing (G05).
Readable colours
color-mixto ±1 per channel.What the builder shows
effectiveValuesnow reads the element's own style before the tune, and the AI gets the corrected values./admin/settings/appearanceredirects to Settings › Portal.srcset,sizesandfetchpriority="high". Designs that store a single image keep working.Perf
After merging, the core bench was clean except for two leaks, both fixed here:
fonts.tsloaded every design option, which added one request on portal pages.Both journeys now load the same scripts as Wave 3.
Testing
/brand.png/admin/settings/appearanceredirect@smoke, so CI doesn't run them.summary_large_image, a posts and feedback description, and light and darktheme-color./brand.pngrenders the card.Notes
/admin/...address now goes through the portal's catch-all first, so on a private portal an anonymous visitor sees the sign-in gate there instead of a plain 404.srcset.