Skip to content

Dark-mode the document background, not just the rail, update text fonts in dark mode#6

Open
AnnaXWang wants to merge 10 commits into
mainfrom
hypeship/unify-doc-rail-scroll
Open

Dark-mode the document background, not just the rail, update text fonts in dark mode#6
AnnaXWang wants to merge 10 commits into
mainfrom
hypeship/unify-doc-rail-scroll

Conversation

@AnnaXWang

@AnnaXWang AnnaXWang commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Summary

The light/dark toggle themed the bar and rail but left the author's document (rendered in the sandboxed iframe) untouched, so picking dark left the doc area light — visibly inconsistent.

This forwards the toggle mode into the iframe via a new jh:themeMode message. The overlay (the only code that can touch the opaque-origin document) forces the document's color-scheme + background:

  • dark/light force the doc canvas + default text (!important, so an explicit pick wins over an authored background); auto removes the override and restores the doc exactly as authored.
  • The overlay re-samples after applying, so the chrome palette and dark-highlight treatment follow the document through the existing jh:theme round-trip — bar, rail, doc, and highlights all end up consistent.

Small, presentation-only change: +9 lines in the shell (one effect), +39 in the overlay (a jh:themeMode handler that sets color-scheme and an injected bg/fg style). No API/DB/anchoring changes.

Inherent limits (documented at the injection site)

  • Per-element authored colors still cascade — we can't safely invert an arbitrary design, so element-level color declarations are left alone.
  • @media (prefers-color-scheme) can't be driven from script, so a doc that themes itself only via that media query won't respond to the toggle.

Verification

  • tsc clean, vitest 108/108, next build clean.
  • Harness with a UA-default doc and an explicitly-light-authored doc: forced-dark flips both canvases dark with body text inverted; forced-light normalizes to white; auto restores the authored look.

Note: this PR was scoped down to just the dark-mode document change. The earlier single-scrollbar work (and its iframe-height-ratchet fix) is preserved on the hypeship/unify-scroll-archive branch, out of this PR.


Note

Medium Risk
Presentation-only but touches iframe postMessage, overlay DOM mutation, and removes the zero-JS cold path—regressions could affect theme flash, highlights on forced dark, or load behavior on comment-less docs.

Overview
The light/dark toggle now repaints the sandboxed document, not only the bar and rail. CommentsShell sends jh:themeMode (auto / light / dark) into the iframe on mode changes, on every jh:ready (via modeRef), and after a jh:ping retry loop so fast loads don’t miss the handshake.

In lib/docs/overlay.ts, the overlay applies forced color-scheme and page background, walks the DOM to recolor prose on the page canvas while skipping surfaces (code blocks, elements with their own background), and keeps jh:theme reporting authored colors so chrome in auto doesn’t stay tinted. Highlights use effective darkness; forced text is reapplied after comment paint and when focus classes are rebuilt.

PlainShell is removed and page.tsx always renders CommentsShell with overlay=1, so the theme control exists on every doc (rail can stay collapsed). DEFAULT_DARK fallback foreground is #ffffff.

Reviewed by Cursor Bugbot for commit 144b54b. Bugbot is set up for automated code reviews on this repo. Configure here.

@vercel

vercel Bot commented Jul 2, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
justhtml Ready Ready Preview, Comment Jul 7, 2026 12:29am

@AnnaXWang AnnaXWang changed the title Unify the doc and comment rail into one scrollbar Viewer: unify doc+rail scroll, and dark-mode the document Jul 2, 2026
@AnnaXWang AnnaXWang closed this Jul 2, 2026
The light/dark toggle themed the bar and rail but left the author's
document (rendered in the sandboxed iframe) untouched, so picking dark
left the doc area light — visibly inconsistent.

Forward the toggle mode into the iframe via a new jh:themeMode message.
The overlay (the only code that can touch the opaque-origin document)
forces the doc's color-scheme and, with !important, its background/text
so an explicit pick wins over an authored background; "auto" removes the
override and restores the doc exactly as authored. The overlay re-samples
after applying, so the chrome palette and dark-highlight treatment follow
the document through the existing jh:theme round-trip.

