WolfDavid's picture
test(02-08): popover at three layers, incl. the Pixel 7 column
fd38c7c
Raw History Blame
18.7 kB
// 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 <ruby><rt> 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 <div>, 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,
};
}