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
106 changes: 100 additions & 6 deletions app/d/[slug]/CommentsShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,13 @@ const MONO = `ui-monospace, "SF Mono", Menlo, Consolas, "Courier New", monospace
// react with the wider allowed set, but the human picker stays small.
const EMOJIS = ["👍", "👎", "🎉", "❤️", "😄", "🚀", "👀"];

type ThemeMode = "auto" | "light" | "dark";
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 };

type Reaction = { emoji: string; count: number; authors: string[] };
type Anchor = { exact: string; prefix?: string; suffix?: string; start?: number; end?: number } | null;
// Anchored reaction group (one per span), as returned by GET /comments grouped by
Expand Down Expand Up @@ -124,14 +131,40 @@ export default function CommentsShell(props: Props) {

// Adaptive chrome (variant D). The server may hand us a coarse dark theme for
// the initial paint (no flash); the overlay's jh:theme then refines/confirms it.
// `mode` is the single gate that keeps the door open for a future user
// light/dark toggle — today it is always "auto" (apply whatever the doc is).
const mode: "auto" = "auto";
const [theme, setTheme] = useState<ThemeSample | null>(props.initialTheme);
// Only DARK themes drive the chrome; light docs keep the literal light chrome.
// Viewer theme preference: "auto" (match the document — the default, and
// today's behavior) or an explicit light/dark. Persisted per viewer in
// 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");
useEffect(() => {
try {
const saved = localStorage.getItem(THEME_MODE_KEY);
if (saved === "auto" || saved === "light" || saved === "dark") setMode(saved);
} catch {
/* localStorage blocked (private mode) — stay on auto. */
}
}, []);
const chooseMode = useCallback((m: ThemeMode) => {
setMode(m);
try {
localStorage.setItem(THEME_MODE_KEY, m);
} catch {
/* best effort — the choice still applies for this session. */
}
}, []);
// The theme that actually drives the chrome. light → today's literal light
// chrome (palette null); dark → variant-D palette from the doc's sampled dark
// colors when it really is dark, else a default dark base; auto → dark only
// when the doc sampled dark (unchanged behavior).
const effectiveTheme: ThemeSample | null = useMemo(() => {
if (mode === "light") return null;
if (mode === "dark") return theme && theme.isDark ? theme : DEFAULT_DARK;
return theme && theme.isDark ? theme : null;
}, [mode, theme]);
const palette: ChromePalette | null = useMemo(
() => (mode === "auto" && theme && theme.isDark ? buildChromePalette(theme) : null),
[theme]
() => (effectiveTheme ? buildChromePalette(effectiveTheme) : null),
[effectiveTheme]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Theme mode not re-synced

Medium Severity

On a second jh:ready while overlayReady is already true, the shell re-posts anchors and reactions but never sends jh:setThemeMode. The new iframe overlay keeps forcedMode at auto, so saved light/dark highlight treatment can disagree with the chrome until the user toggles theme again.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 609727d. Configure here.

);
const isDark = palette !== null;

Expand Down Expand Up @@ -430,6 +463,12 @@ export default function CommentsShell(props: Props) {
if (overlayReady) postToOverlay({ type: "jh:active", id: activeId });
}, [activeId, overlayReady, postToOverlay]);

// Tell the overlay which highlight treatment to paint. A forced light/dark
// overrides the overlay's own doc-darkness sampling; "auto" hands control back.
useEffect(() => {
if (overlayReady) postToOverlay({ type: "jh:setThemeMode", mode });
}, [mode, overlayReady, postToOverlay]);

const visibleThreads = useMemo(
() => threads.filter((t) => showResolved || !t.resolved),
[threads, showResolved]
Expand Down Expand Up @@ -486,6 +525,7 @@ export default function CommentsShell(props: Props) {
{title}
</span>
<span style={{ flexShrink: 0, paddingLeft: "1.25rem", display: "flex", gap: "1.25rem", alignItems: "center", color: "var(--jh-bar-muted, #666)" }}>
<ThemeToggle mode={mode} onChange={chooseMode} />
<button
type="button"
className="jh-commentbtn"
Expand Down Expand Up @@ -684,6 +724,35 @@ function paletteVars(p: ChromePalette): React.CSSProperties {

// --------------------------- subcomponents ---------------------------

// Light / dark / auto theme control in the chrome bar. Segmented, monospace,
// themed off the same --jh-tog-* vars as the comment toggle so it recolors in
// dark. "auto" (◐) matches the document; ☀/☾ force the choice and persist it.
const THEME_OPTS: { m: ThemeMode; glyph: string; label: string }[] = [
{ m: "light", glyph: "☀", label: "light" },
{ m: "dark", glyph: "☾", label: "dark" },
{ m: "auto", glyph: "◐", label: "auto (match the document)" },
];

function ThemeToggle({ mode, onChange }: { mode: ThemeMode; onChange: (m: ThemeMode) => void }) {
return (
<span role="group" aria-label="theme" style={themeToggleWrap}>
{THEME_OPTS.map((o) => (
<button
key={o.m}
type="button"
title={o.label}
aria-label={`theme: ${o.label}`}
aria-pressed={mode === o.m}
onClick={() => onChange(o.m)}
style={themeSegStyle(mode === o.m)}
>
{o.glyph}
</button>
))}
</span>
);
}

function SelectionToolbar({
viewTop,
canComment,
Expand Down Expand Up @@ -1152,6 +1221,31 @@ function commentBtnStyle(pressed: boolean): React.CSSProperties {
};
}

const themeToggleWrap: React.CSSProperties = {
display: "inline-flex",
alignItems: "center",
height: 22,
border: "1px solid var(--jh-tog-border, #ccc)",
borderRadius: 6,
overflow: "hidden",
};

function themeSegStyle(on: boolean): React.CSSProperties {
return {
font: "inherit",
fontSize: 12,
lineHeight: 1,
minWidth: 24,
height: "100%",
padding: "0 6px",
border: "none",
cursor: "pointer",
background: on ? "var(--jh-tog-on-bg, #111)" : "var(--jh-tog-bg, #fafafa)",
color: on ? "var(--jh-tog-on-fg, #fff)" : "var(--jh-tog-fg, #111)",
transition: CHROME_TRANSITION,
};
}

const scrimStyle: React.CSSProperties = {
position: "absolute",
inset: 0,
Expand Down
31 changes: 21 additions & 10 deletions lib/docs/overlay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
// { type:"jh:focus", key } (focus a key from the rail; null clears)
// { type:"jh:scrollTo", id }
// { type:"jh:clearSelection" }
// { type:"jh:setThemeMode", mode } ("light"|"dark" force the highlight
// treatment; "auto"/null = sample the doc)
// overlay → shell: { type:"jh:ready" }
// { type:"jh:positions", positions:{ [id]: yTopPx } } (comment highlight y)
// { type:"jh:selection", anchor:{exact,prefix,suffix}, rect:{...} }
Expand Down Expand Up @@ -63,6 +65,7 @@ export const OVERLAY_SCRIPT = String.raw`
var focusKey = null; // focused (pinned) key
var lastClickKeys = null; // covering set of the last focus click (for cycle)
var lastClickPos = -1; // doc-text offset of the last focus click (cycle reset on move)
var forcedMode = null; // "light"|"dark" from the shell's theme toggle; null = auto (sample the doc)

