WolfDavid commited on
Commit
af23443
·
1 Parent(s): 7d7a567

feat(02-07): furigana controls, level picker, persistence; every transcript line rendered from tokens

Browse files

- blocks.py: READING_CONTROLS_HTML (#furigana-mode + #level-select) above the
transcript, transcript.css linked from TRANSCRIPT_HTML, honest Phase 2 intro
- avatar_component.py: the boot template imports transcript.js and hands it to
bindHost(avatar, document, transcript), so both transports render identical ruby
- host.js: learnerLine/avatarLine through the renderer, every turn rendered from
the turn event's tokens, jla.furigana / jla.level persisted through the two
guarded storageRead/storageWrite helpers, furigana.* seeded on __debug
- test_transport_seam.py: the three-argument bind, one transcript.js import,
every-line rendering, the storage guard and the seeded debug keys

avatar/host.js CHANGED
@@ -14,26 +14,78 @@
14
  // #latency-text, #asr-tier-text) rather than to the host's wrapper elements, so a
15
  // re-render of a wrapper cannot delete a line, and user-supplied text is always written
16
  // through textContent, never innerHTML.
 
 
 
 
 
 
 
 
 
 
17
 
18
  /** Matches REARM_TAIL_MS in mic.js: the controls re-enable when the mic may re-arm. */
19
  const REENABLE_AFTER_SPEECH_MS = 200;
20
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
  /** Enter finishes a Japanese IME composition before it submits; this skips that Enter. */
22
  function isComposing(event) {
23
  return event.isComposing || event.keyCode === 229;
24
  }
25
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
  /**
27
  * @param {object} avatar the facade window.Avatar
28
  * @param {Document} [doc]
 
29
  * @returns {boolean} whether the bindings were installed by this call
30
  */
31
- export function bindHost(avatar, doc = document) {
32
  if (!avatar || typeof avatar.on !== 'function') return false;
33
  // boot() is re-entrant and returns the live object; the bindings must not double up.
34
  if (doc.__avatarHostBound) return false;
35
  doc.__avatarHostBound = true;
36
 
 
37
  const byId = (id) => doc.getElementById(id);
38
  const text = (id, value) => {
39
  const el = byId(id);
@@ -49,6 +101,22 @@ export function bindHost(avatar, doc = document) {
49
  replay: byId('replay-button'),
50
  slower: byId('slower-button'),
51
  };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
 
53
  let spoken = false; // whether anything has been said yet, for replay/slower
54
  let busy = false;
@@ -71,8 +139,9 @@ export function bindHost(avatar, doc = document) {
71
  applyEnabled();
72
  }
73
 
 
74
  function transcriptLine(who, value) {
75
- const el = byId('transcript-text');
76
  if (!el) return;
77
  const line = doc.createElement('div');
78
  line.className = `turn turn-${who}`;
@@ -87,6 +156,36 @@ export function bindHost(avatar, doc = document) {
87
  el.scrollTop = el.scrollHeight;
88
  }
89
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90
  function renderLatency({ lastTurnMs, timings }) {
91
  const t = timings || {};
92
  const ms = (key) => (typeof t[key] === 'number' ? Math.round(t[key]) : '—');
@@ -121,7 +220,7 @@ export function bindHost(avatar, doc = document) {
121
  return;
122
  }
123
  // Echo before the round trip, so the visitor sees their words the instant they send.
124
- transcriptLine('you', value);
125
  if (el) {
126
  el.value = '';
127
  // The host's textbox mirrors its value from input events; a bare .value write
@@ -131,19 +230,60 @@ export function bindHost(avatar, doc = document) {
131
  dispatch(value);
132
  }
133
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
134
  // ------------------------------------------------------------------- avatar -> page
135
  avatar.on('listening', ({ active } = {}) => status(active ? 'listening…' : 'transcribing…'));
136
  avatar.on('transcript', ({ text: heard } = {}) => {
137
  if (!heard) return;
138
- transcriptLine('you', heard);
139
  dispatch(heard);
140
  });
141
  avatar.on('turn-start', () => {
142
  setBusy(true);
143
  status('thinking…');
144
  });
145
- avatar.on('turn', ({ subtitle, speed, greeting } = {}) => {
146
- if (subtitle && (greeting || speed < 1)) transcriptLine(speed < 1 ? 'slower' : 'avatar', subtitle);
 
 
 
147
  spoken = true;
148
  });
149
  avatar.on('speech-start', () => {
@@ -168,7 +308,8 @@ export function bindHost(avatar, doc = document) {
168
  // gesture's own call stack - the only place a gesture-gated browser (iOS Safari;
169
  // Chromium in a cross-origin embed) lets an AudioContext resume. The turn loop makes
170
  // the same call at its entry points; this copy covers the gestures that never reach
171
- // the loop (Send with an empty box, Enter mid-composition) and the ones that do.
 
172
  if (controls.send) {
173
  controls.send.addEventListener('click', () => {
174
  avatar.unlockAudio();
 
14
  // #latency-text, #asr-tier-text) rather than to the host's wrapper elements, so a
15
  // re-render of a wrapper cannot delete a line, and user-supplied text is always written
16
  // through textContent, never innerHTML.
17
+ //
18
+ // Plan 02-07: the transcript is rendered by the DOM-only transcript module the boot
19
+ // template imports and hands over as the third argument. EVERY line goes through it:
20
+ // the avatar's lines from the tokens the 'turn' event carries, the learner's lines echoed
21
+ // at once as plain text and upgraded to ruby when analyze() answers (D-01, D-03). The two
22
+ // reading controls (#furigana-mode, #level-select) drive its mode and level, and both
23
+ // preferences persist in the browser under jla.furigana / jla.level (D-14), read and
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;
30
 
31
+ /** The persisted reading preferences (D-14). Phase 4 adopts the same keys into the account. */
32
+ const STORAGE_MODE_KEY = 'jla.furigana';
33
+ const STORAGE_LEVEL_KEY = 'jla.level';
34
+
35
+ /** Seeded on __debug even without a transcript module, so the key set is timing-independent. */
36
+ const FURIGANA_DEBUG_SEED = {
37
+ mode: 'always',
38
+ level: 'N5',
39
+ storage: 'unknown',
40
+ lines: 0,
41
+ rtTotal: 0,
42
+ lastLineId: null,
43
+ lastLineRt: 0,
44
+ lastLineKanjiRuns: 0,
45
+ lastLineRtHeightPx: 0,
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;
52
  }
53
 
54
+ /**
55
+ * Read one persisted preference. Returns { ok, value }: ok is false when the storage
56
+ * itself is unreachable (the access throws), value is null when the key is unset.
57
+ */
58
+ function storageRead(win, key) {
59
+ try {
60
+ return { ok: true, value: win.localStorage.getItem(key) };
61
+ } catch {
62
+ return { ok: false, value: null };
63
+ }
64
+ }
65
+
66
+ /** Write one persisted preference. Returns whether the storage accepted it. */
67
+ function storageWrite(win, key, value) {
68
+ try {
69
+ win.localStorage.setItem(key, String(value));
70
+ return true;
71
+ } catch {
72
+ return false;
73
+ }
74
+ }
75
+
76
  /**
77
  * @param {object} avatar the facade window.Avatar
78
  * @param {Document} [doc]
79
+ * @param {object} [transcriptModule] the transcript renderer's exports; null renders plain lines
80
  * @returns {boolean} whether the bindings were installed by this call
81
  */
82
+ export function bindHost(avatar, doc = document, transcriptModule = null) {
83
  if (!avatar || typeof avatar.on !== 'function') return false;
84
  // boot() is re-entrant and returns the live object; the bindings must not double up.
85
  if (doc.__avatarHostBound) return false;
86
  doc.__avatarHostBound = true;
87
 
88
+ const win = doc.defaultView || window;
89
  const byId = (id) => doc.getElementById(id);
90
  const text = (id, value) => {
91
  const el = byId(id);
 
101
  replay: byId('replay-button'),
102
  slower: byId('slower-button'),
103
  };
104
+ const reading = {
105
+ mode: byId('furigana-mode'),
106
+ level: byId('level-select'),
107
+ };
108
+
109
+ // The transcript renderer, when the boot template handed it over and the page has the
110
+ // element. Without it (a harness that loads host.js alone) lines fall back to plain text.
111
+ const transcriptEl = byId('transcript-text');
112
+ const transcript =
113
+ transcriptModule && typeof transcriptModule.createTranscript === 'function' && transcriptEl
114
+ ? transcriptModule.createTranscript(transcriptEl, { doc })
115
+ : 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;
 
139
  applyEnabled();
140
  }
141
 
142
+ /** The plain-text fallback used only when no transcript module was handed over. */
143
  function transcriptLine(who, value) {
144
+ const el = transcriptEl;
145
  if (!el) return;
146
  const line = doc.createElement('div');
147
  line.className = `turn turn-${who}`;
 
156
  el.scrollTop = el.scrollHeight;
157
  }
158
 
159
+ /**
160
+ * The learner's line (D-03): echoed synchronously as plain text - the visitor sees their
161
+ * words the instant they send, before any round trip - then upgraded to ruby when the
162
+ * analyze() answer arrives. Fire and forget: it never delays the turn's dispatch, and a
163
+ * failed analysis leaves the plain line in place.
164
+ */
165
+ function learnerLine(value) {
166
+ if (!transcript) {
167
+ transcriptLine('you', value);
168
+ return;
169
+ }
170
+ const { lineId } = transcript.addLine({ who: 'you', text: value });
171
+ avatar
172
+ .analyze(value)
173
+ .then((result) => transcript.setTokens(lineId, result.tokens))
174
+ .catch(() => {
175
+ /* the plain echo stands; the turn loop has already reported the error */
176
+ });
177
+ }
178
+
179
+ /** The avatar's line, rendered from the tokens the directive carried (no second round trip). */
180
+ function avatarLine(who, value, tokens) {
181
+ if (!transcript) {
182
+ transcriptLine(who, value);
183
+ return;
184
+ }
185
+ const { lineId } = transcript.addLine({ who, text: value });
186
+ transcript.setTokens(lineId, Array.isArray(tokens) ? tokens : []);
187
+ }
188
+
189
  function renderLatency({ lastTurnMs, timings }) {
190
  const t = timings || {};
191
  const ms = (key) => (typeof t[key] === 'number' ? Math.round(t[key]) : '—');
 
220
  return;
221
  }
222
  // Echo before the round trip, so the visitor sees their words the instant they send.
223
+ learnerLine(value);
224
  if (el) {
225
  el.value = '';
226
  // The host's textbox mirrors its value from input events; a bare .value write
 
230
  dispatch(value);
231
  }
232
 
233
+ // ------------------------------------------------------------ reading preferences (D-14)
234
+ //
235
+ // Read at bind, validated against the renderer's own lists (junk is ignored), applied to
236
+ // the selects and the renderer, then written back so a first visit persists its
237
+ // defaults and the write path is exercised - which is what makes storage 'ok' mean
238
+ // "read AND write work". A change on either select re-renders every line client-side.
239
+ if (transcript) {
240
+ const modes = transcriptModule.FURIGANA_MODES || [];
241
+ const levels = transcriptModule.LEVELS || [];
242
+ const savedMode = storageRead(win, STORAGE_MODE_KEY);
243
+ const savedLevel = storageRead(win, STORAGE_LEVEL_KEY);
244
+ const mode = modes.includes(savedMode.value) ? savedMode.value : transcript.getMode();
245
+ const level = levels.includes(savedLevel.value) ? savedLevel.value : transcript.getLevel();
246
+ transcript.setMode(mode);
247
+ transcript.setLevel(level);
248
+ if (reading.mode) reading.mode.value = mode;
249
+ if (reading.level) reading.level.value = level;
250
+ const wrote =
251
+ storageWrite(win, STORAGE_MODE_KEY, mode) && storageWrite(win, STORAGE_LEVEL_KEY, level);
252
+ furigana.storage = savedMode.ok && savedLevel.ok && wrote ? 'ok' : 'unavailable';
253
+
254
+ if (reading.mode) {
255
+ reading.mode.addEventListener('change', () => {
256
+ const value = transcript.setMode(reading.mode.value);
257
+ reading.mode.value = value;
258
+ if (!storageWrite(win, STORAGE_MODE_KEY, value)) furigana.storage = 'unavailable';
259
+ });
260
+ }
261
+ if (reading.level) {
262
+ reading.level.addEventListener('change', () => {
263
+ const value = transcript.setLevel(reading.level.value);
264
+ reading.level.value = value;
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
272
  avatar.on('listening', ({ active } = {}) => status(active ? 'listening…' : 'transcribing…'));
273
  avatar.on('transcript', ({ text: heard } = {}) => {
274
  if (!heard) return;
275
+ learnerLine(heard);
276
  dispatch(heard);
277
  });
278
  avatar.on('turn-start', () => {
279
  setBusy(true);
280
  status('thinking…');
281
  });
282
+ // EVERY avatar line is rendered from the turn event's tokens (plan 02-07). Phase 1 skipped
283
+ // the echo of a typed line because it repeated the learner; with furigana the avatar's
284
+ // reading of the same words is the point.
285
+ avatar.on('turn', ({ subtitle, speed, tokens } = {}) => {
286
+ if (subtitle) avatarLine(speed < 1 ? 'slower' : 'avatar', subtitle, tokens);
287
  spoken = true;
288
  });
289
  avatar.on('speech-start', () => {
 
308
  // gesture's own call stack - the only place a gesture-gated browser (iOS Safari;
309
  // Chromium in a cross-origin embed) lets an AudioContext resume. The turn loop makes
310
  // the same call at its entry points; this copy covers the gestures that never reach
311
+ // the loop (Send with an empty box, Enter mid-composition) and the ones that do. The
312
+ // two reading selects above are deliberately NOT in this set: no audio path starts there.
313
  if (controls.send) {
314
  controls.send.addEventListener('click', () => {
315
  avatar.unlockAudio();
src/japanese_avatar/ui/avatar_component.py CHANGED
@@ -62,7 +62,10 @@ _BOOT_JS = """
62
  // The host glue: binds the page's controls to the facade and renders what it
63
  // reports. Loaded here, after boot, from the SAME template for both transports.
64
  const host = await import('/gradio_api/file=avatar/host.js');
65
- host.bindHost(avatar, document);
 
 
 
66
  watch('value', () => m.onDirective(props.value));
67
  }})().catch((err) => {{
68
  console.error('avatar boot failed:', err);
 
62
  // The host glue: binds the page's controls to the facade and renders what it
63
  // reports. Loaded here, after boot, from the SAME template for both transports.
64
  const host = await import('/gradio_api/file=avatar/host.js');
65
+ // transcript.js is DOM rendering handed to the host glue; loaded from this one
66
+ // template so both transports render identical ruby (plan 02-07).
67
+ const transcript = await import('/gradio_api/file=avatar/transcript.js');
68
+ host.bindHost(avatar, document, transcript);
69
  watch('value', () => m.onDirective(props.value));
70
  }})().catch((err) => {{
71
  console.error('avatar boot failed:', err);
src/japanese_avatar/ui/blocks.py CHANGED
@@ -343,15 +343,40 @@ def warm_language_on_load() -> None:
343
  # public portfolio Space; an avatar that echoes must say it echoes.
344
  INTRO_HTML = (
345
  '<div id="intro" class="intro">'
346
- "<strong>Phase 1: the avatar repeats what you say. Tutoring arrives in Phase 3.</strong> "
 
347
  "Type Japanese and press Enter, or hold the microphone button and speak. Everything you hear "
348
  "is synthesised on the CPU with mora-timed lip-sync; speech recognition runs in your browser."
349
  "</div>"
350
  )
351
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
352
  # Server-rendered so the first paint already carries it; the inner ids are what the host script
353
- # writes to, and the wrappers are Gradio's own elements that tests select by elem_id.
354
- TRANSCRIPT_HTML = '<div id="transcript-text" class="transcript" aria-live="polite"></div>'
 
 
 
 
 
355
  LATENCY_HTML = '<div id="latency-text" class="latency">dispatch→speech: —</div>'
356
  ASR_BADGE_HTML = (
357
  '<div id="asr-tier-text" class="asr-badge">ASR: loads in your browser on the first push</div>'
@@ -425,6 +450,7 @@ def build_blocks() -> gr.Blocks:
425
  with gr.Column(scale=2, min_width=280):
426
  StatusLine()
427
  gr.HTML(value=INTRO_HTML, elem_id="intro-html")
 
428
  gr.HTML(value=TRANSCRIPT_HTML, elem_id="transcript")
429
  with gr.Row():
430
  gr.Button("Hold to talk", elem_id="ptt-button", variant="secondary")
 
343
  # public portfolio Space; an avatar that echoes must say it echoes.
344
  INTRO_HTML = (
345
  '<div id="intro" class="intro">'
346
+ "<strong>Phase 2: the avatar repeats what you say, now with furigana, word lookup and "
347
+ "translation. Tutoring arrives in Phase 3.</strong> "
348
  "Type Japanese and press Enter, or hold the microphone button and speak. Everything you hear "
349
  "is synthesised on the CPU with mora-timed lip-sync; speech recognition runs in your browser."
350
  "</div>"
351
  )
352
 
353
+ # Plan 02-07. The reading controls (D-02 / D-14): a three-way furigana mode and an N5-N2 level
354
+ # picker. Project-owned <select> elements inside a gr.HTML value rather than gr.Radio /
355
+ # gr.Dropdown, for the Phase 1 reason: avatar/host.js binds by element id and never depends on
356
+ # Gradio's own DOM or event routing, and a mode change is a client-side re-render with no server
357
+ # call. Server-rendered so the first paint already carries them, like StatusLine. The defaults
358
+ # are D-01's first visit: every kanji annotated, level N5.
359
+ READING_CONTROLS_HTML = (
360
+ '<div id="reading-controls" class="reading-controls">'
361
+ '<label for="furigana-mode">Furigana</label>'
362
+ '<select id="furigana-mode" aria-label="Furigana mode">'
363
+ '<option value="always" selected>always</option><option value="above">above my level</option>'
364
+ '<option value="never">never</option></select>'
365
+ '<label for="level-select">My level</label>'
366
+ '<select id="level-select" aria-label="JLPT level">'
367
+ '<option value="N5" selected>N5</option><option value="N4">N4</option>'
368
+ '<option value="N3">N3</option><option value="N2">N2</option></select>'
369
+ "</div>"
370
+ )
371
+
372
  # Server-rendered so the first paint already carries it; the inner ids are what the host script
373
+ # writes to, and the wrappers are Gradio's own elements that tests select by elem_id. The
374
+ # stylesheet link rides inside the same value: avatar/ is a static path, so the browser fetches
375
+ # transcript.css from the app itself (the ruby rules the browser suites measure - plan 02-07).
376
+ TRANSCRIPT_HTML = (
377
+ '<link rel="stylesheet" href="/gradio_api/file=avatar/transcript.css">'
378
+ '<div id="transcript-text" class="transcript" aria-live="polite"></div>'
379
+ )
380
  LATENCY_HTML = '<div id="latency-text" class="latency">dispatch→speech: —</div>'
381
  ASR_BADGE_HTML = (
382
  '<div id="asr-tier-text" class="asr-badge">ASR: loads in your browser on the first push</div>'
 
450
  with gr.Column(scale=2, min_width=280):
451
  StatusLine()
452
  gr.HTML(value=INTRO_HTML, elem_id="intro-html")
453
+ gr.HTML(value=READING_CONTROLS_HTML, elem_id="reading-controls-html")
454
  gr.HTML(value=TRANSCRIPT_HTML, elem_id="transcript")
455
  with gr.Row():
456
  gr.Button("Hold to talk", elem_id="ptt-button", variant="secondary")
tests/test_transport_seam.py CHANGED
@@ -366,7 +366,12 @@ def test_host_glue_is_neither_a_transport_nor_the_turn_loop():
366
  assert component.count(f"import('/gradio_api/file=avatar/{HOST}')") == 1, (
367
  "the shared boot template must load host.js exactly once, for both transports"
368
  )
369
- assert "bindHost(avatar, document)" in component
 
 
 
 
 
370
 
371
 
372
  # ------------------------------------------------------------------ plan 01-11: audio unlock
@@ -580,3 +585,46 @@ def test_transcript_uses_ruby_not_parentheses():
580
  assert "createElement('ruby')" in s or 'createElement("ruby")' in s
581
  assert "createElement('rt')" in s or 'createElement("rt")' in s
582
  assert "'rp'" not in s and '"rp"' not in s, "no <rp>: it would leak into textContent"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
366
  assert component.count(f"import('/gradio_api/file=avatar/{HOST}')") == 1, (
367
  "the shared boot template must load host.js exactly once, for both transports"
368
  )
369
+ # Plan 02-07: the transcript renderer is the third argument, loaded from the same
370
+ # template exactly once so both transports render identical ruby.
371
+ assert component.count(f"import('/gradio_api/file=avatar/{TRANSCRIPT}')") == 1, (
372
+ "the shared boot template must load transcript.js exactly once, for both transports"
373
+ )
374
+ assert "bindHost(avatar, document, transcript)" in component
375
 
376
 
377
  # ------------------------------------------------------------------ plan 01-11: audio unlock
 
585
  assert "createElement('ruby')" in s or 'createElement("ruby")' in s
586
  assert "createElement('rt')" in s or 'createElement("rt")' in s
587
  assert "'rp'" not in s and '"rp"' not in s, "no <rp>: it would leak into textContent"
588
+
589
+
590
+ # host.js's side of the transcript (plan 02-07): every avatar line rendered from the turn
591
+ # event's tokens, the reading preferences persisted through exactly two guarded helpers, and
592
+ # the furigana numbers seeded on __debug so getDebug() carries them from the first read.
593
+
594
+
595
+ def test_host_renders_every_avatar_line():
596
+ """Phase 1 rendered only greeting / slower lines (the echo repeated the learner). With
597
+ furigana the avatar's reading of the same words is the point: every turn renders."""
598
+ host = src(HOST)
599
+ assert host.count("setTokens(") >= 2, "learner AND avatar lines must go through setTokens"
600
+ assert "greeting || speed < 1" not in host, "the Phase 1 skip-the-echo condition survives"
601
+ assert "createTranscript(" in host
602
+ assert ".analyze(" in host, "learner lines are tokenised through Avatar.analyze()"
603
+
604
+
605
+ def test_host_persists_reading_prefs():
606
+ """Both keys are written and read, and the storage is touched ONLY inside the two
607
+ helpers - each wrapped in try - because Safari in a cross-origin iframe throws on the
608
+ access itself (research Pitfall 9) and an unguarded read would take the whole host down."""
609
+ host = src(HOST)
610
+ assert host.count("jla.furigana") >= 1 and host.count("jla.level") >= 1
611
+ assert "STORAGE_MODE_KEY" in host and "STORAGE_LEVEL_KEY" in host
612
+ remainder = host
613
+ for signature in ("function storageRead(", "function storageWrite("):
614
+ body = "\n".join(_function_body(host, signature))
615
+ assert "try" in body, f"{signature} has no try around the storage access"
616
+ assert "localStorage" in body, f"{signature} does not touch localStorage"
617
+ remainder = remainder.replace(body, "")
618
+ assert "localStorage" not in remainder, (
619
+ "localStorage is accessed outside storageRead / storageWrite in host.js"
620
+ )
621
+ assert "'unavailable'" in host and "'ok'" in host, "furigana.storage must publish both states"
622
+
623
+
624
+ def test_host_seeds_language_debug():
625
+ """furigana.* is on __debug from bind, with or without a transcript module, so the
626
+ parity suite's key-set comparison never depends on timing."""
627
+ host = src(HOST)
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}"