Skip to content

Add dark mode to the marketing site - #2246

Merged
SawyerHood merged 4 commits into
mainfrom
bb/web-dark-mode-thr_49n689amph
Aug 21, 2026
Merged

Add dark mode to the marketing site#2246
SawyerHood merged 4 commits into
mainfrom
bb/web-dark-mode-thr_49n689amph

Conversation

@brsbl

@brsbl brsbl commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds dark mode to the marketing site (/, /blog, /blog/:slug, /changelog, /privacy).

  • Dark token block in landing.css mirroring the app theme's dark anchors (canvas oklch(0.195 0 0), ink oklch(0.81 0 0)) and its dark chromatic tokens. Every surface, border, and state fill in the marketing styles already derives from the --bg/--ink anchors, so the ramp re-resolves coherently; mix percentages step up the same way the app's dark ramp does.
  • Remaining hardcoded light values tokenized (--heading, --heading-sub, --ink-strong, and a --shade base for shadows/scrims, pinned to black in dark so shadows don't become glows). blog.css and changelog.css pick the tokens up unchanged.
  • Nav theme control with the app's theme model: a preference of light | dark | system (default system) stored under the same bb.theme key 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). A prefers-color-scheme listener re-applies the theme while the preference is system, so the page flips live when the OS does; a storage listener picks up a choice made in another tab or in the app. The pre-paint script stamps data-theme-preference on <html> (alongside the dark class) 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.
  • Logo swap in dark: new bb-icon-dark.png (the brand's white glyph from assets/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.
  • Platform-native details: the Telegram band mock follows Telegram's own dark appearance; grayscale company logos invert so near-black marks stay legible; theme-color meta 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 emulated prefers-color-scheme, captured from local dev servers at each revision.

Dark OS preference, desktop 1440px — before the site ignored it and stayed light

Before After
Before: dark preference ignored, page stays light After: full dark theme with nav toggle

Light OS preference, desktop 1440px — unchanged apart from the new nav toggle

Before After
Before: light After: light, with theme toggle in nav

Dark OS preference, mobile 390px

Before After
Before: mobile stays light After: mobile dark

After only — the new theme menu (no before counterpart; the control is new)

After: Light / Dark / System menu open, System checked

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
Light Dark
Home, narrow, light Home, narrow, dark
Home — laptop 1440px
Light Dark
Home, laptop, light Home, laptop, dark
Home — large display 2560px
Light Dark
Home, large, light Home, large, dark
Blog index — narrow 390px
Light Dark
Blog, narrow, light Blog, narrow, dark
Blog index — laptop 1440px
Light Dark
Blog, laptop, light Blog, laptop, dark
Blog index — large display 2560px
Light Dark
Blog, large, light Blog, large, dark
Blog post — narrow 390px
Light Dark
Post, narrow, light Post, narrow, dark
Blog post — laptop 1440px
Light Dark
Post, laptop, light Post, laptop, dark
Blog post — large display 2560px
Light Dark
Post, large, light Post, large, dark
Changelog — narrow 390px (6 vertical segments)
Segment Light Dark
Part 1 (0–6,000px) Changelog, narrow, light, part 1 Changelog, narrow, dark, part 1
Part 2 (6,000–12,000px) Changelog, narrow, light, part 2 Changelog, narrow, dark, part 2
Part 3 (12,000–18,000px) Changelog, narrow, light, part 3 Changelog, narrow, dark, part 3
Part 4 (18,000–24,000px) Changelog, narrow, light, part 4 Changelog, narrow, dark, part 4
Part 5 (24,000–30,000px) Changelog, narrow, light, part 5 Changelog, narrow, dark, part 5
Part 6 (30,000–34,120px) Changelog, narrow, light, part 6 Changelog, narrow, dark, part 6
Changelog — laptop 1440px (3 vertical segments)
Segment Light Dark
Part 1 (0–8,000px) Changelog, laptop, light, part 1 Changelog, laptop, dark, part 1
Part 2 (8,000–16,000px) Changelog, laptop, light, part 2 Changelog, laptop, dark, part 2
Part 3 (16,000–23,352px) Changelog, laptop, light, part 3 Changelog, laptop, dark, part 3
Changelog — large display 2560px (2 vertical segments)
Segment Light Dark
Part 1 (0–12,000px) Changelog, large, light, part 1 Changelog, large, dark, part 1
Part 2 (12,000–23,352px) Changelog, large, light, part 2 Changelog, large, dark, part 2
Privacy — narrow 390px
Light Dark
Privacy, narrow, light Privacy, narrow, dark
Privacy — laptop 1440px
Light Dark
Privacy, laptop, light Privacy, laptop, dark
Privacy — large display 2560px
Light Dark
Privacy, large, light Privacy, large, dark

Validation

  • pnpm typecheck and the full apps/web vitest suite (13 files, 79 tests) pass.
  • Screenshot sweep of every marketing route in both schemes at desktop and mobile widths, plus close-ups (changelog media cards, tweet embeds, Telegram band settled state, open menu in light/dark/mobile).
  • Theme control exercised end-to-end over CDP: a fresh visitor defaults to System (checked in the menu) and the page flips live when the emulated OS scheme changes; choosing Light/Dark persists across reloads and ignores OS flips; choosing System returns to following the OS; Escape closes the menu and returns focus to the button, outside click closes it. A dark-OS visitor gets .dark pre-paint with no light flash, and the theme-color meta 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 in COMPANY_PROOF and 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_INIT gave up when storage access threw. It read localStorage first inside one try, 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 own try, and the mount effect reconciles the document.

The theme-color retint appended a duplicate meta. React 19 hydrates a hoistable <meta> by matching its content attribute (react-dom-client, getHydratableHoistableCache("meta", "content")), so rewriting content to #151515 on DOMContentLoaded made hydration miss and append a second #ffffff meta. <head> now ships one meta per scheme — browser chrome follows the OS with no JS — and an explicit preference narrows them by media, which React does not compare. The five per-route theme-color entries 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:none does not stop an <img> fetch; one .bb-mark element per site now picks its asset in CSS, covering the nav, dashboard and both mocks. The Telegram surface moved to a --tg-card token with both scheme values, replacing a raw oklch(0.28 0 0) in a component rule.

One note on the shared script. lib/theme.ts owns the preference model and lib/theme-init.js holds the pre-paint script, imported with ?raw so the server and client embed identical text. Deriving it from a compiled function's toString() 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

  • New apps/web/src/lib/theme.test.ts runs the shipped THEME_INIT string 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.
  • End to end against the dev server and the production build: 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 write.
  • typecheck, lint, test (79) and build pass for @bb/web.

Also reformats __root.tsx, which prettier already failed on before this change.

BB-Thread-ID: thr_49n689amph

🤖 Generated with Claude Code

AGENT GENERATED

brsbl and others added 4 commits August 21, 2026 02:57
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.
@SawyerHood
SawyerHood merged commit 411c777 into main Aug 21, 2026
13 checks passed
@SawyerHood
SawyerHood deleted the bb/web-dark-mode-thr_49n689amph branch August 21, 2026 23:19
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.

2 participants