diff --git a/app/d/[slug]/CommentsShell.tsx b/app/d/[slug]/CommentsShell.tsx index 0680700..1511d2a 100644 --- a/app/d/[slug]/CommentsShell.tsx +++ b/app/d/[slug]/CommentsShell.tsx @@ -5,7 +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 { readMinutesFor, readTimeLevel, readTimeTitle } from "@/lib/docs/reading-time"; import { buildInlineEdits, type TextChange } from "@/lib/docs/inline-edit"; // CommentsShell — the THIRD React surface (birthday.md "Production @@ -94,10 +94,6 @@ 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, 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 // only when the server is confident the doc is dark — gives the shell a dark @@ -173,8 +169,11 @@ export default function CommentsShell(props: Props) { me, initialSections, } = props; - // Seeded from the SSR full-text estimate, refined by the overlay's jh:readtime. - const [readMinutes, setReadMinutes] = useState(props.readMinutes); + // Null until the overlay reports what's VISIBLE (jh:readtime). No chip before that + // and none at all if the overlay never runs: a number counted from the stored HTML + // would include hidden tab panels, so showing one first would mean rendering a + // figure we know to be wrong and then correcting it. + const [readMinutes, setReadMinutes] = useState(null); const [bookmarked, setBookmarked] = useState(props.bookmarked); const bookmarkPending = useRef(false); const iframeRef = useRef(null); @@ -469,11 +468,12 @@ export default function CommentsShell(props: Props) { } break; case "jh:readtime": - // The overlay measured the text actually VISIBLE on load (hidden tab panels - // and closed
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)); + // Word/CJK counts of the text the overlay found VISIBLE. Re-sent as the doc + // settles (a script-built tab UI hides its panels late), so the latest one + // always wins. + if (typeof d.words === "number" && typeof d.cjk === "number") { + setReadMinutes(readMinutesFor(d.words, d.cjk)); + } break; case "jh:selection": if ((canComment || canReact) && d.anchor && d.anchor.exact) { @@ -938,7 +938,7 @@ export default function CommentsShell(props: Props) { {title} - {readMinutes > 0 ? ( + {readMinutes != null && readMinutes > 0 ? ( {readMinutes} min read @@ -1720,7 +1720,7 @@ const RAIL_CSS = ` (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. */ + a bucket change when a late overlay report moves 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); } diff --git a/app/d/[slug]/page.tsx b/app/d/[slug]/page.tsx index d568817..8cdaf8f 100644 --- a/app/d/[slug]/page.tsx +++ b/app/d/[slug]/page.tsx @@ -13,7 +13,6 @@ import { } from "@/lib/docs/comments"; import { detectServerTheme } from "@/lib/docs/theme"; import { extractSections } from "@/lib/docs/sections"; -import { estimateReadMinutes } from "@/lib/docs/reading-time"; import CommentsShell from "./CommentsShell"; export const dynamic = "force-dynamic"; @@ -110,10 +109,6 @@ export default async function ViewerPage({ params, searchParams }: Props) { // ids to headings in document order and paints the gutter link icon. const sections = extractSections(doc.html); - // Estimated read time for the bar — derived from the stored HTML, like the - // section list. 0 for a doc with no prose (the bar omits it). - const readMinutes = estimateReadMinutes(doc.html); - // 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 — // CommentsShell uses it as the initial theme to avoid a light→dark flash before the @@ -143,7 +138,6 @@ export default async function ViewerPage({ params, searchParams }: Props) { initialDocReactions={docReactions} initialAnchoredReactions={anchoredReactions} initialSections={sections} - readMinutes={readMinutes} version={doc.version} initialTheme={serverTheme} /> diff --git a/lib/docs/overlay.ts b/lib/docs/overlay.ts index 9337b47..7d4cbdc 100644 --- a/lib/docs/overlay.ts +++ b/lib/docs/overlay.ts @@ -23,7 +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:readtime", words, cjk } (counts of the VISIBLE text) // { type:"jh:edit", changes:[{before, after}] } (a block was edited) // { type:"jh:editRejected", reason } (edit changed structure; not sent) // @@ -163,19 +163,16 @@ export const OVERLAY_SCRIPT = String.raw` } // ---- 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 - - -

${words(200)}

`; - expect(estimateReadMinutes(html)).toBe(1); + it("adds the two rates for a mixed doc", () => { + expect(readMinutesFor(200, 500)).toBe(2); }); - it("decodes entities rather than counting them as words", () => { - expect(estimateReadMinutes("

Tom & Jerry

")).toBe(1); - }); - - it("counts CJK characters individually (no spaces to tokenize on)", () => { - expect(estimateReadMinutes(`

${"字".repeat(500)}

`)).toBe(1); - expect(estimateReadMinutes(`

${"字".repeat(2000)}

`)).toBe(4); + it("returns 0 when there is nothing to read", () => { + expect(readMinutesFor(0, 0)).toBe(0); }); }); diff --git a/lib/docs/reading-time.ts b/lib/docs/reading-time.ts index 6cd9a03..4f736d5 100644 --- a/lib/docs/reading-time.ts +++ b/lib/docs/reading-time.ts @@ -1,50 +1,29 @@ -// Estimated read time for the viewer chrome bar — a pure function of the stored -// 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. +// Read time for the viewer chrome bar. // -// 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
) -// 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"; +// The count itself comes from the overlay: only inside the sandboxed iframe, against +// a laid-out DOM, can we tell what the reader actually SEES on first load — a tab +// panel hidden by a class in a