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
37 changes: 33 additions & 4 deletions app/d/[slug]/CommentsShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { buildChromePalette, type ThemeSample, type ChromePalette } from "@/lib/
import CommentMarkdown from "@/lib/docs/comments/CommentMarkdown";
import type { Section } from "@/lib/docs/sections";
import { fragmentFor, parseHash } from "@/lib/docs/deeplink";
import { readTimeLevel, readTimeTitle } from "@/lib/docs/reading-time";
import { buildInlineEdits, type TextChange } from "@/lib/docs/inline-edit";

// CommentsShell — the THIRD React surface (birthday.md "Production
Expand Down Expand Up @@ -93,8 +94,9 @@ type Props = {
// Ordered heading list + stable fragment ids for section deeplinks (from
// lib/docs/sections extractSections). Forwarded to the overlay as jh:sections.
initialSections: Section[];
// Estimated minutes to read the doc (lib/docs/reading-time). 0 = no prose to
// read, and the bar leaves the slot out entirely.
// Estimated minutes to read the doc, from the stored HTML (lib/docs/reading-time).
// 0 = no prose to read, and the bar leaves the chip out entirely. The overlay
// refines this to the VISIBLE text once the doc has settled (jh:readtime).
readMinutes: number;
version: number;
// Coarse SSR theme (from the stored HTML's unconditional html/body bg). Present
Expand Down Expand Up @@ -170,8 +172,9 @@ export default function CommentsShell(props: Props) {
docId,
me,
initialSections,
readMinutes,
} = props;
// Seeded from the SSR full-text estimate, refined by the overlay's jh:readtime.
const [readMinutes, setReadMinutes] = useState(props.readMinutes);
const [bookmarked, setBookmarked] = useState(props.bookmarked);
const bookmarkPending = useRef(false);
const iframeRef = useRef<HTMLIFrameElement>(null);
Expand Down Expand Up @@ -465,6 +468,13 @@ export default function CommentsShell(props: Props) {
});
}
break;
case "jh:readtime":
// The overlay measured the text actually VISIBLE on load (hidden tab panels
// and closed <details> excluded), which the server's regex over the stored
// HTML can't see. Replaces the SSR estimate; a doc whose overlay never runs
// keeps it.
if (typeof d.minutes === "number" && d.minutes >= 0) setReadMinutes(Math.round(d.minutes));
break;
case "jh:selection":
if ((canComment || canReact) && d.anchor && d.anchor.exact) {
setSelection({ anchor: d.anchor, top: d.rect?.top ?? 0, viewTop: d.rect?.viewTop ?? 0 });
Expand Down Expand Up @@ -929,7 +939,7 @@ export default function CommentsShell(props: Props) {
</span>
<span style={{ flexShrink: 0, paddingLeft: "1.25rem", display: "flex", gap: "1.25rem", alignItems: "center", color: "var(--jh-bar-muted, #666)" }}>
{readMinutes > 0 ? (
<span className="jh-readtime" title={`Estimated read time: ${readMinutes} minute${readMinutes === 1 ? "" : "s"}`}>
<span className="jh-readtime" data-level={readTimeLevel(readMinutes)} title={readTimeTitle(readMinutes)}>
{readMinutes} min read
</span>
) : null}
Expand Down Expand Up @@ -1203,6 +1213,12 @@ function paletteVars(p: ChromePalette): React.CSSProperties {
["--jh-sel-shadow" as string]: p.selShadow,
["--jh-sel-hover" as string]: p.selHover,
["--jh-drawer-shadow" as string]: p.drawerShadow,
// Read-time chip: same fills in both modes, but on a dark bar the grey hairline
// vanishes and the charcoal chip merges into the bar, so those two borders become a
// light tone that outlines them. Literals, not palette-derived — the chip has to
// hold its own contrast whatever colors the doc was sampled at.
["--jh-read-warn-border" as string]: "#e1dccf",
["--jh-read-over-border" as string]: "#f2f0e7",
["--jh-scrim-bg" as string]: p.scrimBg,
};
}
Expand Down Expand Up @@ -1697,6 +1713,19 @@ function LinkGlyph() {
// rail is an off-canvas right drawer that slides in over the doc with a scrim.
// Light mode only, monospace chrome — consistent with the rest of the shell.
const RAIL_CSS = `
/* Read-time chip. Kernel brand palette: the fill gets heavier as the estimate grows —
kernel-green #81b300 under 5 min, beige-muted #e1dccf to 15, charcoal #212225 past
that. The palette has no red or amber and gold is never a background, so the
escalation is contrast weight rather than hue; geometry mirrors the website's Tag
(4px radius, hairline border, 4/8px padding). Borders are var()s because dark needs
different ones (paletteVars). The transition repeats CHROME_TRANSITION's values —
that const is declared below this string, so it can't be interpolated — and covers
the bucket change when the overlay refines the estimate. */
.jh-readtime { font: inherit; font-size: 12px; line-height: 1.6; padding: 2px 8px; border-radius: 4px; border: 0.5px solid; transition: background-color .22s ease, color .22s ease, border-color .22s ease; }
.jh-readtime[data-level="ok"] { background: #81b300; color: #212225; border-color: #81b300; }
.jh-readtime[data-level="warn"] { background: #e1dccf; color: #212225; border-color: var(--jh-read-warn-border, #d0d2d9); }
.jh-readtime[data-level="over"] { background: #212225; color: #f2f0e7; border-color: var(--jh-read-over-border, #212225); }

.jh-stage[data-rail="closed"] .jh-rail { display: none; }
.jh-railclose { display: none; }
.jh-scrim { display: none; }
Expand Down
64 changes: 62 additions & 2 deletions lib/docs/overlay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
// { type:"jh:focus", key, keys } (a segment was clicked: focused key + full covering set)
// { type:"jh:hlHover", id } / { type:"jh:hlHoverOut" }
// { type:"jh:reactionToggle", anchor:{exact,prefix,suffix}, emoji } (chip click)
// { type:"jh:readtime", minutes } (read time of the VISIBLE text)
// { type:"jh:edit", changes:[{before, after}] } (a block was edited)
// { type:"jh:editRejected", reason } (edit changed structure; not sent)
//
Expand Down Expand Up @@ -161,6 +162,60 @@ export const OVERLAY_SCRIPT = String.raw`
} catch(e){}
}

