diff --git a/app/d/[slug]/CommentsShell.tsx b/app/d/[slug]/CommentsShell.tsx index 3759567..89a542a 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; @@ -142,6 +142,10 @@ export default function CommentsShell(props: Props) { // localStorage as a GLOBAL preference (not per-doc). localStorage is // client-only, so we start "auto" for SSR and hydrate on mount. const [mode, setMode] = useState("auto"); + // 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(mode); + modeRef.current = mode; useEffect(() => { try { const saved = localStorage.getItem(THEME_MODE_KEY); @@ -256,6 +260,22 @@ export default function CommentsShell(props: Props) { postToOverlay({ type: "jh:reactions", groups: paintReactionGroups, me, avatars }); }, [overlayReady, paintReactionGroups, me, avatars, postToOverlay]); + // Readiness handshake, made robust. The overlay's jh:ready is one-shot and can be + // missed if the iframe loads before this shell's message listener mounts (fast/cached + // loads) — leaving overlayReady false, which silently suppresses jh:themeMode, anchors + // and reactions. So ping until we hear back (the overlay replies to jh:ping with + // jh:ready), then stop; capped so it can't spin forever. + useEffect(() => { + if (overlayReady) return; + postToOverlay({ type: "jh:ping" }); + let tries = 0; + const id = setInterval(() => { + if (tries++ >= 20) clearInterval(id); + else postToOverlay({ type: "jh:ping" }); + }, 150); + return () => clearInterval(id); + }, [overlayReady, postToOverlay]); + // Listen to overlay messages. Only accept messages from our iframe's window // (the sandboxed iframe posts with origin "null"; we match on source window). useEffect(() => { @@ -266,6 +286,11 @@ export default function CommentsShell(props: Props) { switch (d.type) { case "jh:ready": setOverlayReady(true); + // Send the forced theme FIRST, so the overlay applies it before it paints + // (no authored-doc flash), and on EVERY ready — including an iframe reload, + // where overlayReady stays true so the mode effect below won't re-fire and + // the freshly-loaded overlay would otherwise reset to the authored theme. + postToOverlay({ type: "jh:themeMode", mode: modeRef.current }); postToOverlay({ type: "jh:anchors", anchors: paintAnchors }); postToOverlay({ type: "jh:reactions", groups: paintReactionGroups, me, avatars }); break; @@ -470,6 +495,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] @@ -1322,7 +1356,7 @@ const railCloseStyle: React.CSSProperties = { }; // Viewer chrome bar — variant A (LOCKED 2026-06-13): same weights/colors as the -// page footer. Matches PlainShell's bar so both viewer paths share one chrome. +// page footer. const barStyle: React.CSSProperties = { display: "flex", justifyContent: "space-between", diff --git a/app/d/[slug]/PlainShell.tsx b/app/d/[slug]/PlainShell.tsx deleted file mode 100644 index ce3b668..0000000 --- a/app/d/[slug]/PlainShell.tsx +++ /dev/null @@ -1,91 +0,0 @@ -// Plain viewer shell — the cold path (zero comments AND a viewer who can't -// comment). Thin chrome (title + "made with justhtml.sh") wrapping the sandboxed -// iframe to /d/:slug/raw. No rail, no overlay, no client JS — behaviorally -// identical to the pre-B10 page. Server component; the root layout supplies -// / and the monospace brand. -// -// Adaptive chrome (variant D): there is NO JS here to sample the doc, so the -// server pre-derives a COARSE theme from the stored HTML's unconditional -// html/body background (lib/docs/theme.ts detectServerTheme/buildChromePalette) -// and themes the bar + iframe backdrop at SSR. Light docs (or any ambiguity) get -// `theme === null` → today's EXACT light chrome (#fff / #ccc), byte-identical. - -import { buildChromePalette, type ThemeSample } from "@/lib/docs/theme"; - -const MONO = `ui-monospace, "SF Mono", Menlo, Consolas, "Courier New", monospace`; - -// Viewer chrome bar — variant A (LOCKED 2026-06-13): bold title left, quiet -// "made with justhtml.sh" right, same weights/colors as the page footer. Colors -// read from CSS vars with the literal light values as fallbacks, so an UNSET var -// set (light doc) resolves to today's exact bytes. -const BAR: React.CSSProperties = { - display: "flex", - justifyContent: "space-between", - alignItems: "center", - height: "2.4rem", - padding: "0 1.25rem", - fontFamily: MONO, - fontSize: 13, - borderBottom: "1px solid var(--jh-bar-border, #ccc)", - color: "var(--jh-bar-fg, #111)", - background: "var(--jh-bar-bg, #fff)", -}; - -export default function PlainShell({ - title, - rawSrc, - theme, -}: { - title: string; - rawSrc: string; - theme: ThemeSample | null; -}) { - // Only DARK themes recolor the chrome; light/unknown keep the literal chrome. - const dark = theme && theme.isDark ? theme : null; - const p = dark ? buildChromePalette(dark) : null; - const vars: React.CSSProperties | undefined = p - ? { - ["--jh-bar-bg" as string]: p.barBg, - ["--jh-bar-border" as string]: p.barBorder, - ["--jh-bar-fg" as string]: p.barFg, - ["--jh-bar-muted" as string]: p.barMuted, - ["--jh-stage-bg" as string]: p.stageBg, - } - : undefined; - - return ( -
-
- - {title} - - - made with{" "} - - justhtml.sh - - -
-