WolfDavid commited on
Commit
89a4bf6
·
1 Parent(s): 3a22ca4

feat(02-08): word-lookup popover (reading, glosses, level, dictionary form) with published numbers

Browse files

- transcript.js: openPopover / closePopover / bindPointer and a seeded debug.popover
(open, lineId, tokenIndex, surface, reading, level, glossCount, left, top, width,
insideTranscript, openCount, closeCount). The card is filled from the token record the
line already holds, so a tap issues no request of any kind
- pointerup opens, a capture-phase pointerdown on the document dismisses (so a tap on
another word re-anchors without passing through a closed state), Escape closes,
Enter/Space open the focused word, the column's scroll closes; setMode / setLevel /
setTokens close first, because a re-render replaces the anchor
- transcript.css: the card, five level-rank hues on the badge and a 1 px rank underline on
tappable words; #transcript-text becomes a real column (min-height 9em, max-height 40vh,
overflow-y auto) so "the card stayed inside the transcript" is a measurable fact
- host.js calls the renderer's bindPointer once and publishes debug.popover on __debug
- standalone rows: anchor / dismiss / re-anchor / Escape / Tab+Enter, and insideTranscript
on a 300 px scrolling column driven with real touch input

avatar/host.js CHANGED
@@ -24,6 +24,11 @@
24
  // written only through storageRead / storageWrite, which never throw - Safari inside a
25
  // cross-origin iframe throws on the access itself (research Pitfall 9), and the page
26
  // then runs in memory with furigana.storage reporting 'unavailable'.
 
 
 
 
 
27
 
28
  /** Matches REARM_TAIL_MS in mic.js: the controls re-enable when the mic may re-arm. */
29
  const REENABLE_AFTER_SPEECH_MS = 200;
@@ -46,6 +51,23 @@ const FURIGANA_DEBUG_SEED = {
46
  lastLineHeightPx: 0,
47
  };
48
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
  /** Enter finishes a Japanese IME composition before it submits; this skips that Enter. */
50
  function isComposing(event) {
51
  return event.isComposing || event.keyCode === 229;
@@ -116,7 +138,11 @@ export function bindHost(avatar, doc = document, transcriptModule = null) {
116
  // The SAME object the renderer writes to, so getDebug() reads live numbers; the facade
117
  // merges the turn loop's and the stage's keys over the top and leaves this one intact.
118
  const furigana = transcript ? transcript.debug.furigana : { ...FURIGANA_DEBUG_SEED };
119
- if (avatar.__debug) avatar.__debug.furigana = furigana;
 
 
 
 
120
 
121
  let spoken = false; // whether anything has been said yet, for replay/slower
122
  let busy = false;
@@ -265,7 +291,9 @@ export function bindHost(avatar, doc = document, transcriptModule = null) {
265
  if (!storageWrite(win, STORAGE_LEVEL_KEY, value)) furigana.storage = 'unavailable';
266
  });
267
  }
268
- transcript.bindPointer(); // plan 02-08 fills this with the lookup popover's tap handling
 
 
269
  }
270
 
271
  // ------------------------------------------------------------------- avatar -> page
 
24
  // written only through storageRead / storageWrite, which never throw - Safari inside a
25
  // cross-origin iframe throws on the access itself (research Pitfall 9), and the page
26
  // then runs in memory with furigana.storage reporting 'unavailable'.
27
+ //
28
+ // Plan 02-08: the word-lookup card is the renderer's too - this file only calls
29
+ // the renderer's bindPointer once and publishes transcript.debug.popover on __debug. The
30
+ // lookup itself is a pure client action: the glosses ride inside the tokens the line
31
+ // already holds, so a tap issues no request under either transport.
32
 
33
  /** Matches REARM_TAIL_MS in mic.js: the controls re-enable when the mic may re-arm. */
34
  const REENABLE_AFTER_SPEECH_MS = 200;
 
51
  lastLineHeightPx: 0,
52
  };
53
 
54
+ /** The lookup card's numbers (plan 02-08), seeded for the same reason. */
55
+ const POPOVER_DEBUG_SEED = {
56
+ open: false,
57
+ lineId: null,
58
+ tokenIndex: null,
59
+ surface: null,
60
+ reading: null,
61
+ level: null,
62
+ glossCount: 0,
63
+ left: 0,
64
+ top: 0,
65
+ width: 0,
66
+ insideTranscript: null,
67
+ openCount: 0,
68
+ closeCount: 0,
69
+ };
70
+
71
  /** Enter finishes a Japanese IME composition before it submits; this skips that Enter. */
72
  function isComposing(event) {
73
  return event.isComposing || event.keyCode === 229;
 
138
  // The SAME object the renderer writes to, so getDebug() reads live numbers; the facade
139
  // merges the turn loop's and the stage's keys over the top and leaves this one intact.
140
  const furigana = transcript ? transcript.debug.furigana : { ...FURIGANA_DEBUG_SEED };
141
+ const popover = transcript ? transcript.debug.popover : { ...POPOVER_DEBUG_SEED };
142
+ if (avatar.__debug) {
143
+ avatar.__debug.furigana = furigana;
144
+ avatar.__debug.popover = popover;
145
+ }
146
 
147
  let spoken = false; // whether anything has been said yet, for replay/slower
148
  let busy = false;
 
291
  if (!storageWrite(win, STORAGE_LEVEL_KEY, value)) furigana.storage = 'unavailable';
292
  });
293
  }
294
+ // Plan 02-08: the tap wiring for the lookup card lives in the renderer, so the page and
295
+ // the standalone harness install exactly the same handlers. Called once, at bind.
296
+ transcript.bindPointer();
297
  }
298
 
299
  // ------------------------------------------------------------------- avatar -> page
avatar/transcript-harness.html CHANGED
@@ -10,10 +10,11 @@
10
  margin: 16px;
11
  font: 16px/1.4 system-ui, sans-serif;
12
  }
 
 
13
  #transcript-text {
14
  border: 1px solid #ccc;
15
  padding: 8px;
16
- min-height: 2em;
17
  }
18
  </style>
19
  </head>
