Skip to content

feat(builder): wave 4, the brand beyond the page and truthful output - #927

Draft
mortondev wants to merge 23 commits into
feat/builder-9-wave3from
feat/builder-10-wave4
Draft

mortondev wants to merge 23 commits into
feat/builder-9-wave3from
feat/builder-10-wave4

Conversation

@mortondev

Copy link
Copy Markdown
Member

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

    • Favicon: the design favicon, then the brand icon (square-fitted at /brand.png?kind=icon), then the workspace favicon or logo, then the default.
    • Titles: use the Brand kit title.
    • Share card: drawn as a 1200×630 card at /brand.png (page colour, icon, title, page name, brand bar), using summary_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.
    • Descriptions: in posts and feedback words, with translations for all 13 locales. Until a main-language setting exists, they come out in the help center's default language (English today).
    • Theme colour: theme-color meta tags for light and dark.
    • Site images: the captions are corrected, and a link preview sits under them. The same preview appears in the publish review.
  • Visitor emails in the brand (G02). An email palette is derived from the published design:

    • Buttons use the brand colour, with text from the portal's contrast solver.
    • Links are darkened until they reach 4.5:1.
    • The brand icon is used as the logo when it's PNG, JPEG or GIF. SVG is never sent.

    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.

    • A missing post links to its board and offers a search.
    • Unknown portal addresses return 404 with noindex through _portal/$.
    • There's a "Not found" sample page in the page switcher.
  • The address after publishing (G05).

    • A popover on Publish shows the live address with Copy and Open.
    • On Cloud with no custom domain, and for people allowed to set one, it also links to "Use your own domain".

Readable colours

  • Every surface solved (F20).
    • Links, secondary text and the focus ring are now solved against tinted blocks, second-colour vote boxes and the footer pill.
    • Tints are computed in OKLCH from one shared constant, matching Chromium's color-mix to ±1 per channel.
    • Theme › Colours reports from the same list.
  • Headlines at 3:1 only when large (F55). That applies when the smallest size is at least 24px, or 18.66px bold, phones included. Otherwise the headline needs 4.5:1.
  • Dark mode for exact colours (F08).
    • Exact colours get a dark counterpart: white and black map to the card and text colours, a near-role colour snaps to that role, and any other colour has its lightness mirrored.
    • The colour picker opens in the canvas's current mode.
    • A new "stays light in dark mode" check has a one-click fix.
  • Size floors (F56). Portal text is at least 12px, and the dialog, sheet and banner Close buttons are 24px targets. A guard test rejects arbitrary text sizes under 12px.

What the builder shows

  • The value in use (F12). A muted second line shows what the canvas actually draws when it differs from the stored value, for example "56 max · 31 on this phone" or "Shown as #8F8F8F so it reads", with "Use this" to store it. effectiveValues now reads the element's own style before the tune, and the AI gets the corrected values.
  • Settings › Portal (F39).
    • The card shows a live thumbnail, the logo, colour swatches and font names, with Match your brand and Open builder.
    • /admin/settings/appearance redirects to Settings › Portal.
  • A widget sample (G04). A read-only Widget page in the page switcher draws the real widget in the draft design, and the publish compare can show it too. The lightbulb icon is gone.
  • Responsive hero images (F46). Uploads are scaled down in the browser to at most 2560px, with 640 and 1280 WebP copies. The side hero image gets srcset, sizes and fetchpriority="high". Designs that store a single image keep working.

Perf

After merging, the core bench was clean except for two leaks, both fixed here:

  • Settings › Portal had imported the portal's whole design view, which added 7 scripts to admin settings.
  • fonts.ts loaded every design option, which added one request on portal pages.

Both journeys now load the same scripts as Wave 3.

Testing

  • Typecheck, lint and Prettier are clean, and the route tree was regenerated for the three new routes:
    • /brand.png
    • the portal catch-all
    • the /admin/settings/appearance redirect
  • 9,112 app tests and 315 email package tests pass. A Playwright contrast spec renders every design fixture at 1280px and 375px, in each mode the design shows, and measures text contrast as Chromium paints it: 43 of 43 pass. Neither that spec nor the 375px floors spec is tagged @smoke, so CI doesn't run them.
  • Checked on a dev server:
    • The head carries the share card, summary_large_image, a posts and feedback description, and light and dark theme-color.
    • /brand.png renders the card.
    • A missing post renders inside the portal with search and "Go to the board".
    • An unknown address returns 404.

Notes

  • An unknown /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.
  • Background hero images (cover, edge, pattern) use the downscaled original rather than a srcset.

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

No deployments
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