From 82d9b4510011eb2359cfb5c40b34248cf4915aa3 Mon Sep 17 00:00:00 2001 From: sjmiller609 <7516283+sjmiller609@users.noreply.github.com> Date: Fri, 31 Jul 2026 15:54:54 +0000 Subject: [PATCH] Show the read-time chip only once the visible text is measured MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bar used to paint a server-side estimate over the whole stored HTML and then swap in the overlay's visible-text number, so a doc with hidden tab panels showed a figure we already knew was wrong. There is now no server estimate: the overlay posts word/CJK counts for the text it finds visible, the shell turns those into minutes, and no chip renders until that arrives. The overlay also stops reporting on the theme sample's early ticks — a count taken before a script-built tab UI hides its panels would put up a number that then has to be corrected. It reports once the doc has settled and once more later for a doc that rearranges itself very late. Co-Authored-By: Claude Opus 5 --- app/d/[slug]/CommentsShell.tsx | 28 ++++++++-------- app/d/[slug]/page.tsx | 6 ---- lib/docs/overlay.ts | 40 +++++++++++------------ lib/docs/reading-time.test.ts | 41 ++++++++--------------- lib/docs/reading-time.ts | 59 +++++++++++----------------------- 5 files changed, 67 insertions(+), 107 deletions(-) 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