From 0207d855537ef8398b8e7c3839bdd7490c9892dd Mon Sep 17 00:00:00 2001 From: AnnaXWang <6621137+AnnaXWang@users.noreply.github.com> Date: Thu, 2 Jul 2026 02:10:56 +0000 Subject: [PATCH 1/4] Make the theme toggle repaint the document, not just the chrome MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/d/[slug]/CommentsShell.tsx | 9 ++++++++ lib/docs/overlay.ts | 39 ++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/app/d/[slug]/CommentsShell.tsx b/app/d/[slug]/CommentsShell.tsx index 3759567..fa61154 100644 --- a/app/d/[slug]/CommentsShell.tsx +++ b/app/d/[slug]/CommentsShell.tsx @@ -470,6 +470,15 @@ export default function CommentsShell(props: Props) { if (overlayReady) postToOverlay({ type: "jh:active", id: activeId }); }, [activeId, overlayReady, postToOverlay]); + // Forward the theme toggle into the iframe so the overlay repaints the DOCUMENT + // (not just the chrome): "dark"/"light" force the doc's color-scheme + background, + // "auto" leaves it as authored. The overlay re-samples after applying, so the + // chrome palette and highlight treatment follow the doc through the existing + // jh:theme round-trip — no separate wiring needed here. + useEffect(() => { + if (overlayReady) postToOverlay({ type: "jh:themeMode", mode }); + }, [mode, overlayReady, postToOverlay]); + const visibleThreads = useMemo( () => threads.filter((t) => showResolved || !t.resolved), [threads, showResolved] diff --git a/lib/docs/overlay.ts b/lib/docs/overlay.ts index 6bcf6c9..f8efc4a 100644 --- a/lib/docs/overlay.ts +++ b/lib/docs/overlay.ts @@ -12,6 +12,7 @@ // { type:"jh:focus", key } (focus a key from the rail; null clears) // { type:"jh:scrollTo", id } // { type:"jh:clearSelection" } +// { type:"jh:themeMode", mode } ("dark"|"light" force doc theme; else auto) // overlay → shell: { type:"jh:ready" } // { type:"jh:positions", positions:{ [id]: yTopPx }, docHeight, scrollY } // (comment highlight y in doc space; doc scroll for rail sync) @@ -77,6 +78,7 @@ export const OVERLAY_SCRIPT = String.raw` // isDark uses WCAG relative luminance with a small hysteresis dead-band so a // mid-tone bg doesn't flip-flop across re-emits. var lastDark = null; // hysteresis memory across re-emits + var forcedScheme = null; // viewer toggle: null = auto (doc as authored); "dark"|"light" force it function rxParse(s){ if (!s) return null; var m = String(s).match(/rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)(?:[,\s\/]+([\d.%]+))?/i); @@ -133,6 +135,38 @@ export const OVERLAY_SCRIPT = String.raw` } catch(e){} } + // ---- forced document theme (viewer's light/dark toggle) ---- + // The toggle themes the chrome (bar/rail) in the shell; this makes it also repaint + // the DOCUMENT. color-scheme flips UA defaults (canvas, form controls, scrollbars); + // the !important bg/color forces the canvas even over an authored background, so an + // explicit pick wins. Per-element authored colors still cascade — we can't invert an + // arbitrary design, and @media(prefers-color-scheme) can't be driven from script. + // "auto" removes the override entirely, restoring the doc exactly as authored. + function applyDocScheme(){ + try { + var de = document.documentElement; if (!de) return; + var st = document.getElementById("jh-doc-theme"); + if (!forcedScheme){ + de.classList.remove("jh-force-dark", "jh-force-light"); + de.style.colorScheme = ""; + if (st && st.parentNode) st.parentNode.removeChild(st); + return; + } + if (!st){ + st = document.createElement("style"); st.id = "jh-doc-theme"; + st.textContent = + "html.jh-force-dark{color-scheme:dark}" + + "html.jh-force-dark,html.jh-force-dark body{background-color:#0d1117!important;color:#c9d1d9!important}" + + "html.jh-force-light{color-scheme:light}" + + "html.jh-force-light,html.jh-force-light body{background-color:#ffffff!important;color:#111111!important}"; + (document.head || de).appendChild(st); + } + de.classList.toggle("jh-force-dark", forcedScheme === "dark"); + de.classList.toggle("jh-force-light", forcedScheme === "light"); + de.style.colorScheme = forcedScheme; + } catch(e){} + } + // ---- text-content walker (anchor resolution against the live DOM) ---- // We snapshot the text model ONCE per paint (over the pristine DOM, before any // segment wrapping), resolve every anchor's [start,end) against it, then split. @@ -660,6 +694,11 @@ export const OVERLAY_SCRIPT = String.raw` } else if (d.type === "jh:scrollTo"){ var sk = (typeof d.id === "number") ? "c:"+d.id : String(d.id); scrollToKey(sk); } else if (d.type === "jh:clearSelection"){ var s=window.getSelection(); if(s) s.removeAllRanges(); } + else if (d.type === "jh:themeMode"){ + forcedScheme = (d.mode === "dark" || d.mode === "light") ? d.mode : null; + applyDocScheme(); + sampleTheme(); // re-read colors so the chrome + highlight follow the forced doc theme + } else if (d.type === "jh:ping"){ send({type:"jh:ready"}); } }); From 3f85bdd7dee2cf9e32cddbb99b84aecbf6211d57 Mon Sep 17 00:00:00 2001 From: AnnaXWang <6621137+AnnaXWang@users.noreply.github.com> Date: Thu, 2 Jul 2026 03:14:31 +0000 Subject: [PATCH 2/4] Use pure white for body text in forced dark mode 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 --- lib/docs/overlay.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/docs/overlay.ts b/lib/docs/overlay.ts index f8efc4a..307afcb 100644 --- a/lib/docs/overlay.ts +++ b/lib/docs/overlay.ts @@ -156,7 +156,7 @@ export const OVERLAY_SCRIPT = String.raw` st = document.createElement("style"); st.id = "jh-doc-theme"; st.textContent = "html.jh-force-dark{color-scheme:dark}" - + "html.jh-force-dark,html.jh-force-dark body{background-color:#0d1117!important;color:#c9d1d9!important}" + + "html.jh-force-dark,html.jh-force-dark body{background-color:#0d1117!important;color:#ffffff!important}" + "html.jh-force-light{color-scheme:light}" + "html.jh-force-light,html.jh-force-light body{background-color:#ffffff!important;color:#111111!important}"; (document.head || de).appendChild(st); From d6ce92643ce4792962e7d5637f548bf16a0c1263 Mon Sep 17 00:00:00 2001 From: AnnaXWang <6621137+AnnaXWang@users.noreply.github.com> Date: Thu, 2 Jul 2026 03:37:09 +0000 Subject: [PATCH 3/4] Whiten all document text in dark mode, preserving backgrounded blocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- lib/docs/overlay.ts | 77 ++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 69 insertions(+), 8 deletions(-) diff --git a/lib/docs/overlay.ts b/lib/docs/overlay.ts index 307afcb..e79a802 100644 --- a/lib/docs/overlay.ts +++ b/lib/docs/overlay.ts @@ -136,12 +136,69 @@ export const OVERLAY_SCRIPT = String.raw` } // ---- forced document theme (viewer's light/dark toggle) ---- - // The toggle themes the chrome (bar/rail) in the shell; this makes it also repaint - // the DOCUMENT. color-scheme flips UA defaults (canvas, form controls, scrollbars); - // the !important bg/color forces the canvas even over an authored background, so an - // explicit pick wins. Per-element authored colors still cascade — we can't invert an - // arbitrary design, and @media(prefers-color-scheme) can't be driven from script. - // "auto" removes the override entirely, restoring the doc exactly as authored. + // The toggle themes the chrome (bar/rail) in the shell; this repaints the DOCUMENT. + // Setting body color alone doesn't work — authored rules like p,li{color:#1a1a1a} + // beat inheritance. And blanket-whitening every element breaks anything with its own + // (light) background: code blocks, badges, callout boxes would get white-on-light. + // So: force color-scheme + the page background, then WALK the DOM and recolor only + // the text of elements sitting ON that page background. Any element with its own + // background (or a code block) keeps its authored colors, and so does its subtree — + // "leave code alone", generalized. Links keep their accent. "auto" removes it all. + // (@media(prefers-color-scheme) still can't be driven from script.) + var FG_SKIP = { SCRIPT:1, STYLE:1, PRE:1, CODE:1, SVG:1, IMG:1, CANVAS:1, VIDEO:1, IFRAME:1, A:1, BUTTON:1, INPUT:1, SELECT:1, TEXTAREA:1, OPTION:1, NOSCRIPT:1 }; + function ownsBackground(el){ + try { + var bg = getComputedStyle(el).backgroundColor; + if (!bg || bg === "transparent") return false; + var m = bg.match(/rgba?\(([^)]+)\)/); + if (m){ var p = m[1].split(","); return p.length < 4 || parseFloat(p[3]) > 0.05; } + return true; + } catch(e){ return false; } + } + function isSurface(el, tag){ return tag === "PRE" || tag === "CODE" || ownsBackground(el); } + // PASS 1 — before any whitening, pin each surface's authored text color inline. A + // surface (code block / anything with its own background) often has no color of its + // own and relies on inheriting the body's dark text; once we whiten its ancestor + // that inheritance would turn its text white on a light surface. A direct inline + // color beats inheritance, so this preserves the authored look ("keep font color"). + function pinSurfaces(el){ + var kids = el.children; + for (var i = 0; i < kids.length; i++){ + var c = kids[i], tag = c.tagName; + if (tag === "SCRIPT" || tag === "STYLE") continue; + if (isSurface(c, tag)){ + if (!c.hasAttribute("data-jh-fg-pin")){ + c.setAttribute("data-jh-fg-pin", c.style.color); + c.style.color = getComputedStyle(c).color; + } + } else { + pinSurfaces(c); + } + } + } + // PASS 2 — recolor text of elements sitting ON the page background; skip surfaces + // (and their subtrees), links, media, and painted highlight segments. + function whitenPage(el){ + var kids = el.children; + for (var i = 0; i < kids.length; i++){ + var c = kids[i], tag = c.tagName; + if (tag === "SCRIPT" || tag === "STYLE") continue; + if (isSurface(c, tag)) continue; + if (!FG_SKIP[tag] && !c.hasAttribute("data-jh-seg")) c.classList.add("jh-doc-fg"); + whitenPage(c); + } + } + function markForcedText(){ + try { + var pinned = document.querySelectorAll("[data-jh-fg-pin]"); + for (var i = 0; i < pinned.length; i++){ var e = pinned[i]; e.style.color = e.getAttribute("data-jh-fg-pin") || ""; e.removeAttribute("data-jh-fg-pin"); } + var whited = document.querySelectorAll(".jh-doc-fg"); + for (var j = 0; j < whited.length; j++) whited[j].classList.remove("jh-doc-fg"); + if (!forcedScheme || !document.body) return; + pinSurfaces(document.body); + whitenPage(document.body); + } catch(e){} + } function applyDocScheme(){ try { var de = document.documentElement; if (!de) return; @@ -150,20 +207,24 @@ export const OVERLAY_SCRIPT = String.raw` de.classList.remove("jh-force-dark", "jh-force-light"); de.style.colorScheme = ""; if (st && st.parentNode) st.parentNode.removeChild(st); + markForcedText(); return; } if (!st){ st = document.createElement("style"); st.id = "jh-doc-theme"; st.textContent = "html.jh-force-dark{color-scheme:dark}" - + "html.jh-force-dark,html.jh-force-dark body{background-color:#0d1117!important;color:#ffffff!important}" + + "html.jh-force-dark,html.jh-force-dark body{background-color:#0d1117!important}" + + "html.jh-force-dark .jh-doc-fg{color:#fff!important}" + "html.jh-force-light{color-scheme:light}" - + "html.jh-force-light,html.jh-force-light body{background-color:#ffffff!important;color:#111111!important}"; + + "html.jh-force-light,html.jh-force-light body{background-color:#ffffff!important}" + + "html.jh-force-light .jh-doc-fg{color:#111!important}"; (document.head || de).appendChild(st); } de.classList.toggle("jh-force-dark", forcedScheme === "dark"); de.classList.toggle("jh-force-light", forcedScheme === "light"); de.style.colorScheme = forcedScheme; + markForcedText(); } catch(e){} } From e728b12a17532942c877c5af654699df98c9f270 Mon Sep 17 00:00:00 2001 From: AnnaXWang <6621137+AnnaXWang@users.noreply.github.com> Date: Thu, 2 Jul 2026 03:46:06 +0000 Subject: [PATCH 4/4] Make comment (rail) text white in forced dark mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/d/[slug]/CommentsShell.tsx | 2 +- lib/docs/overlay.ts | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/app/d/[slug]/CommentsShell.tsx b/app/d/[slug]/CommentsShell.tsx index fa61154..25247a3 100644 --- a/app/d/[slug]/CommentsShell.tsx +++ b/app/d/[slug]/CommentsShell.tsx @@ -41,7 +41,7 @@ const THEME_MODE_KEY = "jh:theme-mode"; // Fallback dark base when a viewer FORCES dark on a doc we didn't sample as dark // (a light doc, or before the overlay's first jh:theme). Real dark docs keep // their own sampled colors so the chrome matches the page. -const DEFAULT_DARK: ThemeSample = { bg: "#0d1117", fg: "#c9d1d9", isDark: true }; +const DEFAULT_DARK: ThemeSample = { bg: "#0d1117", fg: "#ffffff", isDark: true }; type Reaction = { emoji: string; count: number; authors: string[] }; type Anchor = { exact: string; prefix?: string; suffix?: string; start?: number; end?: number } | null; diff --git a/lib/docs/overlay.ts b/lib/docs/overlay.ts index e79a802..eef51e5 100644 --- a/lib/docs/overlay.ts +++ b/lib/docs/overlay.ts @@ -105,6 +105,12 @@ export const OVERLAY_SCRIPT = String.raw` if (!bgRgb) bgRgb = [255,255,255]; // both transparent → treat as white (light) // fg: body color (fall back to documentElement). var fgRgb = (bodyCS && rxParse(bodyCS.color)) || (deCS && rxParse(deCS.color)) || [17,17,17]; + // When the viewer FORCES a theme, report the forced bg/fg so the chrome palette + // (rail + comment cards, derived from this sample) matches the forced document — + // e.g. comment text is white in forced dark, not a gray lifted from the doc's own + // authored fg. Auto (no force) keeps sampling the doc so the chrome adapts to it. + if (forcedScheme === "dark"){ bgRgb = [13,17,23]; fgRgb = [255,255,255]; } + else if (forcedScheme === "light"){ bgRgb = [255,255,255]; fgRgb = [17,17,17]; } // accent: first , else first heading. var accStr = null; var aEl = document.querySelector("a[href], a");