Skip to content

feat(design): upload the portal's own font - #857

Closed
mortondev wants to merge 2 commits into
feat/portal-design-v2-phase5-font-preloadfrom
feat/portal-design-v2-phase5-font-uploads
Closed

mortondev wants to merge 2 commits into
feat/portal-design-v2-phase5-font-preloadfrom
feat/portal-design-v2-phase5-font-uploads

Conversation

@mortondev

@mortondev mortondev commented Oct 31, 2026 •

Copy link
Copy Markdown
Member

The twenty-fourth of the stack that builds a new design engine for the portal, and the fifth of phase 5. A workspace can upload its own font for the portal's headings, body or both, so a brand with a licensed face is not limited to the bundled families. Behind the portalDesignV2 flag.

In Brand › Fonts

The font controls move out of the Brand panel into fonts-section.tsx, with a closed "Your own font" disclosure under the pickers:

  • Choose up to six woff2 files. Each gets a weight and style read from its name (Brand-SemiBoldItalic.woff2 is 600 italic), which can be changed, and the family name comes from the first file.
  • Pick where to use it: headings, body, or both.
  • Tick "I have a licence to use this font on the web." Nothing uploads before that.
  • Add font: each file goes to storage, and the draft's font becomes the uploaded family. Its fallback is the font the slot had, which keeps drawing scripts the upload lacks.

Once added, the family is listed under "Your fonts" in both pickers, the heading weights offer the weights it has, and review names the change by the family ("Space Grotesk → Unbounded") rather than its fields. Without file storage the disclosure says "Uploading fonts needs file storage." and shows no controls.

Storage and serving

  • getFontUploadUrlFn signs an upload of font/woff2 under fonts/, for someone with the branding permission. It returns refusals as values: not a woff2 name, over 400 KB, no storage, or a plan without custom CSS.
  • The upload itself (PUT /api/storage/*) checks what the signature cannot: a font/woff2 upload must be under fonts/, start with the wOF2 signature and be at most 400 KB. Any other type still has to be an image whose bytes match.
  • fonts/ is a public prefix, since every visitor loads the files. They are always proxied from the portal's own origin, even without S3_PROXY: @font-face fetches in CORS mode, so a redirect to the bucket would depend on its CORS rules. Served with their stored type as a download with nosniff, they embed as fonts but never open as a page.
  • The design schema takes an uploaded file's key only as a woff2 under fonts/ made of characters that cannot end a url(), since the key is written into the page's CSS.

Rendering

  • The theme's CSS gains an @font-face rule per uploaded file (font-display: swap), from /api/storage/….
  • The head preloads the uploaded file nearest the body's 400 or the headings' weight, upright where it can be, alongside the bundled families' links from the previous PR.

Plan

Uploading fonts is gated on the plan's custom CSS, as the spec recommends. Pricing is the owner's call, so this is the line to change if it should sit elsewhere.

Not in this PR

Fallback metric overrides (size-adjust, ascent-override). They need the uploaded face's own metrics, which means decompressing the woff2 tables, so the fallback draws at its own metrics until the face loads.

Verification

  • On the local Kiln workspace, with Playwright: a PNG renamed .woff2 passed the browser's checks and was refused by the server's signature check, with its message. Unbounded Bold and Regular (from the OFL-licensed @fontsource/unbounded) got the name "Unbounded" and weights 700 and 400 from their file names. After the licence tick and Add font, the heading picker showed Unbounded, and the preview drew its headings in it with the face loaded. Review read "Heading font: Space Grotesk → Unbounded". After publishing, the live document preloaded the file from /api/storage/fonts/…, served from the portal as font/woff2, fetched once from the head, and the headline drew in Unbounded. No page errors.
  • New tests, each checked by breaking the code it covers: the upload URL signs font/woff2 under fonts/ for branding, refusing other names, large files, no storage and a plan without custom CSS; the upload refuses a non-woff2 body, a font outside fonts/ and one over 400 KB; a font is served from the portal without S3_PROXY; the key rule refuses other prefixes, traversal, dot segments and CSS-breaking characters; one @font-face rule per file and the body stack; the upright file nearest a weight; the preload from storage; review naming the family; weights and family from file names; the licence holding the upload; a refusal shown with the draft untouched.
  • On Bramble too, the same round passed with the licence ticked through its label.
  • The core bench holds every ceiling. Settings › Portal grows by about 2 KB for the fonts section, inside its ceiling; no other page changes.
  • Prettier also reflowed one long import in proxy-cache-tenant.test.ts while formatting that folder.

@mortondev
mortondev added this pull request to stack #833 October 31, 2026 08:07
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.
@mortondev

Copy link
Copy Markdown
Member Author

Condensed into #879. The stack is now five PRs, #878 to #882; this branch is kept as it was.

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