WolfDavid commited on
Commit
03d5ccf
Β·
1 Parent(s): fd38c7c

docs(02-08): complete the word-lookup popover plan

Browse files
.planning/ROADMAP.md CHANGED
@@ -61,7 +61,7 @@ Decimal phases appear between their surrounding integers in numeric order.
61
  - [x] 02-05-PLAN.md β€” The canonical analyze(text) + the 28-sentence golden fixture set (SC-1) [wave 3]
62
  - [x] 02-06-PLAN.md β€” tokens on the directive; analyze / translate / language_info server functions; warm-up on load; turn-loop + facade surface; parity [wave 4]
63
  - [x] 02-07-PLAN.md β€” Furigana: transcript.js renderer, three-mode control + level picker, persistence, three-layer numbers (JPN-02) [wave 5]
64
- - [ ] 02-08-PLAN.md β€” Word-lookup popover with published numbers, desktop + Pixel 7 rows (JPN-03) [wave 6]
65
  - [ ] 02-09-PLAN.md β€” Inline EN reveal with per-line cache, translation latency harness (JPN-04) [wave 7]
66
  - [ ] 02-11-PLAN.md β€” LICENSES.md rows + on-page credits + README mirror + docs/LANGUAGE.md; credits test extension [wave 8]
67
  - [ ] 02-10-PLAN.md β€” Owner pushes; deployed rows on the Space; translation latency + container record; phone rows; requirements marked (user-gated) [wave 9]
 
61
  - [x] 02-05-PLAN.md β€” The canonical analyze(text) + the 28-sentence golden fixture set (SC-1) [wave 3]
62
  - [x] 02-06-PLAN.md β€” tokens on the directive; analyze / translate / language_info server functions; warm-up on load; turn-loop + facade surface; parity [wave 4]
63
  - [x] 02-07-PLAN.md β€” Furigana: transcript.js renderer, three-mode control + level picker, persistence, three-layer numbers (JPN-02) [wave 5]
64
+ - [x] 02-08-PLAN.md β€” Word-lookup popover with published numbers, desktop + Pixel 7 rows (JPN-03) [wave 6]
65
  - [ ] 02-09-PLAN.md β€” Inline EN reveal with per-line cache, translation latency harness (JPN-04) [wave 7]
66
  - [ ] 02-11-PLAN.md β€” LICENSES.md rows + on-page credits + README mirror + docs/LANGUAGE.md; credits test extension [wave 8]
67
  - [ ] 02-10-PLAN.md β€” Owner pushes; deployed rows on the Space; translation latency + container record; phone rows; requirements marked (user-gated) [wave 9]
.planning/STATE.md CHANGED
@@ -3,13 +3,13 @@ gsd_state_version: 1.0
3
  milestone: v1.0
4
  milestone_name: milestone
5
  status: unknown
6
- stopped_at: "Completed 02-07-PLAN.md (wave 5): DOM-built ruby transcript (Task 1 landed in a prior session at 7d7a567), furigana controls + level picker + localStorage persistence resumed from an uncommitted tree (af23443), rehearsal script + three-layer furigana proof (bdeb461). Rule 1 fix: sudachipy.Tokenizer is now per-thread (Already borrowed lost 5 of 8 concurrent analyses and shipped tokens: []). Standalone rtH 11px, line 35.59 vs 33.59; both transports 32.39 vs 29.39, storage ok; deployed rehearsal 3 passed with the N3 level read back after reload - verified locally, awaiting the owner's push. Quick loop 331 passed / 35.2 s; whole e2e 32 passed 21 skipped 0 failed. JPN-02 NOT marked. Next 02-08."
7
- last_updated: "2026-09-07T03:35:45.974Z"
8
  progress:
9
  total_phases: 6
10
  completed_phases: 1
11
  total_plans: 22
12
- completed_plans: 18
13
  ---
14
 
15
  # Project State
@@ -24,7 +24,7 @@ See: .planning/PROJECT.md (updated 2026-08-08)
24
  ## Current Position
25
 
26
  Phase: 02 (japanese-language-core) β€” EXECUTING
