Skip to content

feat(design): Home, the portal's words, parts, custom CSS and uploaded fonts - #879

Closed
mortondev wants to merge 23 commits into
feat/portal-design-v2-1-enginefrom
feat/portal-design-v2-2-home-parts
Closed

mortondev wants to merge 23 commits into
feat/portal-design-v2-1-enginefrom
feat/portal-design-v2-2-home-parts

Conversation

@mortondev

@mortondev mortondev commented Nov 1, 2026 •

Copy link
Copy Markdown
Member

Home becomes a page of its own, built from blocks, and the portal speaks the workspace's own words. The portal's parts get stable names, so a workspace's own CSS can restyle them safely.

In this PR

  • Home, with the feed at /feedback; edit Home's blocks and the menu in Appearance; new workspaces start on Home
  • The portal's words: post and board vocabulary, own wording and headlines in every language, and emails that use the same words
  • The portal's parts, with stable hooks for workspace CSS
  • The workspace's own CSS, checked as it is saved, and switching a portal that has its own CSS
  • Fonts preloaded during SSR, and uploading the portal's own font

Replaces

#846, #847, #848, #849, #850, #851, #852, #853, #854, #855, #856, #857, #858

Second of five stacked PRs.

On the design engine, Home is a page of its own at the root, built from
blocks on a six-column grid: Welcome, Share an idea, Top ideas, Roadmap,
Latest updates, Help search, Help topics, Status, Contact and Links. Each
block shows live data in the brand's cards and type, draws nothing when its
page is off or it has nothing to show, and streams into the first response.

With Home on, the feed moves to /feedback and an old link to the feed at the
root follows it there for good. With Home off, the root is the first page
the menu shows: the feed as before when that is Feedback, else a redirect.
The legacy theme keeps the feed at the root. The menu comes from the design,
where Feedback can be hidden and Home can have a tab, and every portal link
to the feed takes its path from portalPaths; a test keeps a literal root out
of portal code. The sitemap and canonical tags follow the same rule.

The cached workspace settings are now kept per build, so the first requests
after an upgrade never read a shape an older build wrote.
The cached workspace settings are kept per build, so the tests that seed or
forget that copy use CACHE_KEYS.WORKSPACE_SETTINGS rather than the old literal
key, which no longer reaches it.
Home's code reached pages that never show it, and the feed's chunk lost its
preload at the root.

- Share an idea on Home is a light form. What a visitor types goes to
  /feedback in the navigation's history state, and the feed's composer opens
  with it once per arrival, so Home loads neither the composer nor the
  anonymous-session code.
- Home takes the page gates from the loader, and its module is linked to the
  portal layout as the root's own split would be, so what it shares with the
  layout stays in the layout's chunk.
- The feed's split also re-exports the root's component split. The server
  preloads the chunks holding a route's splits, so the feed is preloaded at
  the root again, and at /feedback.
- The post actions, the sitemap's paths and the feed-search check live
  outside the modules every page loads.

The choice between Home, the feed and a redirect still runs before any route
chunk loads, about 1.1 KB in the entry, so the pages that sat within it of
their script ceilings take their new readings.
Home gets its own section in Sections, and the preview shows it.

- Turning Home on starts it with a layout built from the pages the
  workspace has: Welcome, Share an idea with Status, Top ideas with Latest
  updates, the roadmap and help topics.
- Home's headline, then its blocks in order. Each block takes a width it
  allows, moves up or down, and goes; Top ideas takes an order and a count,
  Latest updates a count, Roadmap which roadmap, Links a heading and its
  links, and Welcome its text. A block whose page is off says so. A link
  reaches the draft once it has a name and a whole web address.
- With Home on, the preview switches between Home and the feed at
  /feedback, and changing Home shows Home. Picking a block in the preview
  opens its row. While Home is on, the band's own headline is named the
  Feedback headline.
- The preview carries the draft's Home and menu, and the root shows Home to
  a preview that asks for it with page=home, whatever the saved design
  leads with, with every block's strings.
- Review and publish names Home's changes and counts its blocks; an empty
  headline reads None.

The portal path hooks move to components/public, where their callers live,
since they read the preview's draft menu.
Its icons were modules it shared with the post page, the inbox and other
admin pages, which split them into chunks of their own there: one or two
more requests on each.
On the design engine the header's menu comes from the design, but the
Navigation tab still edited the legacy menu, so its changes never showed.
The menu now lives in Sections, under the header, and writes the draft.

