// avatar/transcript.js // // DOM ONLY. Knows nothing about the facade, the transports or the host framework; builds // children with createElement/textContent so glosses and learner text are never parsed as // HTML. tests/test_transport_seam.py enforces it. // // The renderer behind #transcript-text (plan 02-07). host.js hands it the container and // feeds it lines and the analyzer's token records; it turns each token's ruby spans into // real elements, gates the readings by mode and level on the KANJI axis // (D-02 / D-13), and publishes the numbers that prove the ruby rendered - the rt count, // the rendered rt height and the line height - on debug.furigana, so a stylesheet that // hides the annotation cannot pass a count (research Pitfall 4, the T-pose lesson). // // Every line's tokens are retained, so a mode or level change re-renders every line from // data with no round trip (D-14), and a tap on a word (plan 02-08) reads its reading, its // dictionary form, its level and its JMdict glosses straight out of the record the line // already holds - the lookup issues no request of any kind (D-05 / D-07, research § Q3). // Plan 02-09 adds the translation reveal under each line. // // The card is a positioned
, not the Popover API: iOS 16 Safari is still in the field // without it and its top-layer behaviour inside the Hub's cross-origin frame is unverified // (research § Q6). Anchoring is 40 lines of getBoundingClientRect + clamp that publish // their numbers on debug.popover, so "it stayed inside the phone column" is an assertion // rather than a screenshot. Press-and-hold is deliberately not a gesture here (D-04), and // the card is read-only: no save, no tap history (D-08). // // The token record (plan 02-05, nlp/analyzer.TOKEN_KEYS): `surface`, `tappable`, `jlpt`, // `kanji_levels` ({kanji: "N5".."N1" | null}) and `ruby` ([[text, rt | null], ...] - one // span per kanji run with its reading, kana spans with null). Non-tappable units carry // [[surface, null]] and never get an annotation. /** Difficulty order for the gate; mirrors nlp/levels.LEVEL_RANK. */ export const LEVEL_RANK = { N5: 1, N4: 2, N3: 3, N2: 4, N1: 5 }; /** The three-way control (D-02). 'above' = "above my level". */ export const FURIGANA_MODES = ['always', 'above', 'never']; /** The level picker's range (D-14). N1 is not offered: at N1 "above my level" is 'never'. */ export const LEVELS = ['N5', 'N4', 'N3', 'N2']; const LABELS = { you: 'You: ', avatar: 'Avatar: ', slower: 'Avatar (slower): ' }; /** * The badge text for a token's WORD-level axis. N5..N1 read as themselves; the two values * the analyser uses for "not on a list" are spelled out on the card so the learner is never * told a level the data does not claim (D-10 / D-12). */ const LEVEL_LABELS = { 'N1+': 'N1+ / beyond lists', name: 'name' }; /** D-07: the card shows the first two or three senses, English glosses only. */ const MAX_SENSES = 3; /** The gap between the tapped word and the card, and the inset from the column's edges. */ const ANCHOR_GAP_PX = 6; const EDGE_INSET_PX = 8; /** The card never grows past this, nor past the room beside the word (see position). */ const CARD_MAX_HEIGHT_PX = 240; /** Below this a card is not worth showing at all, so it is allowed to overflow instead. */ const MIN_CARD_HEIGHT_PX = 48; function clamp(value, low, high) { return Math.min(Math.max(value, low), high); } /** * D-02 / D-13: a run gets its rt when the mode says so; 'above' keys on the KANJI axis - * any kanji in the run above the learner's level, or unlisted (null, D-10), keeps the whole * run annotated. A run whose every kanji is at or below the level renders bare. * * @param {string} runText the base text of one ruby span * @param {object} kanjiLevels the token's {kanji: level | null} * @param {string} mode one of FURIGANA_MODES * @param {string} level one of LEVELS */ export function showRt(runText, kanjiLevels, mode, level) { if (mode === 'always') return true; if (mode === 'never') return false; const mine = LEVEL_RANK[level] ?? 1; const levels = kanjiLevels || {}; for (const ch of runText) { if (!(ch in levels)) continue; // kana inside a run never happens; defensive const lv = levels[ch]; if (lv == null || (LEVEL_RANK[lv] ?? 99) > mine) return true; } return false; } /** * @param {Element} container the project-owned #transcript-text element * @param {object} [opts] * @param {Document} [opts.doc] */ export function createTranscript(container, { doc = document } = {}) { if (!container) throw new Error('createTranscript: a container element is required'); // Every key seeded here so the key set on getDebug() never depends on timing. const debug = { furigana: { mode: 'always', level: 'N5', storage: 'unknown', // host.js sets 'ok' | 'unavailable' after its browser-storage probe lines: 0, rtTotal: 0, lastLineId: null, lastLineRt: 0, lastLineKanjiRuns: 0, lastLineRtHeightPx: 0, lastLineHeightPx: 0, }, // The lookup card's numbers (plan 02-08, research § Q6). insideTranscript is the phone // assertion: the card's rect within the column's rect. null until the first open, so // "never opened" and "opened outside" are different readings. popover: { open: false, lineId: null, tokenIndex: null, surface: null, reading: null, level: null, glossCount: 0, left: 0, top: 0, width: 0, insideTranscript: null, openCount: 0, closeCount: 0, }, }; /** lineId -> { el, said, who, text } in insertion order. */ const lines = new Map(); /** lineId -> the retained token records, so a re-render is pure. */ const tokensByLine = new Map(); let count = 0; let mode = 'always'; let level = 'N5'; /** The number of [text, rt] spans WITH a reading across a line's tokens: the potential. */ function kanjiRuns(tokens) { let runs = 0; for (const token of tokens) { for (const span of token.ruby || []) if (span[1]) runs += 1; } return runs; } function renderTokens(said, tokens) { said.replaceChildren(); tokens.forEach((token, index) => { const el = doc.createElement('span'); if (token.tappable) { el.className = 'tok'; el.dataset.token = String(index); el.setAttribute('role', 'button'); el.tabIndex = 0; if (token.jlpt) el.dataset.level = token.jlpt; } else { el.className = 'plain'; } const spans = Array.isArray(token.ruby) && token.ruby.length > 0 ? token.ruby : [[String(token.surface ?? ''), null]]; for (const [text, rt] of spans) { if (rt && showRt(text, token.kanji_levels, mode, level)) { const ruby = doc.createElement('ruby'); ruby.append(doc.createTextNode(text)); const rtEl = doc.createElement('rt'); rtEl.textContent = rt; ruby.append(rtEl); el.append(ruby); } else { el.append(doc.createTextNode(text)); } } said.append(el); }); } /** * The published numbers, read from the rendered DOM after every (re)render. The line * height is the `.turn` block's: an inline `.said` box reports only its own font's * content area, which does not grow when an annotation sits above it, while the block * that contains the line boxes does - so the block is where "a ruby line is taller than * a bare one" is measurable. */ function measure(lineId) { const line = lines.get(lineId); const f = debug.furigana; f.lines = lines.size; f.rtTotal = container.querySelectorAll('rt').length; if (!line) return; f.lastLineId = lineId; f.lastLineRt = line.said.querySelectorAll('rt').length; f.lastLineKanjiRuns = kanjiRuns(tokensByLine.get(lineId) || []); const rt = line.said.querySelector('rt'); f.lastLineRtHeightPx = rt ? rt.getBoundingClientRect().height : 0; f.lastLineHeightPx = line.el.getBoundingClientRect().height; } function render(lineId) { const line = lines.get(lineId); if (!line) return; const tokens = tokensByLine.get(lineId); if (tokens && tokens.length > 0) renderTokens(line.said, tokens); else line.said.textContent = line.text; // no tokens (yet, or the analysis failed) measure(lineId); } function renderAll() { for (const lineId of lines.keys()) render(lineId); } /** * Append a line as plain text; tokens arrive through setTokens (immediately for the * avatar's lines, after the analyze round trip for the learner's). * * @param {{who: 'you'|'avatar'|'slower', text: string}} line * @returns {{lineId: string, el: Element}} */ function addLine({ who, text }) { count += 1; const lineId = `L${count}`; const el = doc.createElement('div'); el.className = `turn turn-${who}`; el.dataset.line = lineId; el.dataset.who = who; const label = doc.createElement('span'); label.className = 'who'; label.textContent = LABELS[who] ?? LABELS.avatar; const said = doc.createElement('span'); said.className = 'said'; said.textContent = String(text ?? ''); el.append(label, said); container.append(el); container.scrollTop = container.scrollHeight; lines.set(lineId, { el, said, who, text: String(text ?? '') }); measure(lineId); return { lineId, el }; } /** * Attach (or replace) a line's tokens and rebuild its `.said` children from them. * @returns {boolean} whether the line exists */ function setTokens(lineId, tokens) { if (!lines.has(lineId)) return false; closePopover(); // the line's spans are rebuilt below; the card's anchor would be stale tokensByLine.set(lineId, Array.isArray(tokens) ? tokens : []); render(lineId); return true; } // ------------------------------------------------------------- the lookup card (02-08) const popover = debug.popover; /** Built on the first open and reused; `card.el` is the one #lookup-popover element. */ let card = null; let pointerBound = false; function buildCard() { const el = doc.createElement('div'); el.id = 'lookup-popover'; el.className = 'lookup-popover'; el.setAttribute('role', 'dialog'); el.setAttribute('aria-label', 'Word lookup'); el.hidden = true; const head = doc.createElement('div'); head.className = 'lk-head'; const surface = doc.createElement('span'); surface.className = 'lk-surface'; const reading = doc.createElement('span'); reading.className = 'lk-reading'; const badge = doc.createElement('span'); badge.className = 'lk-level'; head.append(surface, reading, badge); const lemma = doc.createElement('div'); lemma.className = 'lk-lemma'; const gloss = doc.createElement('ol'); gloss.className = 'lk-gloss'; const none = doc.createElement('div'); none.className = 'lk-none'; none.textContent = 'no dictionary entry'; none.hidden = true; el.append(head, lemma, gloss, none); container.append(el); card = { el, surface, reading, badge, lemma, gloss, none }; return card; } /** * Anchor the card under (or above) the tapped word and publish where it landed. * * Offsets are in the container's PADDING-BOX coordinates - what `position: absolute` * inside `#transcript-text` resolves against - so `clientLeft` / `clientTop` (the border * widths) come off the viewport rects, and `scrollTop` converts the visible position into * the scrolled content's. The card is clamped horizontally into the column and placed on * whichever side of the word has more room, never growing past that room - it scrolls * internally instead. Those two rules together are what make the published numbers true * by construction rather than by luck: the card cannot leave the column * (insideTranscript) and cannot cover the word that was tapped, which is what would * otherwise make "tap the next word to re-anchor" impossible on a short phone column. */ function position(el, tokEl) { el.style.left = '0px'; el.style.top = '0px'; el.style.maxHeight = `${CARD_MAX_HEIGHT_PX}px`; const contRect = container.getBoundingClientRect(); const tokRect = tokEl.getBoundingClientRect(); const viewTop = container.scrollTop; const viewBottom = viewTop + container.clientHeight; const tokTop = tokRect.top - contRect.top - container.clientTop + viewTop; const tokBottom = tokTop + tokRect.height; const roomBelow = viewBottom - (tokBottom + ANCHOR_GAP_PX) - 2; const roomAbove = tokTop - ANCHOR_GAP_PX - viewTop - 2; const below = roomBelow >= roomAbove; const room = Math.max(MIN_CARD_HEIGHT_PX, below ? roomBelow : roomAbove); el.style.maxHeight = `${Math.round(Math.min(CARD_MAX_HEIGHT_PX, room))}px`; const width = el.offsetWidth; const height = el.offsetHeight; const maxLeft = Math.max(EDGE_INSET_PX, container.clientWidth - width - EDGE_INSET_PX); const left = clamp( tokRect.left - contRect.left - container.clientLeft, EDGE_INSET_PX, maxLeft ); const top = clamp( below ? tokBottom + ANCHOR_GAP_PX : tokTop - ANCHOR_GAP_PX - height, viewTop, Math.max(viewTop, viewBottom - height) ); el.style.left = `${Math.round(left)}px`; el.style.top = `${Math.round(top)}px`; const rect = el.getBoundingClientRect(); popover.left = Math.round(left); popover.top = Math.round(top); popover.width = rect.width; popover.insideTranscript = rect.left >= contRect.left - 0.5 && rect.right <= contRect.right + 0.5 && rect.top >= contRect.top - 0.5 && rect.bottom <= contRect.bottom + 0.5; } /** * Show the card for one token of one line, filled from THAT token's own record. * * Every field is written with textContent: the glosses are dictionary data and the * surfaces can be anything the learner typed. Returns a copy of the published numbers. * * @param {string} lineId * @param {number} tokenIndex the index the `.tok` carries in data-token */ function openPopover(lineId, tokenIndex) { const line = lines.get(lineId); const token = (tokensByLine.get(lineId) || [])[tokenIndex]; if (!line || !token) return { ...popover }; const tokEl = line.said.querySelector(`.tok[data-token="${tokenIndex}"]`); if (!tokEl) return { ...popover }; const c = card || buildCard(); const surface = String(token.surface ?? ''); const badgeValue = token.jlpt == null ? '' : String(token.jlpt); c.surface.textContent = surface; c.reading.textContent = String(token.reading ?? ''); c.badge.textContent = LEVEL_LABELS[badgeValue] ?? badgeValue; c.badge.hidden = badgeValue === ''; if (badgeValue) c.badge.dataset.level = badgeValue; else delete c.badge.dataset.level; const lemma = String(token.lemma ?? ''); const showLemma = lemma !== '' && lemma !== surface; c.lemma.textContent = showLemma ? `dictionary form: ${lemma}` : ''; c.lemma.hidden = !showLemma; const senses = Array.isArray(token.gloss) ? token.gloss.slice(0, MAX_SENSES) : []; c.gloss.replaceChildren(); for (const sense of senses) { const item = doc.createElement('li'); item.textContent = Array.isArray(sense) ? sense.join('; ') : String(sense); c.gloss.append(item); } c.gloss.hidden = senses.length === 0; c.none.hidden = senses.length > 0; c.el.hidden = false; position(c.el, tokEl); popover.open = true; popover.lineId = lineId; popover.tokenIndex = tokenIndex; popover.surface = surface; popover.reading = c.reading.textContent; popover.level = badgeValue || null; popover.glossCount = senses.length; popover.openCount += 1; return { ...popover }; } /** Hide the card. A no-op (and not counted) when it is already closed. */ function closePopover() { if (!popover.open) return { ...popover }; if (card) card.el.hidden = true; popover.open = false; popover.closeCount += 1; return { ...popover }; } function openFromElement(tok) { const line = tok.closest('[data-line]'); if (!line) return null; return openPopover(line.dataset.line, Number(tok.dataset.token)); } /** * Install the tap wiring. Idempotent: the host calls it once at bind, the standalone * harness calls it too, so there is exactly one implementation of the gesture. * * `pointerup` on the column covers mouse, touch and pen alike, with no 300 ms delay * (the page has a viewport meta and `.tok` is touch-action: manipulation). The dismiss * listener is a CAPTURE-phase pointerdown on the document, so a tap on another word * falls through to the pointerup above and re-anchors WITHOUT passing through a closed * state. Scrolling the column moves the anchor, so it closes (research § Q6). No * press-and-hold gesture exists (D-04). */ function bindPointer() { if (pointerBound) return false; pointerBound = true; container.addEventListener('pointerup', (event) => { const tok = event.target?.closest?.('.tok'); if (!tok) return; openFromElement(tok); }); doc.addEventListener( 'pointerdown', (event) => { if (event.target?.closest?.('#lookup-popover, .tok')) return; closePopover(); }, true ); doc.addEventListener('keydown', (event) => { if (event.key === 'Escape') { closePopover(); return; } if (event.key !== 'Enter' && event.key !== ' ') return; const tok = event.target?.closest?.('.tok'); if (!tok) return; event.preventDefault(); openFromElement(tok); }); container.addEventListener('scroll', () => closePopover()); return true; } function setMode(value) { if (!FURIGANA_MODES.includes(value)) return mode; closePopover(); // Pitfall 10: a re-render replaces the .tok the card is anchored to mode = value; debug.furigana.mode = mode; renderAll(); return mode; } function setLevel(value) { if (!LEVELS.includes(value)) return level; closePopover(); level = value; debug.furigana.level = level; renderAll(); return level; } return { addLine, setTokens, setMode, setLevel, getMode: () => mode, getLevel: () => level, getTokens: (lineId) => tokensByLine.get(lineId) || null, openPopover, closePopover, bindPointer, debug, }; }