27
- Plan: 8 of 11
28
 
29
  ## Performance Metrics
30
 
@@ -68,6 +68,7 @@ Plan: 8 of 11
68
  | Phase 02 P05 | 12 | 2 tasks | 7 files |
69
  | Phase 02 P06 | 38 | 3 tasks | 10 files |
70
  | Phase 02 P07 | multi-session | 3 tasks | 13 files |
 
71
 
72
  ## Accumulated Context
73
 
@@ -156,6 +157,9 @@ Recent decisions affecting current work:
156
  - [Phase 02]: getDebug().furigana does not exist at ready - host.js binds after boot from the shared template - so every furigana read waits for window.Avatar.__debug.furigana; reading the selects in that window made a passing D-14 reload look like a failure
157
  - [Phase 02]: The learner line's analyze round trip measures 14.6-22.7 s in-browser against 0.02-0.19 ms of server work (Gradio serving it around a 12-19 s headless synthesis), so the plan's 5 s budget is recorded and printed, never asserted; the product consequence is logged to deferred-items with two levers
158
  - [Phase 02]: Deployed rows are rehearsed through scripts/rehearse_deployed.py (free port, DISABLE_GPU=1, /config polled, app stopped, pytest's exit code) - the reusable local-deployed harness for the rest of Phase 2; 02-07's three furigana rows are verified locally and await the owner's push, and JPN-02 stays Pending for 02-10
 
 
 
159
 
160
  ### Pending Todos
161
 
 
3
  milestone: v1.0
4
  milestone_name: milestone
5
  status: unknown
6
+ stopped_at: "Completed 02-08-PLAN.md (wave 6): the word-lookup popover (89a4bf6) and its three-layer proof incl. the Pixel 7 column (fd38c7c). Tap a word -> an anchored read-only card with reading, up to three JMdict senses, dictionary form and a level badge, built from the token the line already holds: request_counter records ZERO requests under both transports AND on the deployed page. Two fixes the phone row found: the card could be clamped ON TOP of its own anchor (the next tap was then swallowed silently), and #transcript-text had no edges at all - it is now a 40vh scrolling log, which also makes host.js's scrollTop = scrollHeight do something for the first time. Standalone 6 passed; both transports 2 passed with requests []; deployed rehearsal 2 passed (desktop senses ['study', 'diligence; working hard', 'experience; knowledge; lesson (for the future)'], Pixel 7 column 324x336 with insideTranscript true at the top and the bottom of the line) - verified locally, awaiting the owner's push. Quick loop 334 passed / 34.5 s; whole e2e 36 passed 23 skipped 0 failed. JPN-03 NOT marked. Next 02-09."
7
+ last_updated: "2026-09-07T05:10:00.000Z"
8
  progress:
9
  total_phases: 6
10
  completed_phases: 1
11
  total_plans: 22
12
+ completed_plans: 19
13
  ---
14
 
15
  # Project State
 
24
  ## Current Position
25
 
26
  Phase: 02 (japanese-language-core) β€” EXECUTING
27
+ Plan: 9 of 11
28
 
29
  ## Performance Metrics
30
 
 
68
  | Phase 02 P05 | 12 | 2 tasks | 7 files |
69
  | Phase 02 P06 | 38 | 3 tasks | 10 files |
70
  | Phase 02 P07 | multi-session | 3 tasks | 13 files |
71
+ | Phase 02 P08 | 95 | 2 tasks | 8 files |
72
 
73
  ## Accumulated Context
74
 
 
157
  - [Phase 02]: getDebug().furigana does not exist at ready - host.js binds after boot from the shared template - so every furigana read waits for window.Avatar.__debug.furigana; reading the selects in that window made a passing D-14 reload look like a failure
158
  - [Phase 02]: The learner line's analyze round trip measures 14.6-22.7 s in-browser against 0.02-0.19 ms of server work (Gradio serving it around a 12-19 s headless synthesis), so the plan's 5 s budget is recorded and printed, never asserted; the product consequence is logged to deferred-items with two levers
159
  - [Phase 02]: Deployed rows are rehearsed through scripts/rehearse_deployed.py (free port, DISABLE_GPU=1, /config polled, app stopped, pytest's exit code) - the reusable local-deployed harness for the rest of Phase 2; 02-07's three furigana rows are verified locally and await the owner's push, and JPN-02 stays Pending for 02-10
160
+ - [Phase 02]: The word lookup is a PURE CLIENT ACTION and it is asserted as one: a request_counter wraps the taps under both transports and on the deployed page and records [] every time. The glosses ride inside the token record the line already holds, which is why the iframe transport needed no new bridge call for JPN-03
161
+ - [Phase 02]: A popover clamped into the visible column can land ON TOP of the word it is anchored to, and the next tap is then swallowed in silence (the capture-phase dismiss returns early because the target is inside the card, and pointerup finds no .tok). Placement now picks the side of the word with more room and caps the card to that room, so it scrolls internally instead of growing over its anchor
162
+ - [Phase 02]: insideTranscript can only be a fact if the transcript has edges: #transcript-text was an auto-height block, so ANY card anchored to the last line hung below it and the plan's phone must-have was unreachable on the real page. It is now height: 40vh / min-height: 14em / overflow-y: auto (335 px on a Pixel 7), which also makes host.js's long-standing el.scrollTop = el.scrollHeight do something for the first time. A 196 px min-height was tried first and was still too short - the card covered the line above its anchor
163
 
164
  ### Pending Todos
165
 
.planning/phases/02-japanese-language-core/02-08-SUMMARY.md ADDED
@@ -0,0 +1,226 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ phase: 02-japanese-language-core
3
+ plan: 08
4
+ subsystem: ui
5
+ tags: [popover, lookup, jmdict, glosses, jlpt, dom, transcript, pointer-events, touch, pixel-7, playwright, parity, deployed-rehearsal, jpn-03]
6
+
7
+ # Dependency graph
8
+ requires:
9
+ - phase: 02-japanese-language-core
10
+ plan: 07
11
+ provides: "avatar/transcript.js (createTranscript, the retained tokens Map, the bindPointer seam, debug.furigana), transcript.css, transcript-harness.html, host.js's bindHost(avatar, doc, transcriptModule), scripts/rehearse_deployed.py"
12
+ - phase: 02-japanese-language-core
13
+ plan: 05
14
+ provides: "the 14-key token record - surface, reading, lemma, jlpt ('N5'..'N1'|'N1+'|'name'), gloss (senses x glosses), tappable - and the 28-sentence golden fixture"
15
+ - phase: 02-japanese-language-core
16
+ plan: 06
17
+ provides: "AvatarDirective.tokens on every turn, so the glosses are already on the page when a word is tapped"
18
+ - phase: 01-voice-avatar-loop-skeleton
19
+ plan: 09
20
+ provides: "the deployed suite, --space-url, wait_for_avatar_ready, speech_events, request_counter"
21
+ provides:
22
+ - "avatar/transcript.js: openPopover(lineId, tokenIndex) / closePopover() / bindPointer(), the #lookup-popover card built once from the token's own record, and debug.popover {open, lineId, tokenIndex, surface, reading, level, glossCount, left, top, width, insideTranscript, openCount, closeCount}"
23
+ - "avatar/transcript.css: #lookup-popover and its .lk-* parts, five level-rank hues on the badge and a 1 px rank underline on tappable words; #transcript-text is now a 40vh scrolling column"
24
+ - "avatar/host.js: one transcript.bindPointer() at bind and transcript.debug.popover published on __debug (POPOVER_DEBUG_SEED for the module-less fallback)"
25
+ - "avatar/transcript-harness.html: bindPointer() called, window.__popoverDebug live, ?narrow=1 is a 300 px SCROLLING column"
26
+ - "tests/test_transport_seam.py: test_popover_is_dom_only_and_read_only, test_popover_publishes_its_position, test_host_binds_pointer_once"
27
+ - "tests/e2e/test_transcript_standalone.py: test_popover_anchors_and_dismisses, test_popover_stays_inside_narrow_column (real touchscreen taps)"
28
+ - "tests/e2e/test_facade_parity.py: test_lookup_popover_under_both_transports[inline|iframe] - zero requests under either"
29
+ - "tests/e2e/test_avatar_loop.py: test_lookup_popover (desktop) and test_lookup_popover_mobile (Pixel 7 descriptor), plus _tap_mouse / _tap_touch / _box helpers"
30
+ affects: [02-09, 02-10, 02-11, phase-3-tutor-turns]
31
+
32
+ # Tech tracking
33
+ tech-stack:
34
+ added: []
35
+ patterns:
36
+ - "The lookup is a PURE CLIENT ACTION and that is asserted, not asserted-about: a request_counter wraps the taps at both the parity and deployed layers and must record an empty list. It is the one number that proves the glosses really ride inside the token rather than being fetched on demand"
37
+ - "The card is placed on whichever side of the tapped word has more room and is capped to that room (it scrolls internally), so 'it stayed inside the column' and 'it did not cover the word' are true by construction rather than by luck about content length"
38
+ - "insideTranscript can only be a fact if the transcript has edges: #transcript-text became a real 40vh scrolling log, which also makes host.js's long-standing `el.scrollTop = el.scrollHeight` do something for the first time"
39
+ - "Phone rows use playwright.devices['Pixel 7'] with page.touchscreen.tap on a has_touch context, never page.click - research Pitfall 5. Elements are scroll_into_view_if_needed() first, because on a phone the transcript is below the fold and a tap at an off-viewport coordinate lands on nothing"
40
+ - "The badge is compared against the level in the page's OWN lastTokens record, never a literal in the test - the same rule the furigana oracle follows, so a wrong record and a wrong badge cannot agree"
41
+
42
+ key-files:
43
+ created: []
44
+ modified:
45
+ - avatar/transcript.js
46
+ - avatar/transcript.css
47
+ - avatar/transcript-harness.html
48
+ - avatar/host.js
49
+ - tests/test_transport_seam.py
50
+ - tests/e2e/test_transcript_standalone.py
51
+ - tests/e2e/test_facade_parity.py
52
+ - tests/e2e/test_avatar_loop.py
53
+
54
+ key-decisions:
55
+ - "A card clamped into the visible column can land ON TOP of the word it is anchored to, which silently makes 'tap the next word to re-anchor' impossible - the second tap hits the card, the capture-phase dismiss returns early because the target is inside #lookup-popover, and nothing happens at all. Placement now picks the side of the word with more room and caps the card to that room"
56
+ - "A 196 px column is not tall enough to hold a two-line conversation AND a 90 px card: the card covered the line above its anchor and made those words untappable. #transcript-text is a 40vh scrolling log (335 px on a Pixel 7, 288 px on a 720 px desktop) with a 14em floor, rather than an auto-height block with a max"
57
+ - "insideTranscript is measured against the container's own rect with a 0.5 px tolerance, and the offsets are computed in the container's PADDING-box coordinates (clientLeft / clientTop off the viewport rects, plus scrollTop) - the plan's formula used the border-box left, which is 9 px out in the harness"
58
+ - "The Popover API was not used (research Β§ Q6): iOS 16 Safari lacks it and its top-layer behaviour inside the Hub's cross-origin frame is unverified. The positioned div is ~60 lines and every one of its decisions is a published number"
59
+ - "The card is read-only (D-08) and the seam test enforces it as an absence: no fetch(, no XMLHttpRequest, no localStorage, no sessionStorage, no contextmenu and no long-press plumbing anywhere in transcript.js"
60
+
61
+ patterns-established:
62
+ - "Three-layer lookup proof: standalone harness (no server, both mouse and touch) -> both transports (two live Gradio apps, request counter) -> deployed rows rehearsed locally on a free port, one of them on a phone descriptor. A standalone pass with a deployed failure localises the fault to the host page by construction"
63
+ - "Every geometry claim publishes the rect it was derived from, so a failure prints the card rect AND the container rect rather than a bare boolean"
64
+
65
+ requirements-completed: [] # JPN-03 deliberately NOT marked - 02-VALIDATION binds it to 02-10's deployed rows against the pushed Space
66
+
67
+ # Metrics
68
+ duration: 95min
69
+ completed: 2026-09-07
70
+ ---
71
+
72
+ # Phase 02 Plan 08: Word-Lookup Popover Summary
73
+
74
+ **Tapping a word opens an anchored, read-only card carrying its reading, its JMdict senses, its dictionary form and its JLPT badge, built entirely from the token record the line already holds - proven with a request counter that records ZERO requests under both transports and on the deployed page - and the two placement bugs that only a phone-width column could expose (a card clamped on top of its own anchor, and a column too short to hold the conversation and the card at once) were found by the Pixel 7 row and fixed.**
75
+
76
+ ## Performance
77
+
78
+ - **Duration:** ~95 min, single session.
79
+ - **Completed:** 2026-09-07
80
+ - **Tasks:** 2 of 2
81
+ - **Files created:** 0. **Files modified:** 8.
82
+
83
+ ## Task Commits
84
+
85
+ 1. **Task 1: the popover in transcript.js + CSS, pointer wiring, harness tests, host hook** β€” `89a4bf6` (feat)
86
+ 2. **Task 2: both-transports and deployed rows, desktop and Pixel 7, plus the two placement fixes they found** β€” `fd38c7c` (test)
87
+
88
+ ## What shipped
89
+
90
+ `transcript.js` gained `openPopover(lineId, tokenIndex)`, `closePopover()` and a real `bindPointer()`:
91
+
92
+ - the `#lookup-popover` element is built once, appended to `#transcript-text`, `role="dialog" aria-label="Word lookup"`, hidden with the `hidden` attribute, every field written with `textContent`;
93
+ - the card carries `.lk-surface` / `.lk-reading` / `.lk-level` (with `data-level` for the five rank hues), `.lk-lemma` (`dictionary form: X`, only when the lemma differs from the surface), an `<ol class="lk-gloss">` of the first three senses (each sense's glosses joined with `; `), and `.lk-none` ("no dictionary entry") when the word has none;
94
+ - the badge reads `N5`..`N1` as itself, `N1+` as **`N1+ / beyond lists`** and `name` as `name` (D-10 / D-12) β€” the card never invents a level;
95
+ - `pointerup` on the column opens, a **capture-phase `pointerdown` on the document** dismisses unless the target is inside `#lookup-popover, .tok` (so a tap on another word re-anchors without passing through a closed state β€” `closeCount` stays 0 across a re-anchor, and that is asserted), `Escape` closes, `Enter`/`Space` open the focused word, and the column's `scroll` closes;
96
+ - `setMode` / `setLevel` / `setTokens` close the card first (Pitfall 10), enforced by a seam test that reads each function's body.
97
+
98
+ `host.js` calls the renderer's `bindPointer()` exactly once and publishes `transcript.debug.popover` on `__debug`; it owns none of the card's DOM.
99
+
100
+ ## Measured numbers
101
+
102
+ ### Standalone (`tests/e2e/test_transcript_standalone.py`, **6 passed**)
103
+
104
+ `田中さんは東京に住んでいます。` as an avatar line, 1280x720:
105
+
106
+ | tap | published |
107
+ | --- | --- |
108
+ | `田中さん` (a name, no JMdict entry) | `level 'name'`, `glossCount 0`, `openCount 1`, `insideTranscript true`, card **218.25 x 85.13 px**; `.lk-level` reads `name`, `.lk-lemma` reads `dictionary form: η”°δΈ­`, `.lk-none` visible |
109
+ | empty column space | `open false`, `closeCount 1` |
110
+ | `住んでいます` | `tokenIndex 4`, `level 'N5'` (= the fixture record's), `glossCount 1`, `openCount 2`, `.lk-lemma` contains `住む` |
111
+ | `Escape` | `open false`, `closeCount 2` |
112
+ | Tab from `#level-select` -> `.tok[data-token=0]`, Enter | `open true`, `openCount 3` |
113
+
114
+ `.tok` count 3 and `.plain` count 3 on that line: `は` / `に` / `。` are not words (D-11).
115
+
116
+ **The 300 px phone column** (`?narrow=1`, 412x839, `has_touch`, six copies of the long fixture β€” column **318 x 353.6 px**, `clientWidth 316`, `clientHeight 352`, `scrollHeight 431`, so it really scrolls):
117
+
118
+ | tap (real `touchscreen.tap`) | published |
119
+ | --- | --- |
120
+ | the LAST word of the LAST line (`θ‘ŒγγΎγ—γŸ`) | `open true`, **`insideTranscript true`**, `left 8`, `width 300`, `glossCount 3`; card rect `left 25 / top 157.59 / right 325 / bottom 355.52` (**300 x 197.92**) β€” flipped ABOVE the word |
121
+ | scroll the column | `open false`, `closeCount 1` |
122
+ | the FIRST word of the FIRST line (`今ζ—₯`) | `open true`, `openCount 2`, **`insideTranscript true`**, card **243.63 x 89.95** β€” below the word |
123
+ | scroll again | `open false`, `closeCount 2` |
124
+
125
+ ### Both transports (`test_lookup_popover_under_both_transports`, **2 passed**)
126
+
127
+ Typed turn `田中さんは東京に住んでいます。`, then two taps inside a `request_counter`:
128
+
129
+ | number | inline | iframe |
130
+ | --- | --- | --- |
131
+ | `田中さん` | `level 'name'`, `glossCount 0`, `openCount 1` | identical |
132
+ | `住んでいます` | `tokenIndex 4`, `level 'N5'`, `glossCount 1`, `openCount 2`, `closeCount 0` | identical |
133
+ | card left/top/width | `161 / 66 / 252.86` | `161 / 66 / 252.86` |
134
+ | **requests during both taps** | **`[]`** | **`[]`** |
135
+ | tap `#intro` | `open false`, `closeCount 1` | `open false`, `closeCount 1` |
136
+
137
+ The iframe column is the one that mattered: a design that fetched glosses on tap would have needed a new bridge call there, and this row would have recorded it.
138
+
139
+ ### Deployed rows, rehearsed locally (`scripts/rehearse_deployed.py`, **2 passed**)
140
+
141
+ App answered `/config` after **4.1 s**.
142
+
143
+ **`test_lookup_popover` (desktop, 1280x720).** `ζ—₯本θͺžγ‚’勉強しています。`, whole turn **24,000 ms**; tap `勉強しています` (token 2):
144
+
145
+ - `popover` = `{open: true, lineId: 'L2', tokenIndex: 2, surface: '勉強しています', reading: 'べんきょうしています', level: 'N5', glossCount: 3, left: 100, top: 66, width: 291.84, insideTranscript: true, openCount: 1, closeCount: 0}`
146
+ - card rect `left 880 / top 347.33 / right 1171.84 / bottom 477.91` = **291.84 x 130.58 px**
147
+ - senses shown: **`['study', 'diligence; working hard', 'experience; knowledge; lesson (for the future)']`**
148
+ - badge `N5` == the page's own `lastTokens[2].jlpt`; lemma line `dictionary form: 勉強する`; reading `べんきょうしています`
149
+ - **requests during the tap: `[]`**
150
+ - the three `.plain` spans on that line carry no `role`
151
+ - tapping the learner's own `ζ—₯本θͺž` re-anchors: `lineId 'L1'`, `level 'N1+'`, `openCount 2`, `closeCount 0` (D-03 / D-18 parity); `Escape` closes it
152
+
153
+ **`test_lookup_popover_mobile` (Pixel 7 descriptor, 412x839, real touch).** `今ζ—₯γ―γ„γ„ε€©ζ°—γ§γ™γ‹γ‚‰γ€ε…¬εœ’γ‚’ζ•£ζ­©γ—γ¦γ‹γ‚‰γ€θ²·γ„η‰©γ«θ‘ŒγγΎγ—γŸγ€‚`, the avatar's line **35,031 ms** after Enter:
154
+
155
+ - transcript column: `{left: 44, top: 332.52, width: 324, height: 335.59, clientWidth: 324, clientHeight: 336, scrollHeight: 336, scrollTop: 0}`
156
+ - tap the LAST word (`θ‘ŒγγΎγ—γŸ`): `{open: true, tokenIndex: 14, level: 'N5', glossCount: 3, left: 8, top: 125, width: 308, insideTranscript: true, openCount: 1}`; card rect `left 52 / top 457.52 / right 360 / bottom 607.36` = **308 x 149.84 px** β€” inside a column that runs 332.52 -> 668.11
157
+ - tap the FIRST word (`今ζ—₯`): `{open: true, tokenIndex: 0, level: 'N5', glossCount: 2, insideTranscript: true, openCount: 2}`; card **208.20 x 87.33** at `left 88 / top 428.52`
158
+ - tap the intro text: `open false`, `closeCount 1`
159
+
160
+ **These deployed rows are verified LOCALLY, against a `DISABLE_GPU=1 app.py` on loopback, and await the owner's push.** Nothing was pushed; the only remote is the owner's HF `space`.
161
+
162
+ ### Suite totals
163
+
164
+ | suite | result |
165
+ | --- | --- |
166
+ | `tests/e2e/test_transcript_standalone.py` | **6 passed** |
167
+ | `tests/test_transport_seam.py` | **100 passed** |
168
+ | `test_lookup_popover_under_both_transports` | **2 passed** |
169
+ | `rehearse_deployed.py ... -k lookup_popover` | **2 passed** |
170
+ | whole `tests/e2e/` in ONE invocation | **36 passed, 23 skipped, 0 failed in 323 s** |
171
+ | quick loop (`tests --ignore=tests/e2e -m "not mt"`) | **334 passed / 17 deselected in 34.5 s** (budget 40 s) |
172
+ | `-m mt` | 17 passed |
173
+ | `git diff --stat -- avatar/avatar.js avatar/avatar-iframe.js avatar/stage.html avatar/turn-loop.js avatar/facade.js` | **0 lines** β€” no new surface needed |
174
+ | ruff check + format --check | clean (57 files) |
175
+
176
+ ## Deviations from Plan
177
+
178
+ ### Auto-fixed issues
179
+
180
+ **1. [Rule 1 - Bug] The card could be clamped on top of the word it was anchored to, which broke re-anchoring**
181
+
182
+ - **Found during:** Task 2, by `test_lookup_popover_under_both_transports` β€” the second tap read `tokenIndex 0` with `openCount 1`, i.e. it did nothing at all.
183
+ - **Issue:** the plan's placement rule was "below, flip above if it overflows, otherwise clamp into view". On a column shorter than word + card, neither side fits, so the clamp put the card *over* its own line. The second tap then landed on the card: the capture-phase dismiss returns early (the target is inside `#lookup-popover`) and `pointerup` finds no `.tok`, so the tap was swallowed silently β€” the worst shape of failure, because nothing errors and nothing moves.
184
+ - **Fix:** the card is placed on whichever side of the word has more room (`roomBelow >= roomAbove`) and its `max-height` is set to that room, so it scrolls internally rather than growing over the anchor. Offsets are computed in the container's padding-box coordinates (`clientLeft` / `clientTop` off the viewport rects) rather than the plan's border-box `left`, which was 9 px out in the harness.
185
+ - **Files modified:** `avatar/transcript.js`
186
+ - **Commit:** `fd38c7c`
187
+
188
+ **2. [Rule 2 - Missing critical functionality] `insideTranscript` was not a fact anywhere, because the transcript had no edges**
189
+
190
+ - **Found during:** Task 1's first standalone run: `insideTranscript` read `false` on a perfectly correct card, because `#transcript-text` was an auto-height block one line tall and *any* card anchored to the last line hangs below it. The plan's must-have ("on a 412 px phone column the popover stays inside the transcript") was unreachable on the real page, not just in the harness.
191
+ - **Fix:** `#transcript-text` is now a real scrolling log β€” `height: 40vh; min-height: 14em; overflow-y: auto` (335 px on a Pixel 7, 288 px on a 720 px desktop). This also makes `host.js`'s long-standing `el.scrollTop = el.scrollHeight` do something for the first time; on an auto-height element it had always been a no-op, so a long conversation grew the page instead of scrolling the transcript.
192
+ - **Second iteration:** a `min-height: 14em` (196 px) block was still too short β€” the Pixel 7 row then showed the card covering the line *above* its anchor, so the first word of the line was untappable. A viewport fraction, not a content-driven minimum, is what gives the card room beside the words.
193
+ - **Files modified:** `avatar/transcript.css`, `avatar/transcript-harness.html` (its own `min-height: 2em` was dropped so the harness measures the shape the app ships)
194
+ - **Commits:** `89a4bf6` (the column), `fd38c7c` (40vh)
195
+
196
+ ### Knowing deviations from the plan's letter
197
+
198
+ - **The plan's standalone narrow row said "render fixture #25 three times so the container scrolls".** Three copies do not overflow a 300 px column; six do, and the row now *asserts* `scrollHeight > clientHeight` before relying on it, so the scroll-dismiss claim can never pass vacuously.
199
+ - **`test_popover_anchors_and_dismisses` asserts `insideTranscript` too**, which the plan only asked of the narrow row. It is free once the column has edges, and it is the assertion that caught deviation 2.
200
+ - **The seam test spells "no long-press" as `contextmenu` absent plus `longpress` absent** rather than the plan's bare `long`, which would match `belongs`, `along` and `longest`.
201
+ - **`transcript.css` uses double-quoted attribute selectors** (`[data-level="N5"]`) because the plan's acceptance grep is written with double quotes; the file has no other quoted selectors, so there is no inconsistency.
202
+ - **The mobile row waits for the avatar's line (its `.tok` spans), not for speech-end.** The line is rendered from the `turn` event, which arrives before playback; waiting for the audio would have added ~20 s to a row that says nothing about audio.
203
+ - **Task 2's commit is `test(...)` although it carries the two renderer fixes above.** The bulk of it is the two new test layers, and the fixes exist only because those layers found them; both are named in the commit body.
204
+
205
+ ### Out of scope, logged not fixed
206
+
207
+ Nothing new. The two known-flaky items in `deferred-items.md` (Phase 1's `running_after_click_ms` ceiling and `test_asr_wasm_fallback` under whole-file load) did not fire in this plan's runs.
208
+
209
+ ## Known Stubs
210
+
211
+ None. Every field on the card is filled from the token record; there is no placeholder text and no hardcoded gloss anywhere in `transcript.js`.
212
+
213
+ ## Requirements
214
+
215
+ **JPN-03 deliberately NOT marked complete.** `02-VALIDATION.md` binds it to 02-10's deployed rows against the *pushed* Space; everything here is verified locally. Nothing was run through `requirements mark-complete`.
216
+
217
+ ## For The Next Plan
218
+
219
+ - **02-09 (inline EN reveal):** the transcript is now a **scrolling** column, so anything that grows a line (a translation under it) must expect the container's `scroll` event β€” which closes the lookup card. If the reveal scrolls the line into view, do it deliberately and expect `popover.open` to go false.
220
+ - `transcript.getTokens(lineId)` and `openPopover(lineId, tokenIndex)` are exported; `debug.popover` is live on `getDebug()` under both transports. **Wait for `window.Avatar.__debug.popover` (`POPOVER_BOUND`), not `ready`** β€” same bind race as `furigana`.
221
+ - `_tap_mouse` / `_tap_touch` / `_box` in `tests/e2e/test_avatar_loop.py` are the shared raw-input helpers (they scroll the element into view first, which a phone viewport needs); `playwright.devices["Pixel 7"]` + `browser.new_context(**...)` is the phone-row recipe.
222
+ - **Any new absolutely-positioned UI inside `#transcript-text` must publish where it landed**, and be capped to the room beside its anchor rather than clamped over it β€” the failure mode is a silently swallowed tap, not an error.
223
+
224
+ ## Self-Check: PASSED
225
+
226
+ All eight modified files exist on disk and both commits (`89a4bf6`, `fd38c7c`) are in `git log`.