Per-element authored colors still cascade (we can't invert an arbitrary
design) and @media(prefers-color-scheme) can't be driven from script;
both are inherent and documented at the injection site.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@AnnaXWang AnnaXWang changed the title Viewer: unify doc+rail scroll, and dark-mode the document Dark-mode the document background, not just the rail Jul 2, 2026
@AnnaXWang AnnaXWang reopened this Jul 2, 2026
@AnnaXWang AnnaXWang force-pushed the hypeship/unify-doc-rail-scroll branch from a0fbf0c to 0207d85 Compare July 2, 2026 03:05
@AnnaXWang AnnaXWang changed the title Dark-mode the document background, not just the rail Dark-mode the document background, not just the rail, update text fonts in dark mode Jul 2, 2026
The forced-dark override set body text to #c9d1d9 (a light gray); use
#ffffff so default document text is white on the dark canvas.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Setting body color white didn't work: authored element rules (p,li
{color:#1a1a1a}, th{color:#444}) beat inheritance, so most text stayed
dark. But blanket-whitening every element breaks anything with its own
light background — code chips, pill badges, callout boxes would render
white-on-light.

Walk the DOM instead: recolor the text of every element sitting on the
page background, and skip any element with its own background (or a code
block) plus its subtree — generalizing "leave code alone" to badges and
boxes too. A first pass pins each such surface's authored text color
inline (so a whitened ancestor can't leak white into a code chip that
inherits its color). Links keep their accent.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The rail/comment-card colors are derived from the doc's sampled fg. On a
light doc forced dark, that fg is the authored dark text lifted only to
AA — a gray, so comment text read gray, not white. When the viewer forces
a theme, report the forced fg/bg from the overlay's sample so the chrome
palette matches the forced document (white comment text in dark), and set
the DEFAULT_DARK fallback fg to white for the pre-sample moment. Auto mode
still samples the doc so the chrome adapts to genuinely-dark docs.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@AnnaXWang AnnaXWang marked this pull request as ready for review July 2, 2026 03:47
@AnnaXWang AnnaXWang requested a review from rgarcia July 2, 2026 03:48
Comment thread lib/docs/overlay.ts
Comment thread lib/docs/overlay.ts
Two review findings: forcing dark left anchored-comment (highlight) text
dark-on-dark. The segment spans carry a wash background in light mode, so
the surface pass pinned their authored dark color; and whitenPage skipped
them, so an authored span color rule kept them dark once jh-dark turned
the wash into an underline-only treatment.

Treat data-jh-seg spans as prose, not surfaces: don't pin them, and DO
recolor them (color:#fff !important in dark, beating any span rule). A
segment inside a code block still sits inside a surface and is never
reached, so highlighted code text keeps its own color. Re-run the recolor
at the end of paint() so segments (re)created on reload/resize — which
carry no themeMode message — are recolored too.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@AnnaXWang

Copy link
Copy Markdown
Contributor Author

Addressed both highlight-segment findings (commit 5b00998).

Root cause: highlight wrapper spans (data-jh-seg) carry a wash background in light mode, so (1) the surface pass pinned their authored dark text color inline, and (2) whitenPage skipped them — so once jh-dark turned the wash into an underline-only treatment, the anchored-comment text stayed dark on the dark canvas.

Fix: treat data-jh-seg spans as prose, not surfaces — don't pin them, and recolor them via .jh-doc-fg (color:#fff !important in dark, which beats any authored span{color} rule). Segments inside a code block still sit within a surface and are never reached, so highlighted code text keeps its own color. Also re-run the recolor at the end of paint() so segments recreated on reload/resize (which carry no themeMode message) get recolored.

Verified against a real doc: the anchored "What's missing for prod" heading now renders white with its amber underline instead of near-invisible dark-on-dark.

Comment thread app/d/[slug]/CommentsShell.tsx
Comment thread app/d/[slug]/CommentsShell.tsx
Comment thread lib/docs/overlay.ts
Three review findings on the forced-theme flow:

- Theme applied after paint / omitted on ready: jh:themeMode was sent only
  from a useEffect keyed on overlayReady/mode. On jh:ready the shell posted
  anchors+reactions (→ paint) before that effect ran, briefly showing the
  authored doc; and on an iframe reload overlayReady stays true so the
  effect never re-fired, leaving the fresh overlay on the authored theme.
  Now the jh:ready handler sends jh:themeMode FIRST, on every ready, via a
  modeRef so it reads the current mode.

- Focus styles strip forced text class (High): applyFocusStyles rebuilt each
  segment with el.className = "d"+depth, dropping jh-doc-fg, so hover/focus
  reverted highlighted prose to dark-on-dark. It now preserves jh-doc-fg.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@AnnaXWang

Copy link
Copy Markdown
Contributor Author

Addressed all three findings (commit cc87ac0).

  • Theme mode applied after paint / omitted on ready — the shell now sends jh:themeMode from the jh:ready handler itself, before jh:anchors, so the overlay forces the theme before it paints (no authored-doc flash) and on every ready, including an iframe reload (where overlayReady stays true and the mode useEffect wouldn't re-fire). It reads the live value via a modeRef so there's no stale closure.

  • Focus styles strip forced text class (High)applyFocusStyles rebuilt each segment as el.className = "d"+depth, dropping jh-doc-fg; it now preserves it, so hover/focus keeps the forced text color instead of reverting highlighted prose to dark-on-dark.

Verified: after forcing dark and hovering an anchored segment, its class is d1 jh-doc-fg jh-hover with computed color: rgb(255,255,255). tsc / 108 tests / build green.

Comment thread app/d/[slug]/CommentsShell.tsx
Comment thread app/d/[slug]/CommentsShell.tsx
The light/dark toggle lived only in CommentsShell, so the cold path
(PlainShell — no comments and a viewer who can't comment) had no way to
toggle themes. The toggle is a viewer feature, not a comments one, so it
must be available on every doc.

Route every doc through CommentsShell and retire PlainShell. On a doc with
no comments the rail just starts collapsed; a viewer who can't comment
gets no composer — but the theme toggle (and the overlay that repaints the
document) is always present. The SSR theme detection is unchanged; it now
seeds CommentsShell's initial theme in every case.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Comment thread lib/docs/overlay.ts
On a doc with no comments the toggle themed the chrome bar but not the
document. The document theming is driven by jh:themeMode, which the shell
only posts once overlayReady is true — and overlayReady is set solely from
the overlay's one-shot jh:ready. When the iframe loads before the shell's
message listener mounts (fast or cached loads), that jh:ready is missed,
overlayReady stays false, and jh:themeMode (plus anchors) is silently
never sent; the bar still themes because that's pure React state.

Ping the overlay from the iframe's onLoad — it replies jh:ready — so
overlayReady is set reliably regardless of load timing, and the jh:ready
handler then (re)sends the forced theme. Also fixes the same race for
anchored highlights on cached loads.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two review findings on the theme sampling timing:

- Stale forced sample drives auto chrome: while forced, jh:theme reports
  the forced colors. After switching back to auto the shell kept driving
  the bar/rail from that stale sample (e.g. dark on a light doc) until a
  fresh sample arrived. Reset the shell's theme to the authored SSR
  baseline on every mode change; the overlay re-samples right after.

- Premature bootstrap sample: the overlay ran sampleTheme() at init while
  forcedScheme was still unset, reporting the authored theme before the
  shell's jh:themeMode could apply a forced one. Drop that eager sample —
  the jh:themeMode handler (sent on jh:ready) does the first, forced-aware
  sample, with the window.load and settle-timeout re-emits as the safety
  net for late CSS or a host that never sends jh:themeMode.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@AnnaXWang

Copy link
Copy Markdown
Contributor Author

Addressed both theme-sampling timing findings (commit 9a354b6).

  • Stale theme tints auto chrome — while forced, jh:theme reports the forced colors; after switching to auto the shell kept rendering the bar/rail from that stale sample until a fresh one arrived. The shell now resets theme to the authored SSR baseline (initialTheme) on every mode change, and the overlay re-samples immediately after (via jh:themeMode), so auto never renders from a forced sample.

  • Bootstrap sampleTheme precedes themeMode — the overlay no longer samples eagerly at init (when forcedScheme is still unset). The first sample now comes from the jh:themeMode handler (the shell replies to jh:ready with it), so it's forced-aware; window.load + a settle timeout remain as safety-net re-emits (late CSS, or a host that never sends jh:themeMode).

tsc / 108 tests / build green.

Comment thread app/d/[slug]/CommentsShell.tsx
Comment thread app/d/[slug]/CommentsShell.tsx Outdated
Two coupled fragilities kept producing regressions; fix them at the root.

Readiness handshake: overlayReady (which gates jh:themeMode, anchors and
reactions) was set only from the overlay's one-shot jh:ready, which is
missed if the iframe loads before the shell's listener mounts (fast/cached
loads) — so the document silently never received forced theme. The shell
now pings the overlay until it replies jh:ready (capped), immune to load
timing. Removes the onLoad ping.

Theme sampling: jh:theme conflated authored vs forced colors, and the
patches around it (forced bg/fg override, setTheme(initialTheme) reset) each
broke another mode. The overlay now ALWAYS reports the doc's AUTHORED colors
(captured while unforced, cached, reported even while forced); the in-doc
highlight darkness (jh-dark) follows the EFFECTIVE darkness; and the shell
gates chrome purely by mode (auto → authored sample; dark → sample-if-dark
else DEFAULT_DARK white base; light → literals). This removes both the stale
forced tint after switching to auto and the wrong light chrome on a dark doc
the server couldn't coarse-detect (initialTheme null) — no reset needed.

Verified: forced dark reports authored + jh-dark on + doc canvas dark (white
prose, code/badges/boxes preserved, highlighted text white); auto restores
authored with jh-dark off and no stale tint.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@AnnaXWang

Copy link
Copy Markdown
Contributor Author

Addressed both findings at the root (commit 144b54b), after planning to stop the symptom-by-symptom churn.

Ready handshake can fail silently (High)overlayReady (gating jh:themeMode, anchors, reactions) was set only from the overlay's one-shot jh:ready, which is missed if the iframe loads before the shell's listener mounts (fast/cached), and the onLoad ping had the same timing fragility. The shell now pings the overlay until it replies jh:ready (immediate, then every ~150ms, capped ~3s, cleared on ready). Immune to load timing. Removed the onLoad ping.

Auto mode resets chrome wrongly (Medium) — root cause was that jh:theme conflated authored vs forced colors, so the patches around it (forced bg/fg override, setTheme(initialTheme) reset) each broke another mode. Now the overlay always reports the doc's AUTHORED colors (captured while unforced, cached, reported even while forced); in-doc highlight darkness (jh-dark) follows the effective darkness; and the shell gates chrome purely by mode (auto→authored sample, dark→sample-if-dark else DEFAULT_DARK white base, light→literals). No initialTheme reset — so a dark doc the server couldn't coarse-detect (initialTheme null) still reads dark in auto, and no stale forced tint lingers after switching back.

Verified via harness on a real doc: forced dark → reports authored + jh-dark on + body.bg=#0d1117 (white prose, code/badges/boxes preserved, highlighted text white); auto → authored + jh-dark off + canvas restored, no stale tint. tsc / 108 tests / build green.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 144b54b. Configure here.

// Mirror mode into a ref so the jh:ready handler (not in mode's deps) reads the
// current value — it must send the forced theme on every ready, including reloads.
const modeRef = useRef<ThemeMode>(mode);
modeRef.current = mode;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Auto chrome beats document

Medium Severity

Switching to auto recomputes effectiveTheme from stored authored theme immediately, while the iframe document stays on forced light/dark until jh:themeMode is handled. That yields a visible window where bar/rail/stage colors match the authored doc but the canvas still shows the previous forced override (or the reverse).

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 144b54b. Configure here.

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