Add dark mode to the marketing site - #2246
Merged
Merged
Conversation
The marketing pages (/, /blog, /changelog, /privacy) were light-only. Add a .dark token block to landing.css mirroring the app theme's dark anchors (canvas oklch(0.195), ink oklch(0.81)) so the derived surface/border ramp re-resolves coherently; tokenize the remaining hardcoded light values (headings, strong body ink, shadow base); and give the Telegram mock its platform-native dark appearance. The nav gains a sun/moon toggle that flips the same html.dark class the pre-paint script sets and persists to the bb.theme key the app and dashboard already read. The logo swaps to the brand's white glyph in dark (new bb-icon-dark.png derived from assets/bb-logo-white.png, optically matched to the light tile's glyph), in the site nav and the connect dashboard brand row. The theme-color meta now follows the resolved theme. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replace the two-state nav toggle with the app's theme model: a preference of light | dark | system (default system) stored under bb.theme. The button shows the resolved sun/moon and opens a Light / Dark / System menu; a prefers-color-scheme listener re-applies the theme while the preference is system, so the page flips when the OS does, and a storage listener picks up a choice made in another tab or the app. Lift .nav above the hero so the open menu paints over the animated announcement pill (the filled entrance animation leaves the nav a stacking context). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
With System selected the button showed the sun or moon for whatever the OS resolved to, which read as "Light" or "Dark" being chosen. Stamp data-theme-preference on <html> pre-paint (THEME_INIT) and on every change (applyThemePreference), and key the glyph off it: sun / moon / monitor for light / dark / system, with system as the no-attribute default. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
What was wrong - The dark-mode logo filter assumed every company mark was a glyph on transparent. Four of them (Blackstone, Moody's, Notion, Owner.com) and Shortcut bake their own tile, so `invert(1)` flipped the tile instead of the glyph: Blackstone and Moody's became bright squares, Owner.com a dark blob. - The white app-icon tile still rendered in the Telegram card and spawnbar mocks, the glare the nav and dashboard swaps were added to avoid. - THEME_INIT read localStorage first inside one try, so a storage throw (Safari "block all cookies", sandboxed frames) aborted the script before it set the dark class, and nothing re-applied the theme on mount. The page stayed light while the control reported "System". - The DOMContentLoaded retint edited the theme-color meta's content. React 19 hydrates a hoistable <meta> by matching content, so it missed and appended a duplicate #ffffff meta. - The storage and scheme listeners updated the document but not React state, so an open menu kept a checkmark the page no longer agreed with. - Both logo PNGs downloaded on every page: display:none does not stop an <img> fetch. What changed - lib/theme.ts owns the preference model; lib/theme-init.js holds the pre-paint script and is imported with `?raw` so the server and client embed identical text. Deriving it from toString() does not work: esbuild's SSR and client transforms differ by stray semicolons, which is a hydration mismatch. - <head> ships one theme-color meta per scheme, so browser chrome follows the OS with no JS. An explicit preference narrows them by `media`, which React does not compare when hydrating. The five per-route metas are gone. - Tile-backed company marks are flagged in the data and excluded from the invert. - One .bb-mark element per site, with the asset chosen in CSS, so only the variant in use is fetched. It covers the nav, dashboard and both mocks. - The Telegram surface moves to a --tg-card token with both scheme values. How you verified - New tests in lib/theme.test.ts run the shipped THEME_INIT string against stub globals: 4 of the 6 fail against the previous script, all 6 pass now. - End to end against the dev and production builds: no hydration errors, exactly two theme-color metas on all five routes, one icon request per load instead of two, dark honoured with storage access throwing, and an open menu tracking a real cross-tab change. - typecheck, lint, test (79) and build pass for @bb/web. Also reformats __root.tsx, which prettier already failed on before this change.
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.
Summary
Adds dark mode to the marketing site (
/,/blog,/blog/:slug,/changelog,/privacy).landing.cssmirroring the app theme's dark anchors (canvasoklch(0.195 0 0), inkoklch(0.81 0 0)) and its dark chromatic tokens. Every surface, border, and state fill in the marketing styles already derives from the--bg/--inkanchors, so the ramp re-resolves coherently; mix percentages step up the same way the app's dark ramp does.--heading,--heading-sub,--ink-strong, and a--shadebase for shadows/scrims, pinned to black in dark so shadows don't become glows).blog.cssandchangelog.csspick the tokens up unchanged.light | dark | system(defaultsystem) stored under the samebb.themekey the app and dashboard read. The button shows the preference (sun / moon / monitor) and opens a Light / Dark / System menu (menuitemradio, Escape/outside-click dismiss). Aprefers-color-schemelistener re-applies the theme while the preference issystem, so the page flips live when the OS does; astoragelistener picks up a choice made in another tab or in the app. The pre-paint script stampsdata-theme-preferenceon<html>(alongside thedarkclass) so the glyph is right from first paint; all three glyphs render and CSS picks one, so SSR output is preference-independent and hydration can't mismatch. The menu only exists while open, so its checked state is read from storage at open time.bb-icon-dark.png(the brand's white glyph fromassets/bb-logo-white.png, scaled so its glyph optically matches the light tile's), used in the site nav and the connect dashboard brand row.theme-colormeta follows the resolved theme (the pre-paint script retints it because the router dedupes metas by name, so two media-scoped metas can't coexist).Screenshots
Before (merge base
c942421a4) / After (PR head): same route, viewport, and emulatedprefers-color-scheme, captured from local dev servers at each revision.Dark OS preference, desktop 1440px — before the site ignored it and stayed light
Light OS preference, desktop 1440px — unchanged apart from the new nav toggle
Dark OS preference, mobile 390px
After only — the new theme menu (no before counterpart; the control is new)
Full-page screenshots of every marketing page (PR head)
Each page at three widths — narrow/PWA (390px), laptop (1440px), large external display (2560px) — in light and dark, captured full-page from the PR head after scrolling through so scroll-reveal sections and the self-assembling hero mock have rendered. The changelog is 23–34k px tall, beyond what Chrome can capture in one image, so it is shown in labeled vertical segments (DPR 1) that together cover the whole page.
Home — narrow 390px
Home — laptop 1440px
Home — large display 2560px
Blog index — narrow 390px
Blog index — laptop 1440px
Blog index — large display 2560px
Blog post — narrow 390px
Blog post — laptop 1440px
Blog post — large display 2560px
Changelog — narrow 390px (6 vertical segments)
Changelog — laptop 1440px (3 vertical segments)
Changelog — large display 2560px (2 vertical segments)
Privacy — narrow 390px
Privacy — laptop 1440px
Privacy — large display 2560px
Validation
pnpm typecheckand the fullapps/webvitest suite (13 files, 79 tests) pass..darkpre-paint with no light flash, and thetheme-colormeta resolves to the current canvas.Review follow-up (026768d)
A code review of the first three commits found eight issues; this commit fixes them.
Dark mode rendered wrong in three places.
.dark .company-proof-company img { filter: grayscale(1) invert(1) }assumed every company mark was a glyph on transparent, but Blackstone, Moody's, Notion, Owner.com and Shortcut bake their own tile, so the invert flipped the tile rather than the glyph — Blackstone and Moody's came out as bright squares, Owner.com as a dark blob. The marks are now flagged inCOMPANY_PROOFand tiles are excluded from the invert. The white app-icon tile also still rendered in the Telegram card and spawnbar mocks, which is the glare the nav and dashboard swaps were added to avoid.THEME_INITgave up when storage access threw. It readlocalStoragefirst inside onetry, so on Safari's "block all cookies" or in a sandboxed frame the script aborted before setting the dark class, and nothing re-applied the theme on mount: the page stayed light while the nav control reported "System". The storage read now has its owntry, and the mount effect reconciles the document.The
theme-colorretint appended a duplicate meta. React 19 hydrates a hoistable<meta>by matching itscontentattribute (react-dom-client,getHydratableHoistableCache("meta", "content")), so rewriting content to#151515onDOMContentLoadedmade hydration miss and append a second#ffffffmeta.<head>now ships one meta per scheme — browser chrome follows the OS with no JS — and an explicit preference narrows them bymedia, which React does not compare. The five per-routetheme-colorentries are gone.Smaller fixes. The storage and scheme listeners updated the document but not React state, so an open menu kept a checkmark the page no longer agreed with. Both logo PNGs downloaded on every page, because
display:nonedoes not stop an<img>fetch; one.bb-markelement per site now picks its asset in CSS, covering the nav, dashboard and both mocks. The Telegram surface moved to a--tg-cardtoken with both scheme values, replacing a rawoklch(0.28 0 0)in a component rule.One note on the shared script.
lib/theme.tsowns the preference model andlib/theme-init.jsholds the pre-paint script, imported with?rawso the server and client embed identical text. Deriving it from a compiled function'stoString()does not work: esbuild's SSR and client transforms re-print it with different stray semicolons, which React reports as a hydration mismatch on every page load. That was caught in the browser before it shipped.Verified
apps/web/src/lib/theme.test.tsruns the shippedTHEME_INITstring against stub globals. Four of its six tests fail against the previous script and all six pass now, covering the storage-throw path and the content-is-never-edited rule.theme-colormetas on all five routes, one icon request per load instead of two, dark honoured with storage access throwing, and an open menu tracking a real cross-tab write.typecheck,lint,test(79) andbuildpass for@bb/web.Also reformats
__root.tsx, which prettier already failed on before this change.BB-Thread-ID: thr_49n689amph
🤖 Generated with Claude Code