function send(msg){ try { parent.postMessage(msg, "*"); } catch(e){} }
function esc(s){ return (s||"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;"); }
Expand Down Expand Up @@ -117,8 +120,13 @@ export const OVERLAY_SCRIPT = String.raw`
else dark = lum < 0.4;
lastDark = dark;

// The highlight treatment follows the shell's explicit light/dark toggle
// when one is set (forcedMode), otherwise the sampled darkness. jh:theme
// still reports the SAMPLED value below so the shell's auto path stays honest.
var effDark = forcedMode === "dark" ? true : forcedMode === "light" ? false : dark;

// toggle the dark-highlight stylesheet branch (needs the style present)
try { ensureStyle(); if (document.documentElement) document.documentElement.classList.toggle("jh-dark", !!dark); } catch(e){}
try { ensureStyle(); if (document.documentElement) document.documentElement.classList.toggle("jh-dark", !!effDark); } catch(e){}

send({ type:"jh:theme",
bg: "rgb("+Math.round(bgRgb[0])+","+Math.round(bgRgb[1])+","+Math.round(bgRgb[2])+")",
Expand Down Expand Up @@ -223,15 +231,17 @@ export const OVERLAY_SCRIPT = String.raw`
+ "span[data-jh-seg].jh-hover{background:#ffd76b}"
+ "span[data-jh-seg].jh-focus{background:#ffce3a;box-shadow:inset 0 0 0 9999px rgba(255,179,0,.18)}"
+ "span[data-jh-seg].jh-dim{opacity:.4}"
// DARK DOC (adaptive chrome, variant D): the yellow wash blows out on a dark
// page, so when the doc is dark we repaint highlights as a translucent warm
// wash (rgba(241,196,15,.20)) + a ~55% warm ring, keeping the doc's own text
// color (legible). Gated by a .jh-dark class on <html> set from sampleTheme.
+ "html.jh-dark span[data-jh-seg].d1{background:rgba(241,196,15,.16);border-bottom-color:rgba(241,196,15,.45)}"
+ "html.jh-dark span[data-jh-seg].d2{background:rgba(241,196,15,.24);border-bottom-color:rgba(241,196,15,.55)}"
+ "html.jh-dark span[data-jh-seg].d3{background:rgba(241,196,15,.32);border-bottom-color:rgba(241,196,15,.7)}"
+ "html.jh-dark span[data-jh-seg].jh-hover{background:rgba(241,196,15,.3)}"
+ "html.jh-dark span[data-jh-seg].jh-focus{background:rgba(241,196,15,.2);box-shadow:inset 0 0 0 9999px rgba(241,196,15,.12),0 0 0 1px rgba(241,196,15,.55)}"
// DARK DOC (adaptive chrome, variant D): the light #fff3bf wash is nearly
// invisible on a dark page, so when the doc is dark we repaint highlights as a
// stronger warm amber wash (~.30–.54 alpha by depth) with a near-opaque warm
// underline — legible on dark while keeping the doc's own (light) text
// readable. Gated by a .jh-dark class on <html> set from sampleTheme (which
// honors the shell's light/dark toggle via forcedMode).
+ "html.jh-dark span[data-jh-seg].d1{background:rgba(245,197,24,.30);border-bottom-color:rgba(245,197,24,.95)}"
+ "html.jh-dark span[data-jh-seg].d2{background:rgba(245,197,24,.42);border-bottom-color:rgba(245,197,24,.98)}"
+ "html.jh-dark span[data-jh-seg].d3{background:rgba(245,197,24,.54);border-bottom-color:#f5c518}"
+ "html.jh-dark span[data-jh-seg].jh-hover{background:rgba(245,197,24,.5)}"
+ "html.jh-dark span[data-jh-seg].jh-focus{background:rgba(245,197,24,.44);box-shadow:inset 0 0 0 9999px rgba(245,197,24,.12),0 0 0 1px rgba(245,197,24,.95)}"
+ "span[data-jh-chip]{display:inline-flex;align-items:center;gap:2px;font-size:11.5px;line-height:1;"
+ "background:#fbfbfb;border:1px solid #e0e0e0;border-radius:10px;padding:1px 6px 1px 5px;margin-left:4px;"
+ "vertical-align:.12em;font-family:ui-monospace,Menlo,Consolas,monospace;cursor:pointer;user-select:none;"
Expand Down Expand Up @@ -637,6 +647,7 @@ 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:setThemeMode"){ forcedMode = (d.mode === "dark" || d.mode === "light") ? d.mode : null; sampleTheme(); }
else if (d.type === "jh:ping"){ send({type:"jh:ready"}); }
});

Expand Down
Loading