// ---- visible read time (jh:readtime) ----
// The server's estimate counts every word in the stored HTML. Only here, with a
// laid-out DOM, can we tell what the reader actually SEES: a tab panel hidden by a
// class defined in a <style> block, a closed <details>, a dialog that hasn't been
// opened. We prune hidden subtrees, count the text that survives, and post the
// minutes for the shell's chip.
//
// Rate constants MUST match lib/docs/reading-time.ts (server code this stringified
// script cannot import) — same duplication as the reaction sig comment above.
var RT_WPM = 200, RT_CJK_PER_MIN = 500;
var RT_CJK = /[\p{sc=Han}\p{sc=Hiragana}\p{sc=Katakana}\p{sc=Hangul}]/gu;
var RT_WORDISH = /[\p{L}\p{N}]/u;
// Non-prose (the server masks the same set), plus our own injected UI: a reaction
// chip's "👍 2" and a section anchor's link glyph are chrome, not the doc.
var RT_SKIP = { SCRIPT:1, STYLE:1, TEMPLATE:1, NOSCRIPT:1, SVG:1, CANVAS:1 };
function rtHidden(el){
if (el.hasAttribute("hidden") || el.getAttribute("aria-hidden") === "true") return true;
if (el.nodeName === "DIALOG" && !el.open) return true;
var cs = getComputedStyle(el);
if (cs.display === "none" || cs.visibility === "hidden" || cs.visibility === "collapse") return true;
return parseFloat(cs.opacity) === 0;
}
// Recursive rather than a TreeWalker: pruning a hidden subtree is one
// getComputedStyle per element instead of one per ancestor per text node.
function rtCollect(el, out){
for (var n = el.firstChild; n; n = n.nextSibling){
if (n.nodeType === 3){ out.push(n.nodeValue); continue; }
if (n.nodeType !== 1) continue;
var tag = n.nodeName.toUpperCase();
if (RT_SKIP[tag]) continue;
if (n.hasAttribute("data-jh-chip") || n.hasAttribute("data-jh-sec-anchor")) continue;
if (rtHidden(n)) continue;
// A closed <details> shows its summary only; the rest is a click away.
if (tag === "DETAILS" && !n.open){
var sum = n.querySelector("summary");
if (sum) rtCollect(sum, out);
continue;
}
rtCollect(n, out);
}
}
function reportReadTime(){
try {
if (!document.body) return;
var parts = []; rtCollect(document.body, parts);
var text = parts.join(" ");
var cjk = (text.match(RT_CJK) || []).length;
var words = 0, toks = text.replace(RT_CJK, " ").split(/\s+/);
for (var i=0;i<toks.length;i++) if (RT_WORDISH.test(toks[i])) words++;
var minutes = (words === 0 && cjk === 0) ? 0 : Math.ceil(words/RT_WPM + cjk/RT_CJK_PER_MIN);
send({ type:"jh:readtime", minutes: minutes });
} catch(e){}
}