@@ -31,7 +32,9 @@
31
  never pass a 0-based index. window.__fixtureUnits(number) returns the same
32
  sentence's token records for an oracle to compute against.
33
 
34
- Query parameters: ?narrow=1 sets #transcript-text to 300 px (plan 02-08's phone column).
 
 
35
  -->
36
  <div id="reading-controls" class="reading-controls">
37
  <label for="furigana-mode">Furigana</label>
@@ -55,12 +58,18 @@
55
 
56
  const params = new URLSearchParams(location.search);
57
  const container = document.getElementById('transcript-text');
58
- if (params.get('narrow') === '1') container.style.width = '300px';
 
 
 
 
59
 
60
  const t = createTranscript(container);
 
61
  window.__transcript = t;
62
- // The same object the renderer writes to, not a copy: a test reads live numbers.
63
  window.__transcriptDebug = t.debug.furigana;
 
64
  window.__transcriptReady = false;
65
 
66
  document.getElementById('furigana-mode').addEventListener('change', (e) => {
 
10
  margin: 16px;
11
  font: 16px/1.4 system-ui, sans-serif;
12
  }
13
+ /* Border and padding only: the column's height and overflow are transcript.css's, so
14
+ the harness measures the shape the app ships rather than one of its own. */
15
  #transcript-text {
16
  border: 1px solid #ccc;
17
  padding: 8px;
 
18
  }
19
  </style>
20
  </head>
 
32
  never pass a 0-based index. window.__fixtureUnits(number) returns the same
33
  sentence's token records for an oracle to compute against.
34
 
35
+ Query parameters: ?narrow=1 sets #transcript-text to a 300 px SCROLLING column - the
36
+ phone shape plan 02-08's popover has to stay inside, and the only way the container's
37
+ own scroll event (which closes the card) can fire at all.
38
  -->
39
  <div id="reading-controls" class="reading-controls">
40
  <label for="furigana-mode">Furigana</label>
 
58
 
59
  const params = new URLSearchParams(location.search);
60
  const container = document.getElementById('transcript-text');
61
+ if (params.get('narrow') === '1') {
62
+ container.style.width = '300px';
63
+ container.style.maxHeight = '360px';
64
+ container.style.overflowY = 'auto';
65
+ }
66
 
67
  const t = createTranscript(container);
68
+ t.bindPointer();
69
  window.__transcript = t;
70
+ // The same objects the renderer writes to, not copies: a test reads live numbers.
71
  window.__transcriptDebug = t.debug.furigana;
72
+ window.__popoverDebug = t.debug.popover;
73
  window.__transcriptReady = false;
74
 
75
  document.getElementById('furigana-mode').addEventListener('change', (e) => {
avatar/transcript.css CHANGED
@@ -8,9 +8,18 @@
8
  * the app through a <link> inside the transcript's gr.HTML value and by
9
  * avatar/transcript-harness.html relative to itself.
10
  */
 
 
 
 
 
 
11
  #transcript-text {
12
  position: relative;
13
  overflow-x: clip;
 
 
 
14
  }
15
  #transcript-text .said {
16
  line-height: 2.1;
@@ -33,6 +42,104 @@
33
  background: rgba(0, 0, 0, 0.06);
34
  outline: none;
35
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
  #reading-controls {
37
  display: flex;
38
  gap: 0.5em;
 
8
  * the app through a <link> inside the transcript's gr.HTML value and by
9
  * avatar/transcript-harness.html relative to itself.
10
  */
11
+ /* The transcript is a COLUMN with a top and a bottom, not an ever-growing block. host.js has
12
+ * always ended every line with `el.scrollTop = el.scrollHeight`, which is a no-op on an
13
+ * auto-height element - so a long conversation grew the page instead of scrolling the
14
+ * transcript. The height is also what makes plan 02-08's `insideTranscript` a fact rather
15
+ * than a coincidence: a card can only be proven to have stayed inside a box that has edges,
16
+ * and on a phone that box is all the room there is. */
17
  #transcript-text {
18
  position: relative;
19
  overflow-x: clip;
20
+ overflow-y: auto;
21
+ min-height: 9em;
22
+ max-height: 40vh;
23
  }
24
  #transcript-text .said {
25
  line-height: 2.1;
 
42
  background: rgba(0, 0, 0, 0.06);
43
  outline: none;
44
  }
