Skip to content

Commit 0835e53

Browse files
fix(web): r3 codex review — ribbon ranking, gloss label, dag rm
Three real defects from adversarial review of the r3 diff. - Marginalia ribbon picked the wrong section. With rootMargin -30%/-30% and `intersectionRatio` ranking, multiple sections often qualified simultaneously and the SHORTEST section won (its bounding rect occupied a larger fraction of the centre band). Visible bug: ribbon showed "§ 02 · 7 · 骨格" while the readalong section was dominant. Fix: walk SECTIONS on each observer fire, pick the chapter whose vertical centre is closest to the viewport midpoint, and only if a chapter actually straddles that midpoint. The ribbon now hides when reading the hero / access bookends instead of staying pinned to the last visited chapter. - ReadAlong EN-pane gloss label was a constant. Every EN span carried `SOURCE · {customId}`, so all five spans showed the same chip — the recon brief calls for bidirectional gloss but the label didn't carry per-span identity. Fix: use `glossLabel(p.section, p.page)` like the JA-pane spans, so EN→JA hover surfaces the source section / page, with the JA mate in the aux line. - DAG RAF kept advancing under prefers-reduced-motion. The reducedMotion state zeroed the ink-pressure speed multiplier but the loop still ticked at the base rate, re-rendering React every frame for users who explicitly opted out of motion. Fix: gate the RAF effect on reducedMotion — when true, set static initial state and skip the loop entirely. Defence in depth: also added a `prefers-reduced-motion` media rule zeroing `#paper-stage`'s velocity coupling, in case a future paper-rail refactor ever stops pinning --paper-vel to 0. Codex flags also surfaced false positives (cite-gloss tooltip jumping into <sup>, duplicate 節度 across failures+manifest section-tags) — logged in r3-readout.md "Adversarial review notes". Typecheck clean, vitest 41/41.
1 parent e88ea1f commit 0835e53

4 files changed

Lines changed: 57 additions & 25 deletions

File tree

web/app/globals.css

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1148,6 +1148,13 @@ section[data-paper-hide] {
11481148
pointer-events: none;
11491149
will-change: opacity;
11501150
}
1151+
@media (prefers-reduced-motion: reduce) {
1152+
/* Defence in depth: paper-rail.tsx already pins --paper-vel to 0 under
1153+
reduced-motion (line 855), but if a future refactor ever moved the
1154+
motion-media check, the calc above would silently start animating
1155+
opacity. Strip the velocity coupling explicitly for this cohort. */
1156+
#paper-stage { opacity: var(--paper-fade, 1); }
1157+
}
11511158