// ---- forced document theme (viewer's light/dark toggle) ----
// 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}
Expand Down Expand Up @@ -1152,7 +1207,12 @@ export const OVERLAY_SCRIPT = String.raw`
// late-applied CSS and for any host that never sends jh:themeMode.
authoredTheme = sampleAuthored();
try { ensureStyle(); if (document.documentElement) document.documentElement.classList.toggle("jh-dark", effectiveDark()); } catch(e){}
window.addEventListener("load", sampleTheme);
setTimeout(sampleTheme, 400);
// Read time rides the theme sample's load + settle ticks, plus one later tick of its
// own: measured too early, a doc whose tab UI is built by script still has every
// panel visible and we would just re-report the server's full-text number. Each
// report supersedes the last, so a doc that only settles late still converges.
window.addEventListener("load", function(){ sampleTheme(); reportReadTime(); });
setTimeout(function(){ sampleTheme(); reportReadTime(); }, 400);
setTimeout(reportReadTime, 1500);
})();
`;
21 changes: 20 additions & 1 deletion lib/docs/reading-time.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { estimateReadMinutes } from "@/lib/docs/reading-time";
import { estimateReadMinutes, readTimeLevel, readTimeTitle } from "@/lib/docs/reading-time";

const words = (n: number) => Array.from({ length: n }, (_, i) => `w${i}`).join(" ");

Expand Down Expand Up @@ -38,3 +38,22 @@ describe("estimateReadMinutes", () => {
expect(estimateReadMinutes(`<p>${"字".repeat(2000)}</p>`)).toBe(4);
});
});

describe("readTimeLevel", () => {
it("steps at 5 and 15 minutes", () => {
expect([1, 4].map(readTimeLevel)).toEqual(["ok", "ok"]);
expect([5, 15].map(readTimeLevel)).toEqual(["warn", "warn"]);
expect([16, 90].map(readTimeLevel)).toEqual(["over", "over"]);
});
});

describe("readTimeTitle", () => {
it("spells out the estimate so the fill color isn't the only signal", () => {
expect(readTimeTitle(1)).toBe("Estimated read time: 1 minute");
expect(readTimeTitle(7)).toBe("Estimated read time: 7 minutes");
});

it("names the problem past 15 minutes", () => {
expect(readTimeTitle(22)).toBe("Estimated read time: 22 minutes — long for a shared doc");
});
});
36 changes: 33 additions & 3 deletions lib/docs/reading-time.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
// Estimated read time for the viewer chrome bar — a pure function of the stored
// HTML, computed at SSR alongside extractSections. Nothing is stored and nothing
// is recomputed client-side: an inline edit changes the estimate on the next load,
// the same as the title and the section list.
// HTML, computed at SSR alongside extractSections. Nothing is stored: an inline
// edit changes the estimate on the next load, the same as the title and the
// section list.
//
// The SSR number counts every word in the stored HTML. The overlay re-measures
// what is actually VISIBLE on first load (hidden tab panels, closed <details>)
// and posts jh:readtime; the shell prefers that. This module stays the definition
// of the rate and the thresholds — the overlay duplicates the rate constants
// because it is stringified browser JS and cannot import server code.

import { htmlToText } from "@/lib/docs/anchor";

Expand Down Expand Up @@ -41,3 +47,27 @@ export function estimateReadMinutes(html: string): number {
if (words === 0 && cjk === 0) return 0;
return Math.ceil(words / WORDS_PER_MINUTE + cjk / CJK_PER_MINUTE);
}

// A shared doc should be a short read; the chip's fill gets heavier as the estimate
// grows. Under 5 minutes reads as fine, up to 15 as getting long, past 15 as "this
// should probably be two docs".
export type ReadLevel = "ok" | "warn" | "over";

const WARN_FROM = 5;
const OVER_ABOVE = 15;

export function readTimeLevel(minutes: number): ReadLevel {
if (minutes < WARN_FROM) return "ok";
if (minutes <= OVER_ABOVE) return "warn";
return "over";
}

/**
* The chip's tooltip — the estimate in words, since the fill color alone is no use to
* a screen reader, plus what the heaviest fill is complaining about.
*/
export function readTimeTitle(minutes: number): string {
const plural = `${minutes} minute${minutes === 1 ? "" : "s"}`;
const suffix = readTimeLevel(minutes) === "over" ? " — long for a shared doc" : "";
return `Estimated read time: ${plural}${suffix}`;
}
Loading