Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 36 additions & 2 deletions app/d/[slug]/CommentsShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<ThemeMode>("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<ThemeMode>(mode);
modeRef.current = mode;
Comment thread
cursor[bot] marked this conversation as resolved.

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.

useEffect(() => {
try {
const saved = localStorage.getItem(THEME_MODE_KEY);
Expand Down Expand Up @@ -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(() => {
Expand All @@ -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 });
Comment thread
cursor[bot] marked this conversation as resolved.
Comment thread
cursor[bot] marked this conversation as resolved.
postToOverlay({ type: "jh:anchors", anchors: paintAnchors });
postToOverlay({ type: "jh:reactions", groups: paintReactionGroups, me, avatars });
break;
Expand Down Expand Up @@ -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
Comment thread
cursor[bot] marked this conversation as resolved.
Comment thread
cursor[bot] marked this conversation as resolved.
// (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]
Expand Down Expand Up @@ -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",
Expand Down
91 changes: 0 additions & 91 deletions app/d/[slug]/PlainShell.tsx

This file was deleted.

24 changes: 8 additions & 16 deletions app/d/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import {
} from "@/lib/docs/comments";
import { detectServerTheme } from "@/lib/docs/theme";
import CommentsShell from "./CommentsShell";
import PlainShell from "./PlainShell";

export const dynamic = "force-dynamic";

Expand Down Expand Up @@ -95,26 +94,19 @@ export default async function ViewerPage({ params, searchParams }: Props) {
const threadData = await allThreads(doc);
const docReactions = threadData.doc_reactions ?? [];
const anchoredReactions = threadData.anchored_reactions ?? [];
const hasComments = threadData.total > 0;
const hasDocReactions = docReactions.length > 0;
const hasAnchoredReactions = anchoredReactions.length > 0;
const title = doc.title || doc.slug;

// Adaptive chrome (variant D): coarsely detect a DARK doc from the stored
// HTML's unconditional html/body background so the shell renders themed at SSR
// (PlainShell has no JS to sample; CommentsShell uses it as the initial theme
// to avoid a light→dark flash before the overlay's jh:theme refines it).
// Conservative — a bg dark only under prefers-color-scheme is "unknown" → light.
// HTML's unconditional html/body background so the shell renders themed at SSR
// CommentsShell uses it as the initial theme to avoid a light→dark flash before the
// overlay's jh:theme refines it. Conservative — a bg dark only under
// prefers-color-scheme is "unknown" → light.
const serverTheme = detectServerTheme(doc.html);

// Cold path: no comments, no reactions of any kind, AND this viewer can't
// comment or react → plain shell (zero chrome). The moment any reaction exists
// (doc-level or anchored) or the viewer can interact, the rail earns its keep.
if (!hasComments && !hasDocReactions && !hasAnchoredReactions && !canComment && !canReact) {
return <PlainShell title={title} rawSrc={`/d/${encodeURIComponent(slug)}/raw${rawQuery}`} theme={serverTheme} />;
}

// Comments shell: overlay-injected iframe + the variant-B rail.
// Always the viewer shell: the light/dark toggle is a viewer feature that must be
// available on every doc, including ones with no comments and viewers who can't
// comment (the rail just stays collapsed). The doc still renders in the sandboxed
// iframe; the overlay is injected so the toggle can repaint the document.
const overlayQuery = rawQuery ? `${rawQuery}&overlay=1` : "?overlay=1";
return (
<CommentsShell
Expand Down
Loading
Loading