11521159
/* Hero → Problem edge bleed. PaperRail writes --paper-edge-progress
11531160
between 0 and 1 during the handoff window where the paper is still

web/components/sections/dag.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,15 @@ export function Dag() {
5454
pressureRef.current = reducedMotion ? 0 : pressure;
5555

5656
useEffect(() => {
57+
// Skip the RAF loop entirely under reduced-motion. Previously the
58+
// loop kept advancing at base speed (just without ink-pressure
59+
// multiplier), so React still re-rendered every frame with new
60+
// `edge` / `t` values for users who explicitly opted out of motion.
61+
if (reducedMotion) {
62+
setEdge(0);
63+
setT(0);
64+
return;
65+
}
5766
let raf = 0;
5867
const tick = (now: number) => {
5968
const last = lastRef.current ?? now;
@@ -75,7 +84,7 @@ export function Dag() {
7584
};
7685
raf = requestAnimationFrame(tick);
7786
return () => cancelAnimationFrame(raf);
78-
}, []);
87+
}, [reducedMotion]);
7988

8089
const fromNode = NODES[edge];
8190
const toNode = NODES[edge + 1] ?? NODES[edge];

web/components/sections/readalong.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,7 @@ export function ReadAlong() {
189189
key={p.pair}
190190
className={"ra-span" + (hovered === p.pair ? " is-on" : "")}
191191
data-pair={p.pair}
192-
data-gloss-label={`SOURCE · ${filer.customId}`}
192+
data-gloss-label={glossLabel(p.section, p.page)}
193193
data-gloss-aux={truncate(p.jp, 60)}
194194
onMouseEnter={() => setHovered(p.pair)}
195195
onMouseLeave={() => setHovered(null)}

web/components/ui/marginalia-ribbon.tsx

Lines changed: 39 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,10 @@ import { useEffect, useState } from "react";
33

44
type SectionMeta = { id: string; num: string; ja: string };
55

6-
// Section-header data, mirrored from each section's `<div className="section-tag">`.
7-
// Phase-4 ships these in the post-Phase-5 §-numbering format so the ribbon
8-
// reads coherently from day one; Phase 5 brings the in-section tags into
9-
// alignment. Hero / Access intentionally omitted — they bookend the document
10-
// and don't carry a §-numbered chapter label.
6+
// Section ids and § labels mirror the in-section `.section-tag` `.num` and
7+
// `.ja` text. Keep these in sync with the corresponding section component.
8+
// Hero / Access intentionally omitted — they bookend the document and don't
9+
// carry a §-numbered chapter label, so the ribbon hides over those zones.
1110
const SECTIONS: SectionMeta[] = [
1211
{ id: "problem", num: "§ 01", ja: "読まれない" },
1312
{ id: "how", num: "§ 02", ja: "仕組み" },
@@ -25,11 +24,13 @@ const SECTIONS: SectionMeta[] = [
2524

2625
// Editorial running header. Sits in the top-right gutter just below the
2726
// topbar, fades in 1.5 s after page-load (same delay as ProgressRail) so
28-
// it never competes with the hero. Tracks the section currently dominant
29-
// in the viewport via an IntersectionObserver tuned to the centre band
30-
// (rootMargin: -30% 0px -30% 0px) so it reads what the user is reading,
31-
// not what is just barely on screen. Hidden on tablet/mobile so the
32-
// reduced viewport keeps the typographic spine.
27+
// it never competes with the hero. Reads which section the reader is in
28+
// by picking the chapter whose vertical centre is closest to the
29+
// viewport's centre — `intersectionRatio` favours shorter sections (their
30+
// area-fraction inside any band is higher), so we recompute on each
31+
// observer fire from `getBoundingClientRect`. The ribbon stays null when
32+
// no observed section straddles the viewport centre — i.e. while the
33+
// user is reading the hero or the access bookends, the ribbon hides.
3334
export function MarginaliaRibbon() {
3435
const [active, setActive] = useState<SectionMeta | null>(null);
3536
const [visible, setVisible] = useState(false);
@@ -40,23 +41,38 @@ export function MarginaliaRibbon() {
4041
}, []);
4142

4243
useEffect(() => {
43-
const obs = new IntersectionObserver(
44-
(entries) => {
45-
const inView = entries
46-
.filter((e) => e.isIntersecting)
47-
.sort((a, b) => b.intersectionRatio - a.intersectionRatio);
48-
if (inView[0]) {
49-
const id = (inView[0].target as HTMLElement).id;
50-
const meta = SECTIONS.find((s) => s.id === id);
51-
if (meta) setActive(meta);
52-
}
53-
},
54-
{ rootMargin: "-30% 0px -30% 0px", threshold: [0, 0.25, 0.5, 0.75, 1] },
55-
);
44+
const recompute = () => {
45+
const viewportCenter = window.innerHeight / 2;
46+
let best: { meta: SectionMeta; dist: number } | null = null;
47+
for (const s of SECTIONS) {
48+
const el = document.getElementById(s.id);
49+
if (!el) continue;
50+
const r = el.getBoundingClientRect();
51+
// Section must straddle the centre line — its top above and its
52+
// bottom below the viewport midpoint. Otherwise we're between
53+
// chapters or outside them entirely (hero / access).
54+
if (r.top > viewportCenter || r.bottom < viewportCenter) continue;
55+
const sectionCenter = r.top + r.height / 2;
56+
const dist = Math.abs(sectionCenter - viewportCenter);
57+
if (!best || dist < best.dist) best = { meta: s, dist };
58+
}
59+
setActive(best ? best.meta : null);
60+
};
61+
62+
const obs = new IntersectionObserver(recompute, {
63+
// A thin centre band — only fires when sections cross the viewport
64+
// midpoint. Combined with the recompute walk above, this gives one
65+
// active section at a time without favouring shorter ones.
66+
rootMargin: "-50% 0px -49.99% 0px",
67+
threshold: 0,
68+
});
5669
SECTIONS.forEach((s) => {
5770
const el = document.getElementById(s.id);
5871
if (el) obs.observe(el);
5972
});
73+
// Run once on mount so first paint isn't blank if the observer's
74+
// first fire is delayed.
75+
recompute();
6076
return () => obs.disconnect();
6177
}, []);
6278

0 commit comments

Comments
 (0)