45
+ /* The word-level badge on the word itself (D-14): one pixel of the same rank colour under a
46
+ * tappable unit, so the transcript carries the level information the card spells out. */
47
+ #transcript-text .tok[data-level] {
48
+ border-bottom: 1px solid rgba(0, 0, 0, 0.25);
49
+ }
50
+ #transcript-text .tok[data-level="N5"] {
51
+ border-bottom-color: #2e7d32;
52
+ }
53
+ #transcript-text .tok[data-level="N4"] {
54
+ border-bottom-color: #558b2f;
55
+ }
56
+ #transcript-text .tok[data-level="N3"] {
57
+ border-bottom-color: #f9a825;
58
+ }
59
+ #transcript-text .tok[data-level="N2"] {
60
+ border-bottom-color: #ef6c00;
61
+ }
62
+ #transcript-text .tok[data-level="N1"] {
63
+ border-bottom-color: #c62828;
64
+ }
65
+
66
+ /* ---------------------------------------------- the lookup card (plan 02-08, research Q6)
67
+ *
68
+ * A positioned div, not the Popover API: iOS 16 Safari lacks it and its top-layer behaviour
69
+ * inside the Hub's cross-origin frame is unverified. transcript.js sets left/top from
70
+ * getBoundingClientRect and clamps them into #transcript-text (which is position: relative
71
+ * above), so the card can never leave the column on a phone. max-height keeps a three-sense
72
+ * entry inside a short column; z-index 5 clears the transcript's own content only.
73
+ */
74
+ #lookup-popover {
75
+ position: absolute;
76
+ max-width: min(320px, calc(100% - 16px));
77
+ max-height: 240px;
78
+ overflow-y: auto;
79
+ z-index: 5;
80
+ box-sizing: border-box;
81
+ padding: 8px 10px;
82
+ border-radius: 6px;
83
+ background: #fff;
84
+ color: #111;
85
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.18);
86
+ font-size: 0.95em;
87
+ line-height: 1.45;
88
+ }
89
+ #lookup-popover .lk-head {
90
+ display: flex;
91
+ gap: 0.5em;
92
+ align-items: baseline;
93
+ flex-wrap: wrap;
94
+ }
95
+ #lookup-popover .lk-surface {
96
+ font-weight: 600;
97
+ font-size: 1.15em;
98
+ }
99
+ #lookup-popover .lk-reading {
100
+ color: #555;
101
+ }
102
+ #lookup-popover .lk-level {
103
+ font-size: 0.75em;
104
+ padding: 0 0.4em;
105
+ border-radius: 3px;
106
+ color: #fff;
107
+ background: #777;
108
+ white-space: nowrap;
109
+ }
110
+ #lookup-popover .lk-level[data-level="N5"] {
111
+ background: #2e7d32;
112
+ }
113
+ #lookup-popover .lk-level[data-level="N4"] {
114
+ background: #558b2f;
115
+ }
116
+ #lookup-popover .lk-level[data-level="N3"] {
117
+ background: #f9a825;
118
+ }
119
+ #lookup-popover .lk-level[data-level="N2"] {
120
+ background: #ef6c00;
121
+ }
122
+ #lookup-popover .lk-level[data-level="N1"] {
123
+ background: #c62828;
124
+ }
125
+ #lookup-popover .lk-level[data-level="N1+"],
126
+ #lookup-popover .lk-level[data-level="name"] {
127
+ background: #777;
128
+ }
129
+ #lookup-popover .lk-lemma {
130
+ color: #555;
131
+ font-size: 0.9em;
132
+ }
133
+ #lookup-popover .lk-gloss {
134
+ margin: 0.3em 0 0;
135
+ padding-left: 1.2em;
136
+ }
137
+ #lookup-popover .lk-none {
138
+ color: #777;
139
+ font-size: 0.9em;
140
+ margin-top: 0.3em;
141
+ }
142
+
143
  #reading-controls {
144
  display: flex;
145
  gap: 0.5em;
avatar/transcript.js CHANGED
@@ -12,8 +12,17 @@
12
  // hides the annotation cannot pass a count (research Pitfall 4, the T-pose lesson).
13
  //
14
  // Every line's tokens are retained, so a mode or level change re-renders every line from
15
- // data with no round trip (D-14). bindPointer() is the seam plan 02-08 fills with tap
16
- // handling; plan 02-09 adds the translation reveal under each line.
 
 
 
 
 
 
 
 
 
17
  //
18
  // The token record (plan 02-05, nlp/analyzer.TOKEN_KEYS): `surface`, `tappable`, `jlpt`,
19
  // `kanji_levels` ({kanji: "N5".."N1" | null}) and `ruby` ([[text, rt | null], ...] - one
@@ -29,6 +38,26 @@ export const LEVELS = ['N5', 'N4', 'N3', 'N2'];
29
 
30
  const LABELS = { you: 'You: ', avatar: 'Avatar: ', slower: 'Avatar (slower): ' };
31
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
  /**
33
  * D-02 / D-13: a run gets its rt when the mode says so; 'above' keys on the KANJI axis -
34
  * any kanji in the run above the learner's level, or unlisted (null, D-10), keeps the whole
@@ -65,7 +94,7 @@ export function createTranscript(container, { doc = document } = {}) {
65
  furigana: {
66
  mode: 'always',
67
  level: 'N5',
68
- storage: 'unknown', // host.js sets 'ok' | 'unavailable' after its localStorage probe
69
  lines: 0,
70
  rtTotal: 0,
71
  lastLineId: null,
@@ -74,6 +103,24 @@ export function createTranscript(container, { doc = document } = {}) {
74
  lastLineRtHeightPx: 0,
75
  lastLineHeightPx: 0,
76
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
77
  };
78
 
79
  /** lineId -> { el, said, who, text } in insertion order. */