- While Home is on: Home in the menu, and its tab's name.
- The pages and links in order, each shown or hidden and renamed, with
  links added and removed, in the same editor as before. Feedback can be
  hidden too, as the design engine allows.
- The rows reach the draft once every link in them is whole and, with Home
  off, they show a page to start on; until then a note says what is
  missing. A menu changed elsewhere, such as by a discard, replaces them.
- Review and publish names the menu and Home's tab name, and counts what
  the menu shows.

The Portal settings page on the design engine is Appearance alone; its
Navigation tab is gone.
Appearance now imports the portal header's menu module for the menu
editor, which moves a chunk boundary the post page loads across: 0.2 KB
more script there, just over a ceiling it sat at.
Setup publishes the design a new workspace starts with: its branding
converted, as an established workspace's is on read, with Home on and laid
out from the pages its goals turned on. It is the first version, from
setup, with no author.

Only setup does this, and only for a new workspace with the design engine
on and no custom CSS a switch would have to carry over. A workspace that
already has a design keeps it, and an established workspace's root never
changes on upgrade.

The setup tests that stand in for the settings helpers now pass the real
ones through, since setup reads the portal config to lay Home out.
A workspace chooses what visitors call what they post (Idea, Post, Feature
request or Suggestion) and what groups them (Board or Category), and the
portal, the widget and the unsubscribe page say it in every language.

- Brand > Words in Appearance: a tile per choice with a sentence the portal
  would say. The preview shows it before publishing, Home's block names
  follow it, and the menu labels and English headlines that still name the
  old word are offered for rewording in the same draft.
- 112 visitor strings select on postWord and boardWord, with idea and board
  as each select's other branch; 30 that use the words another way stay.
  PortalWordsIntl passes the words into every message, so no call site
  changes: the portal, unsubscribe and widget set the published words, the
  preview the draft's, and the admin and onboarding keep post and board.
  Each nested provider applies them again.
- All 12 other catalogues word every choice, each language with one noun
  per choice anchored on its translator's terms.
- A guard fails an English visitor string that names a post or board
  without the select, and any catalogue whose choices fail to format, read
  alike, or miss the default. The placeholder parity check now parses
  messages, since a select branch's first word is not a placeholder.
- The Boards settings say what visitors see where it differs from the
  admin's words.

The English of each converted string, every choice included, lives in the
code, so the post page's script ceilings take their new readings.
A notification row is stored in the admin's words, a post and a board, and
the bell words it again in the reader's: the portal's own word there. The
parity check now formats as the admin does.
The portal now says idea by default: the composer's field is Idea title,
the empty filter state says No ideas match your filters, and a new post's
toast says Idea submitted.
The status change, new comment, mention and linked-feedback emails said
"your feedback" and "View Feedback". They now say the portal's word:
"Your suggestion is now Planned!", "New comment on your idea", "View
feature request", and the status subject too. Idea when none is chosen.

The hook context reads the word as the portal does, from the workspace
settings' design, once per event, and the post subscribers' and mention
targets carry it to the senders. An email still goes out, saying idea, if
the design cannot be read.
A workspace can write three strings in its own words, per language: the
composer's prompt, its submit button and the empty feed's title, under
Brand > Words. The portal's composer, the empty feed and Home's Share an
idea use them for readers of that language. A language left blank says the
chosen word's translation, never another language's wording.

The feed's and Home's headlines get a language picker, so each language is
written on its own.

The portal layout provides the saved wording through FeedCopyContext, in a
module the feed and Home already share, and the preview provides a draft's
in its place. The widget embed and the admin feedback page, which sat at
their script ceilings, take their new readings.
Main already reads 473.5 KB on the admin feedback page against its 466.1 KB
ceiling, inside the 2% tolerance. The stack's 2 KB took five admin pages
past it.
A workspace's CSS can style the portal by part name rather than by class
names that change with the code: [data-qb-part="post-card"].

- Twelve parts are marked: header, logo, nav-item, band, headline,
  home-block, composer, post-card, post-title, vote-button, status-badge
  and filter-panel. States are data-state: active for the current menu
  item, open for a composer being written in, voted for a voted vote box,
  done for a post whose status is complete or closed.
- lib/shared/design/parts.ts lists each part's states, the tokens that style
  it and the files that draw it, with aliases for renamed parts and
  rewritePartAliases, which keeps old names working.
- A contract test keeps every listed part marked where it is drawn, with
  its states, fails on a part the manifest does not list, and checks each
  documented token is one the design declares.

Every card now carries its part names, so the portal home's HTML ceilings
take their new readings.
A workspace can write its own CSS over the design in Sections, styling the
portal's parts by name. It renders in the tenant layer, the preview shows
it, and review counts it in lines.

The check on save refuses @import, url() anywhere but the workspace's own
storage, expression(), behavior and <, as a refusal with the line. It warns
on internal class names, offering the part to use, which Fix it for me
swaps in, and on !important, tokens no part reads, unknown or renamed
parts, and text and background colours on a part below 4.5:1. Old part
names are rewritten as the design is read.

It parses with css-tree's self-contained build, pure JavaScript with its
data inlined, so the server bundle needs neither a native module nor data
files beside it.

Custom CSS stays a plan feature: new or changed CSS asks the plan, while CSS
already published stays and removing it is always allowed.
A portal with its own CSS stays on its old theme until someone switches.
Its old Settings › Portal page now offers the switch: today's portal beside
the portal switched, a report on the CSS, and Switch, held while the CSS
has refusals. History offers the way back.

The switch publishes today's branding converted, with the theme variables
the CSS sets on :root and .dark read in first, since the design sets those
variables itself and a tenant-layer :root would stop applying. The CSS
carries over unchanged without asking the plan, as it is the portal's
already. Switching back unpublishes the design, so the old theme, never
touched, shows again; the design's versions stay. Both are audited.

The switched frame is drawn by the engine on the server: with
?design=switch the bootstrap draws the portal switched, for a dashboard
session that can edit branding only.

The CSS check now offers the post-card part for .post-card.
A family loads with a dynamic import of its CSS module, so the client build
gives its files hashed names the server could not know, and a design's
fonts arrived only once the page's script asked: text drew in a fallback
face and swapped after hydration.

A build plugin now records each font module's stylesheet and Latin woff2
files from the client build, by the font id the loader gives it, and hands
them to the server build as a virtual module. The design view carries the
links: each family's stylesheet and at most two preloads, the body at 400
and the headings at their weight or the nearest the family has. Inter
ships with the app's own stylesheet and needs neither.

Three admin pages' script ceilings take their readings: main already reads
about 2% over them, and these bytes leave them under 0.1 KB from the limit.
Importing the type from the build plugin gave server code a dependency on
lib/build, which the dependency graph does not allow for.
Brand › Fonts gains Your own font: up to six woff2 files with the weight
and style read from each name, a family name, headings, body or both, and
a licence to tick before anything uploads. The draft's font becomes the
uploaded family, with the font the slot had as its fallback; both pickers
list it, and review names the change by the family.

The upload URL signs font/woff2 under fonts/ for the branding permission,
refusing other names, files over 400 KB, no storage and a plan without
custom CSS as values. The upload itself checks the wOF2 signature, the
prefix and the size. fonts/ is public and always proxied from the portal's
own origin, since @font-face fetches in CORS mode. The schema takes an
uploaded key only as a woff2 under fonts/ in characters that cannot end a
url().

The theme's CSS carries an @font-face rule per file, and the head preloads
the file nearest the body's and headings' weights.
The app's heading and tracking rules were unlayered, so they beat every
layer, the workspace's tenant CSS included: a portal's own CSS could not
change its headline's weight or tracking. On a portal drawn by the design
engine they now sit in a design layer between utilities and tenant. They
still beat utilities, as before, and the workspace's CSS beats them.

The admin and portals on the old theme keep the unlayered rules, scoped
with :where so their specificity is what it was.
@mortondev
mortondev added this pull request to stack #883 November 1, 2026 00:25
@mortondev mortondev changed the title feat/portal design v2 2 home parts feat(design): Home, the portal's words, parts, custom CSS and uploaded fonts Nov 1, 2026
@mortondev

Copy link
Copy Markdown
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.

@mortondev mortondev closed this Nov 7, 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