@@ -194,13 +241,230 @@ export function createTranscript(container, { doc = document } = {}) {
194
  */
195
  function setTokens(lineId, tokens) {
196
  if (!lines.has(lineId)) return false;
 
197
  tokensByLine.set(lineId, Array.isArray(tokens) ? tokens : []);
198
  render(lineId);
199
  return true;
200
  }
201
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
202
  function setMode(value) {
203
  if (!FURIGANA_MODES.includes(value)) return mode;
 
204
  mode = value;
205
  debug.furigana.mode = mode;
206
  renderAll();
@@ -209,6 +473,7 @@ export function createTranscript(container, { doc = document } = {}) {
209
 
210
  function setLevel(value) {
211
  if (!LEVELS.includes(value)) return level;
 
212
  level = value;
213
  debug.furigana.level = level;
214
  renderAll();
@@ -223,9 +488,9 @@ export function createTranscript(container, { doc = document } = {}) {
223
  getMode: () => mode,
224
  getLevel: () => level,
225
  getTokens: (lineId) => tokensByLine.get(lineId) || null,
226
- bindPointer() {
227
- // plan 02-08: tap handling for .tok (the lookup popover) is installed here.
228
- },
229
  debug,
230
  };
231
  }
 
12
  // hides the annotation cannot pass a count (research Pitfall 4, the T-pose lesson).
13
  //
14
  // Every line's tokens are retained, so a mode or level change re-renders every line from
15
+ // data with no round trip (D-14), and a tap on a word (plan 02-08) reads its reading, its
16
+ // dictionary form, its level and its JMdict glosses straight out of the record the line
17
+ // already holds - the lookup issues no request of any kind (D-05 / D-07, research § Q3).
18
+ // Plan 02-09 adds the translation reveal under each line.
19
+ //
20
+ // The card is a positioned <div>, not the Popover API: iOS 16 Safari is still in the field
21
+ // without it and its top-layer behaviour inside the Hub's cross-origin frame is unverified
22
+ // (research § Q6). Anchoring is 40 lines of getBoundingClientRect + clamp that publish
23
+ // their numbers on debug.popover, so "it stayed inside the phone column" is an assertion
24
+ // rather than a screenshot. Press-and-hold is deliberately not a gesture here (D-04), and
25
+ // the card is read-only: no save, no tap history (D-08).
26
  //
27
  // The token record (plan 02-05, nlp/analyzer.TOKEN_KEYS): `surface`, `tappable`, `jlpt`,
28
  // `kanji_levels` ({kanji: "N5".."N1" | null}) and `ruby` ([[text, rt | null], ...] - one
 
38
 
39
  const LABELS = { you: 'You: ', avatar: 'Avatar: ', slower: 'Avatar (slower): ' };
40
 
41
+ /**
42
+ * The badge text for a token's WORD-level axis. N5..N1 read as themselves; the two values
43
+ * the analyser uses for "not on a list" are spelled out on the card so the learner is never
44
+ * told a level the data does not claim (D-10 / D-12).
45
+ */
46
+ const LEVEL_LABELS = { 'N1+': 'N1+ / beyond lists', name: 'name' };
47
+
48
+ /** D-07: the card shows the first two or three senses, English glosses only. */
49
+ const MAX_SENSES = 3;
50
+
51
+ /** The gap between the tapped word and the card, and the inset from the column's edges. */
52
+ const ANCHOR_GAP_PX = 6;
53
+ const EDGE_INSET_PX = 8;
54
+ /** The card never grows past this, nor past the column it must stay inside (see position). */
55
+ const CARD_MAX_HEIGHT_PX = 240;
56
+
57
+ function clamp(value, low, high) {
58
+ return Math.min(Math.max(value, low), high);
59
+ }
60
+
61
  /**
62
  * D-02 / D-13: a run gets its rt when the mode says so; 'above' keys on the KANJI axis -
63
  * any kanji in the run above the learner's level, or unlisted (null, D-10), keeps the whole
 
94
  furigana: {
95
  mode: 'always',
96
  level: 'N5',
97
+ storage: 'unknown', // host.js sets 'ok' | 'unavailable' after its browser-storage probe
98
  lines: 0,
99
  rtTotal: 0,
100
  lastLineId: null,
 
103
  lastLineRtHeightPx: 0,
104
  lastLineHeightPx: 0,
105
  },
106
+ // The lookup card's numbers (plan 02-08, research § Q6). insideTranscript is the phone
107
+ // assertion: the card's rect within the column's rect. null until the first open, so
108
+ // "never opened" and "opened outside" are different readings.
109
+ popover: {
110
+ open: false,
111
+ lineId: null,
112
+ tokenIndex: null,
113
+ surface: null,
114
+ reading: null,
115
+ level: null,
116
+ glossCount: 0,
117
+ left: 0,
118
+ top: 0,
119
+ width: 0,
120
+ insideTranscript: null,
121
+ openCount: 0,
122
+ closeCount: 0,
123
+ },
124
  };
125
 
126
  /** lineId -> { el, said, who, text } in insertion order. */
 
241
  */
242
  function setTokens(lineId, tokens) {
243
  if (!lines.has(lineId)) return false;
244
+ closePopover(); // the line's spans are rebuilt below; the card's anchor would be stale
245
  tokensByLine.set(lineId, Array.isArray(tokens) ? tokens : []);
246
  render(lineId);
247
  return true;
248
  }
249
 
250
+ // ------------------------------------------------------------- the lookup card (02-08)
251
+
252
+ const popover = debug.popover;
253
+ /** Built on the first open and reused; `card.el` is the one #lookup-popover element. */
254
+ let card = null;
255
+ let pointerBound = false;
256
+
257
+ function buildCard() {
258
+ const el = doc.createElement('div');
259
+ el.id = 'lookup-popover';
260
+ el.className = 'lookup-popover';
261
+ el.setAttribute('role', 'dialog');
262
+ el.setAttribute('aria-label', 'Word lookup');
263
+ el.hidden = true;
264
+
265
+ const head = doc.createElement('div');
266
+ head.className = 'lk-head';
267
+ const surface = doc.createElement('span');
268
+ surface.className = 'lk-surface';
269
+ const reading = doc.createElement('span');
270
+ reading.className = 'lk-reading';
271
+ const badge = doc.createElement('span');
272
+ badge.className = 'lk-level';
273
+ head.append(surface, reading, badge);
274
+
275
+ const lemma = doc.createElement('div');
276
+ lemma.className = 'lk-lemma';
277
+ const gloss = doc.createElement('ol');
278
+ gloss.className = 'lk-gloss';
279
+ const none = doc.createElement('div');
280
+ none.className = 'lk-none';
281
+ none.textContent = 'no dictionary entry';
282
+ none.hidden = true;
283
+
284
+ el.append(head, lemma, gloss, none);
285
+ container.append(el);
286
+ card = { el, surface, reading, badge, lemma, gloss, none };
287
+ return card;
288
+ }
289
+
290
+ /**
291
+ * Anchor the card under (or above) the tapped word and publish where it landed.
292
+ *
293
+ * Offsets are in the container's PADDING-BOX coordinates - what `position: absolute`
294
+ * inside `#transcript-text` resolves against - so `clientLeft` / `clientTop` (the border
295
+ * widths) come off the viewport rects, and `scrollTop` converts the visible position into
296
+ * the scrolled content's. The card is clamped horizontally into the column, flipped above
297
+ * the word when it would fall out of the bottom, and, when the column is tall enough to
298
+ * hold it at all, clamped into view vertically as a last resort - which is what keeps
299
+ * insideTranscript true on a 300 px phone column.
300
+ */
301
+ function position(el, tokEl) {
302
+ el.style.left = '0px';
303
+ el.style.top = '0px';
304
+ // A three-sense entry can be taller than a phone's transcript column. Capping the card
305
+ // to the column (it scrolls internally past that) is what turns "it fitted this time"
306
+ // into "it always fits": every branch below can then place it inside.
307
+ el.style.maxHeight = `${Math.min(
308
+ CARD_MAX_HEIGHT_PX,
309
+ Math.max(60, container.clientHeight - 2 * EDGE_INSET_PX)
310
+ )}px`;
311
+ const contRect = container.getBoundingClientRect();
312
+ const tokRect = tokEl.getBoundingClientRect();
313
+ const width = el.offsetWidth;
314
+ const height = el.offsetHeight;
315
+
316
+ const maxLeft = Math.max(EDGE_INSET_PX, container.clientWidth - width - EDGE_INSET_PX);
317
+ const left = clamp(
318
+ tokRect.left - contRect.left - container.clientLeft,
319
+ EDGE_INSET_PX,
320
+ maxLeft
321
+ );
322
+
323
+ const viewTop = container.scrollTop;
324
+ const viewBottom = viewTop + container.clientHeight;
325
+ const base = tokRect.top - contRect.top - container.clientTop + viewTop;
326
+ let top = base + tokRect.height + ANCHOR_GAP_PX;
327
+ if (top + height > viewBottom) {
328
+ const above = base - height - ANCHOR_GAP_PX;
329
+ if (above >= viewTop) top = above;
330
+ }
331
+ if (height <= container.clientHeight) {
332
+ top = clamp(top, viewTop + 2, viewBottom - height - 2);
333
+ }
334
+
335
+ el.style.left = `${Math.round(left)}px`;
336
+ el.style.top = `${Math.round(top)}px`;
337
+
338
+ const rect = el.getBoundingClientRect();
339
+ popover.left = Math.round(left);
340
+ popover.top = Math.round(top);
341
+ popover.width = rect.width;
342
+ popover.insideTranscript =
343
+ rect.left >= contRect.left - 0.5 &&
344
+ rect.right <= contRect.right + 0.5 &&
345
+ rect.top >= contRect.top - 0.5 &&
346
+ rect.bottom <= contRect.bottom + 0.5;
347
+ }
348
+
349
+ /**
350
+ * Show the card for one token of one line, filled from THAT token's own record.
351
+ *
352
+ * Every field is written with textContent: the glosses are dictionary data and the
353
+ * surfaces can be anything the learner typed. Returns a copy of the published numbers.
354
+ *
355
+ * @param {string} lineId
356
+ * @param {number} tokenIndex the index the `.tok` carries in data-token
357
+ */
358
+ function openPopover(lineId, tokenIndex) {
359
+ const line = lines.get(lineId);
360
+ const token = (tokensByLine.get(lineId) || [])[tokenIndex];
361
+ if (!line || !token) return { ...popover };
362
+ const tokEl = line.said.querySelector(`.tok[data-token="${tokenIndex}"]`);
363
+ if (!tokEl) return { ...popover };
364
+ const c = card || buildCard();
365
+
366
+ const surface = String(token.surface ?? '');
367
+ const badgeValue = token.jlpt == null ? '' : String(token.jlpt);
368
+ c.surface.textContent = surface;
369
+ c.reading.textContent = String(token.reading ?? '');
370
+ c.badge.textContent = LEVEL_LABELS[badgeValue] ?? badgeValue;
371
+ c.badge.hidden = badgeValue === '';
372
+ if (badgeValue) c.badge.dataset.level = badgeValue;
373
+ else delete c.badge.dataset.level;
374
+
375
+ const lemma = String(token.lemma ?? '');
376
+ const showLemma = lemma !== '' && lemma !== surface;
377
+ c.lemma.textContent = showLemma ? `dictionary form: ${lemma}` : '';
378
+ c.lemma.hidden = !showLemma;
379
+
380
+ const senses = Array.isArray(token.gloss) ? token.gloss.slice(0, MAX_SENSES) : [];
381
+ c.gloss.replaceChildren();
382
+ for (const sense of senses) {
383
+ const item = doc.createElement('li');
384
+ item.textContent = Array.isArray(sense) ? sense.join('; ') : String(sense);
385
+ c.gloss.append(item);
386
+ }
387
+ c.gloss.hidden = senses.length === 0;
388
+ c.none.hidden = senses.length > 0;
389
+
390
+ c.el.hidden = false;
391
+ position(c.el, tokEl);
392
+
393
+ popover.open = true;
394
+ popover.lineId = lineId;
395
+ popover.tokenIndex = tokenIndex;
396
+ popover.surface = surface;
397
+ popover.reading = c.reading.textContent;
398
+ popover.level = badgeValue || null;
399
+ popover.glossCount = senses.length;
400
+ popover.openCount += 1;
401
+ return { ...popover };
402
+ }
403
+
404
+ /** Hide the card. A no-op (and not counted) when it is already closed. */
405
+ function closePopover() {
406
+ if (!popover.open) return { ...popover };
407
+ if (card) card.el.hidden = true;
408
+ popover.open = false;
409
+ popover.closeCount += 1;
410
+ return { ...popover };
411
+ }
412
+
413
+ function openFromElement(tok) {
414
+ const line = tok.closest('[data-line]');
415
+ if (!line) return null;
416
+ return openPopover(line.dataset.line, Number(tok.dataset.token));
417
+ }
418
+
419
+ /**
420
+ * Install the tap wiring. Idempotent: the host calls it once at bind, the standalone
421
+ * harness calls it too, so there is exactly one implementation of the gesture.
422
+ *
423
+ * `pointerup` on the column covers mouse, touch and pen alike, with no 300 ms delay
424
+ * (the page has a viewport meta and `.tok` is touch-action: manipulation). The dismiss
425
+ * listener is a CAPTURE-phase pointerdown on the document, so a tap on another word
426
+ * falls through to the pointerup above and re-anchors WITHOUT passing through a closed
427
+ * state. Scrolling the column moves the anchor, so it closes (research § Q6). No
428
+ * press-and-hold gesture exists (D-04).
429
+ */
430
+ function bindPointer() {
431
+ if (pointerBound) return false;
432
+ pointerBound = true;
433
+
434
+ container.addEventListener('pointerup', (event) => {
435
+ const tok = event.target?.closest?.('.tok');
436
+ if (!tok) return;
437
+ openFromElement(tok);
438
+ });
439
+
440
+ doc.addEventListener(
441
+ 'pointerdown',
442
+ (event) => {
443
+ if (event.target?.closest?.('#lookup-popover, .tok')) return;
444
+ closePopover();
445
+ },
446
+ true
447
+ );
448
+
449
+ doc.addEventListener('keydown', (event) => {
450
+ if (event.key === 'Escape') {
451
+ closePopover();
452
+ return;
453
+ }
454
+ if (event.key !== 'Enter' && event.key !== ' ') return;
455
+ const tok = event.target?.closest?.('.tok');
456
+ if (!tok) return;
457
+ event.preventDefault();
458
+ openFromElement(tok);
459
+ });
460
+
461
+ container.addEventListener('scroll', () => closePopover());
462
+ return true;
463
+ }
464
+
465
  function setMode(value) {
466
  if (!FURIGANA_MODES.includes(value)) return mode;
467
+ closePopover(); // Pitfall 10: a re-render replaces the .tok the card is anchored to
468
  mode = value;
469
  debug.furigana.mode = mode;
470
  renderAll();
 
473
 
474
  function setLevel(value) {
475
  if (!LEVELS.includes(value)) return level;
476
+ closePopover();
477
  level = value;
478
  debug.furigana.level = level;
479
  renderAll();
 
488
  getMode: () => mode,
489
  getLevel: () => level,
490
  getTokens: (lineId) => tokensByLine.get(lineId) || null,
491
+ openPopover,
492
+ closePopover,
493
+ bindPointer,
494
  debug,
495
  };
496
  }
tests/e2e/test_transcript_standalone.py CHANGED
@@ -34,8 +34,17 @@ STUDY_TEXT = "日本語を勉強しています。"
34
  STUDY_READING_FIRST = "にほんご"
35
  TANAKA = 13
36
  HELLO = 1
 
 
 
37
 
38
  DEBUG = "() => JSON.parse(JSON.stringify(window.__transcriptDebug))"
 
 
 
 
 
 
39
 
40
 
41
  def _units(number: int) -> list[dict]:
@@ -65,6 +74,202 @@ def _set(page, select_id: str, value: str) -> dict:
65
  return _debug(page)
66
 
67
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
  def test_ruby_renders_with_numbers(page, static_server):
69
  """D-01: the first line renders with every kanji annotated and nothing touched.
70
 
 
34
  STUDY_READING_FIRST = "にほんご"
35
  TANAKA = 13
36
  HELLO = 1
37
+ # The long fixture, used to make a 300 px column scroll: 今日はいい天気ですから、公園を散歩して
38
+ # から、買い物に行きました。 - eight tappable units, several of them with three senses.
39
+ LONG = 25
40
 
41
  DEBUG = "() => JSON.parse(JSON.stringify(window.__transcriptDebug))"
42
+ POPOVER = "() => JSON.parse(JSON.stringify(window.__popoverDebug))"
43
+
44
+ # The narrow column plan 02-08 has to survive: a Pixel-7-width viewport with the harness's
45
+ # ?narrow=1 column (300 px wide, 360 px tall, scrolling).
46
+ PHONE_VIEWPORT = {"width": 412, "height": 839}
47
+ NARROW_COLUMN_PX = 300
48
 
49
 
50
  def _units(number: int) -> list[dict]:
 
74
  return _debug(page)
75
 
76
 
77
+ def _popover(page) -> dict:
78
+ """The card's live numbers, copied out of the object the renderer writes to."""
79
+ return page.evaluate(POPOVER)
80
+
81
+
82
+ def _units_in_page(page, number: int) -> list[dict]:
83
+ """The fixture's records as the PAGE sees them, so a level is never hard-coded here."""
84
+ return page.evaluate("(n) => window.__fixtureUnits(n)", number)
85
+
86
+
87
+ def _centre(locator) -> tuple[float, float]:
88
+ box = locator.bounding_box()
89
+ assert box and box["width"] > 0 and box["height"] > 0, f"nothing to tap: {box}"
90
+ return box["x"] + box["width"] / 2, box["y"] + box["height"] / 2
91
+
92
+
93
+ def _click_token(page, index: int) -> dict:
94
+ """Raw pointer input on the token's centre, not ``page.click``: the popover is opened
95
+ on ``pointerup``, and the dismiss listener is a capture-phase ``pointerdown``, so the
96
+ ORDER of the two real pointer events is the thing under test (research Pitfall 5)."""
97
+ x, y = _centre(page.locator(f"#transcript-text .tok[data-token='{index}']").first)
98
+ page.mouse.click(x, y)
99
+ return _popover(page)
100
+
101
+
102
+ def _container_rect(page) -> dict:
103
+ return page.evaluate(
104
+ "() => { const c = document.querySelector('#transcript-text');"
105
+ " const r = c.getBoundingClientRect();"
106
+ " return { left: r.left, top: r.top, width: r.width, height: r.height,"
107
+ " clientWidth: c.clientWidth, clientHeight: c.clientHeight,"
108
+ " scrollHeight: c.scrollHeight, scrollTop: c.scrollTop }; }"
109
+ )
110
+
111
+
112
+ def _card_rect(page) -> dict:
113
+ return page.evaluate(
114
+ "() => { const el = document.querySelector('#lookup-popover');"
115
+ " if (!el) return null; const r = el.getBoundingClientRect();"
116
+ " return { left: r.left, top: r.top, right: r.right, bottom: r.bottom,"
117
+ " width: r.width, height: r.height }; }"
118
+ )
119
+
120
+
121
+ def _scroll_container(page, top) -> None:
122
+ page.evaluate(
123
+ "(t) => { const c = document.querySelector('#transcript-text');"
124
+ " c.scrollTop = t === null ? c.scrollHeight : t; }",
125
+ top,
126
+ )
127
+ page.wait_for_timeout(60) # the scroll event is dispatched on the next frame
128
+
129
+
130
+ def test_popover_anchors_and_dismisses(page, static_server):
131
+ """JPN-03 / D-05 / D-06 / D-07 / D-11 / D-12: a tap on a word opens a card built from
132
+ that word's OWN record - reading, dictionary form, level badge and JMdict senses - and
133
+ the card dismisses, re-anchors and answers the keyboard.
134
+
135
+ Every claim is a number on ``debug.popover`` cross-checked against the DOM, and the
136
+ level comes from the fixture record the page itself rendered (``__fixtureUnits``), never
137
+ from a literal here: a wrong record and a wrong badge must not be able to agree.
138
+ """
139
+ _open_harness(page, static_server)
140
+ line_id = _render(page, TANAKA, "avatar")
141
+ units = _units_in_page(page, TANAKA)
142
+
143
+ seed = _popover(page)
144
+ assert seed["open"] is False and seed["openCount"] == 0 and seed["insideTranscript"] is None
145
+
146
+ # 田中さん: a name (D-12), and a word JMdict has no entry for - the card says both.
147
+ name = _click_token(page, 0)
148
+ card = page.locator("#lookup-popover")
149
+ print(f"[standalone] tap 田中さん -> {name}; card {_card_rect(page)}")
150
+ assert name["open"] is True
151
+ assert name["lineId"] == line_id and name["tokenIndex"] == 0
152
+ assert name["surface"] == units[0]["surface"] == "田中さん"
153
+ assert name["reading"] == units[0]["reading"] == "たなかさん"
154
+ assert name["level"] == units[0]["jlpt"] == "name"
155
+ assert name["glossCount"] == 0
156
+ assert name["openCount"] == 1 and name["closeCount"] == 0
157
+ assert name["insideTranscript"] is True, name
158
+ assert card.is_visible()
159
+ assert card.get_attribute("role") == "dialog"
160
+ assert card.locator(".lk-surface").text_content() == "田中さん"
161
+ assert card.locator(".lk-level").text_content() == "name"
162
+ assert card.locator(".lk-level").get_attribute("data-level") == "name"
163
+ assert card.locator(".lk-lemma").text_content() == "dictionary form: 田中"
164
+ assert card.locator(".lk-gloss li").count() == 0
165
+ assert card.locator(".lk-none").is_visible(), "an unlisted word must say so, not show blank"
166
+
167
+ # D-06: a tap on empty column space closes it.
168
+ cont = _container_rect(page)
169
+ line_box = page.locator(f"#transcript-text [data-line='{line_id}']").bounding_box()
170
+ page.mouse.click(cont["left"] + cont["width"] - 6, line_box["y"] + line_box["height"] / 2)
171
+ closed = _popover(page)
172
+ print(
173
+ f"[standalone] tap the empty column -> open={closed['open']} closes={closed['closeCount']}"
174
+ )
175
+ assert closed["open"] is False and closed["closeCount"] == 1
176
+ assert card.is_visible() is False
177
+
178
+ # 住んでいます: the conjugated form is one tap target (D-05) and the card names 住む.
179
+ verb = _click_token(page, 4)
180
+ print(f"[standalone] tap 住んでいます -> {verb}")
181
+ assert verb["open"] is True and verb["tokenIndex"] == 4
182
+ assert verb["surface"] == units[4]["surface"]
183
+ assert verb["level"] == units[4]["jlpt"], "the badge must be the record's level, not a guess"
184
+ assert verb["glossCount"] >= 1
185
+ assert verb["openCount"] == 2 and verb["insideTranscript"] is True
186
+ assert "住む" in card.locator(".lk-lemma").text_content()
187
+ assert card.locator(".lk-gloss li").count() == verb["glossCount"]
188
+ assert card.locator(".lk-none").is_visible() is False
189
+ assert card.locator(".lk-level").text_content() == units[4]["jlpt"]
190
+
191
+ page.keyboard.press("Escape")
192
+ escaped = _popover(page)
193
+ assert escaped["open"] is False and escaped["closeCount"] == 2
194
+ assert card.is_visible() is False
195
+
196
+ # D-11: particles and punctuation are not words. は / に / 。 are plain spans.
197
+ assert page.locator("#transcript-text .tok").count() == 3
198
+ assert page.locator("#transcript-text .plain").count() >= 3
199
+ tok_text = page.locator("#transcript-text .tok").all_text_contents()
200
+ assert "は" not in tok_text and "。" not in tok_text, tok_text
201
+
202
+ # The accessibility floor (research § Q6): Tab reaches a word, Enter opens its card.
203
+ page.focus("#level-select")
204
+ page.keyboard.press("Tab")
205
+ focused = page.evaluate(
206
+ "() => { const a = document.activeElement;"
207
+ " return { cls: a.className, token: a.dataset ? a.dataset.token : null }; }"
208
+ )
209
+ assert "tok" in focused["cls"], focused
210
+ page.keyboard.press("Enter")
211
+ keyed = _popover(page)
212
+ print(f"[standalone] Tab to {focused} + Enter -> {keyed}")
213
+ assert keyed["open"] is True and keyed["tokenIndex"] == int(focused["token"])
214
+ assert keyed["openCount"] == 3
215
+
216
+
217
+ def test_popover_stays_inside_narrow_column(browser, static_server):
218
+ """D-06 on a phone: on a 300 px scrolling column, with REAL touch input, the card stays
219
+ inside the transcript wherever the word is - including the last word of the last line,
220
+ where an unflipped card would hang off the bottom.
221
+
222
+ ``page.touchscreen.tap`` on a ``has_touch`` context, not ``page.click``: Playwright's
223
+ click helper grants activation and dispatches a mouse sequence, which is exactly the
224
+ thing research Pitfall 5 warns masks phone behaviour.
225
+ """
226
+ context = browser.new_context(viewport=PHONE_VIEWPORT, has_touch=True)
227
+ page = context.new_page()
228
+ try:
229
+ _open_harness(page, static_server, "?narrow=1")
230
+ for _ in range(6):
231
+ _render(page, LONG, "avatar")
232
+ _scroll_container(page, None)
233
+ cont = _container_rect(page)
234
+ print(f"[standalone] narrow column: {cont}")
235
+ assert cont["scrollHeight"] > cont["clientHeight"], (
236
+ f"the column does not scroll, so the scroll-dismiss claim would be vacuous: {cont}"
237
+ )
238
+ assert cont["clientWidth"] <= NARROW_COLUMN_PX + 16
239
+
240
+ last_tok = page.locator("#transcript-text .turn").last.locator(".tok").last
241
+ page.touchscreen.tap(*_centre(last_tok))
242
+ bottom = _popover(page)
243
+ rect = _card_rect(page)
244
+ print(f"[standalone] tap the LAST word of the LAST line -> {bottom}; card {rect}")
245
+ assert bottom["open"] is True
246
+ assert bottom["insideTranscript"] is True, (bottom, rect, _container_rect(page))
247
+ assert bottom["left"] >= 0
248
+ assert bottom["width"] <= NARROW_COLUMN_PX, bottom
249
+
250
+ # Scrolling moves the anchor, so the card closes (research § Q6).
251
+ _scroll_container(page, 0)
252
+ after_scroll = _popover(page)
253
+ assert after_scroll["open"] is False and after_scroll["closeCount"] == 1
254
+
255
+ first_tok = page.locator("#transcript-text .turn").first.locator(".tok").first
256
+ page.touchscreen.tap(*_centre(first_tok))
257
+ top = _popover(page)
258
+ print(
259
+ f"[standalone] tap the FIRST word of the FIRST line -> {top}; card {_card_rect(page)}"
260
+ )
261
+ assert top["open"] is True and top["openCount"] == 2
262
+ assert top["insideTranscript"] is True, (top, _card_rect(page), _container_rect(page))
263
+ assert top["width"] <= NARROW_COLUMN_PX
264
+
265
+ _scroll_container(page, 40)
266
+ scrolled = _popover(page)
267
+ assert scrolled["open"] is False and scrolled["closeCount"] == 2
268
+ assert page.locator("#lookup-popover").is_visible() is False
269
+ finally:
270
+ context.close()
271
+
272
+
273
  def test_ruby_renders_with_numbers(page, static_server):
274
  """D-01: the first line renders with every kanji annotated and nothing touched.
275
 
tests/test_transport_seam.py CHANGED
@@ -628,3 +628,65 @@ def test_host_seeds_language_debug():
628
  assert "__debug.furigana" in host
629
  for key in ("lastLineRt", "lastLineRtHeightPx", "lastLineHeightPx", "storage", "rtTotal"):
630
  assert f"{key}:" in host, f"host.js does not seed furigana.{key}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
628
  assert "__debug.furigana" in host
629
  for key in ("lastLineRt", "lastLineRtHeightPx", "lastLineHeightPx", "storage", "rtTotal"):
630
  assert f"{key}:" in host, f"host.js does not seed furigana.{key}"
631
+
632
+
633
+ # --------------------------------------------------------- plan 02-08: the lookup popover
634
+ #
635
+ # The card is DOM and nothing else. Its whole claim - "instant lookup" - rests on the
636
+ # glosses already riding inside the token record the line holds (research § Q3), so a
637
+ # network call anywhere in this module would mean the claim is false; and D-08 says the tap
638
+ # is read-only in Phase 2, so a browser-storage write would be a feature nobody asked for.
639
+
640
+
641
+ def test_popover_is_dom_only_and_read_only():
642
+ s = src(TRANSCRIPT)
643
+ assert "lookup-popover" in s, "transcript.js builds no #lookup-popover element"
644
+ assert "openPopover" in s and "closePopover" in s
645
+ assert "token.gloss" in s, "the card must read the glosses off the token, not fetch them"
646
+
647
+ for forbidden in ("fetch(", "XMLHttpRequest", "localStorage", "sessionStorage"):
648
+ assert forbidden not in s, (
649
+ f"transcript.js references {forbidden!r}: the lookup is a pure client action on "
650
+ "data the line already holds (research § Q3), and D-08 makes it read-only"
651
+ )
652
+ # D-04: no press-and-hold reveal, so no long-press plumbing and no suppressed menu.
653
+ assert "contextmenu" not in s
654
+ assert "longpress" not in s.replace("-", "").replace("_", "").lower()
655
+
656
+ # The gesture wiring: pointerup opens (mouse, touch and pen alike) and a CAPTURE-phase
657
+ # pointerdown on the document dismisses, which is what lets a tap on another word
658
+ # re-anchor without passing through a closed state.
659
+ assert "'pointerup'" in s, "the open gesture must be pointerup, not click"
660
+ capture = re.search(r"addEventListener\(\s*'pointerdown',[\s\S]{0,600}?,\s*true\s*\)", s)
661
+ assert capture, "the dismiss listener is not a capture-phase pointerdown on the document"
662
+ assert "'Escape'" in s, "Escape must close the card"
663
+ assert "'scroll'" in s, "scrolling the column moves the anchor; the card must close"
664
+
665
+ # Pitfall 10: every re-render replaces the .tok the card is anchored to.
666
+ for signature in ("function setMode(", "function setLevel(", "function setTokens("):
667
+ body = "\n".join(_function_body(s, signature))
668
+ assert "closePopover()" in body, f"{signature} re-renders without closing the card"
669
+
670
+
671
+ def test_popover_publishes_its_position():
672
+ """insideTranscript is the phone assertion (research § Q6): a card that renders outside
673
+ the column is invisible on a 412 px screen and passes every count-based check."""
674
+ s = src(TRANSCRIPT)
675
+ for key in ("insideTranscript", "openCount", "closeCount", "glossCount", "tokenIndex"):
676
+ assert f"{key}:" in s, f"transcript.js does not seed popover.{key}"
677
+ assert s.count("insideTranscript") >= 2, "insideTranscript is seeded but never measured"
678
+ assert "getBoundingClientRect" in s
679
+ # D-10 / D-12: the card never invents a level for a word that is on no list.
680
+ assert "N1+ / beyond lists" in s
681
+ assert "name:" in s
682
+
683
+
684
+ def test_host_binds_pointer_once():
685
+ """One call, at bind, and the numbers published beside furigana's - so a deployed row
686
+ reads the card's state through the same getDebug() it already uses."""
687
+ host = src(HOST)
688
+ assert host.count("transcript.bindPointer()") == 1, (
689
+ "host.js must install the tap wiring exactly once, and only through the renderer"
690
+ )
691
+ assert host.count("__debug.popover") == 1
692
+ assert "lookup-popover" not in host, "the card is the renderer's DOM, not the host's"