Skip to content

Commit 54d425e

Browse files
hellpanderrrclaude
andcommitted
feat(macronizer): real editable output + mobile tap-to-readings sheet
Phase 1-2 of the editing overhaul: words render as real text (selectable, copyable, findable) via a setDisplay setter that keeps the content attribute in sync; the ambig/unknown highlights move off the ::before painter. Clicking a vowel toggles its macron, the output is contenteditable, Ctrl+Z/Y undo-redo. Council-validated mobile model (3 cold agents: touch, a11y, classicist): desktop keeps hover + click-vowel toggle; on touch, tapping a flagged word opens the readings sheet (delegated touchend + preventDefault so the OS keyboard never slides up), tapping a clean word lands a caret, and tapping a reading row chooses it. Flagged words get a keyboard path (Enter opens the dialog); the popup becomes role=dialog with a role=status reading announcement; the sheet gains a scrim + grab handle. Double-click pinning removed. Copy updated in caption, legend, and help page. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
1 parent 7659ae8 commit 54d425e

6 files changed

Lines changed: 510 additions & 77 deletions

File tree

wiktionary_pron/css/style.css

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -449,6 +449,9 @@ body i.icon-home {
449449
}
450450

451451
.ipa::before {
452+
/* SHARED painter: the transcriber's line mode renders empty
453+
<div class="ipa" content="…"> cells and depends on this to paint their text.
454+
The macronizer neutralizes it locally (real textContent there). */
452455
content: attr(content);
453456
}
454457

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import { test, expect } from "@playwright/test";
2+
3+
const PAGE = "/wiktionary_pron/macronizer.html";
4+
5+
async function macronize(page, text) {
6+
await page.fill("#text_to_macronize", text);
7+
await page.click("#macronize_btn");
8+
await expect(page.locator("#resultText .ipa").first()).toBeVisible({ timeout: 120_000 });
9+
}
10+
11+
// Client-rect centre of one character of the first word span. Uses a Range over the
12+
// span's text node so the click lands inside the glyph, not on the span's padding.
13+
async function charCenter(page, charIndex) {
14+
return await page.evaluate((charIndex) => {
15+
const span = document.querySelector("#resultText .ipa");
16+
const tn = span.firstChild;
17+
const range = document.createRange();
18+
range.setStart(tn, charIndex);
19+
range.setEnd(tn, Math.min(charIndex + 1, tn.length));
20+
const r = range.getBoundingClientRect();
21+
return { x: (r.left + r.right) / 2, y: (r.top + r.bottom) / 2 };
22+
}, charIndex);
23+
}
24+
25+
test.describe("Phase 2 editing", () => {
26+
test("click a vowel toggles its macron; Ctrl+Z / Ctrl+Y / Ctrl+Shift+Z round-trip", async ({ page }) => {
27+
test.setTimeout(360_000);
28+
await page.goto(PAGE);
29+
await expect(page.locator("#macronize_btn")).toBeEnabled({ timeout: 240_000 });
30+
31+
await macronize(page, "divisa");
32+
const span = page.locator("#resultText .ipa").first();
33+
await expect(span).toHaveAttribute("content", "dīvīsa");
34+
35+
// Toggle the ī at index 1 → "divīsa" (that single vowel flips).
36+
const pt = await charCenter(page, 1);
37+
await page.mouse.click(pt.x, pt.y);
38+
await expect(span).toHaveAttribute("content", "divīsa");
39+
40+
// Ctrl+Z undoes the toggle.
41+
await page.keyboard.press("Control+z");
42+
await expect(span).toHaveAttribute("content", "dīvīsa");
43+
44+
// Ctrl+Y redoes.
45+
await page.keyboard.press("Control+y");
46+
await expect(span).toHaveAttribute("content", "divīsa");
47+
48+
// Ctrl+Shift+Z is the other redo chord — the word stays toggled.
49+
await page.keyboard.press("Control+Shift+z");
50+
await expect(span).toHaveAttribute("content", "divīsa");
51+
52+
// Undo again — the round-trip is fully reversible.
53+
await page.keyboard.press("Control+z");
54+
await expect(span).toHaveAttribute("content", "dīvīsa");
55+
});
56+
57+
test("clicking a consonant does not toggle; typing edits and Ctrl+Z reverts", async ({ page }) => {
58+
test.setTimeout(360_000);
59+
await page.goto(PAGE);
60+
await expect(page.locator("#macronize_btn")).toBeEnabled({ timeout: 240_000 });
61+
62+
await macronize(page, "divisa");
63+
const span = page.locator("#resultText .ipa").first();
64+
await expect(span).toHaveAttribute("content", "dīvīsa");
65+
66+
// Click the consonant v (index 2) — precise hit-testing must NOT toggle a neighbour.
67+
const pt = await charCenter(page, 2);
68+
await page.mouse.click(pt.x, pt.y);
69+
await expect(span).toHaveAttribute("content", "dīvīsa");
70+
71+
// The caret sits inside the word; typing edits it and the content attr follows.
72+
await page.keyboard.type("x");
73+
const edited = await span.textContent();
74+
expect(edited).toContain("x");
75+
await expect(span).toHaveAttribute("content", edited);
76+
77+
await page.keyboard.press("Control+z");
78+
await expect(span).toHaveAttribute("content", "dīvīsa");
79+
});
80+
81+
test("keyboard: Enter on a flagged word opens the readings popup", async ({ page }) => {
82+
test.setTimeout(360_000);
83+
await page.goto(PAGE);
84+
await expect(page.locator("#macronize_btn")).toBeEnabled({ timeout: 240_000 });
85+
86+
await macronize(page, "divisa");
87+
const span = page.locator("#resultText .ipa").first();
88+
await expect(span).toHaveAttribute("content", "dīvīsa");
89+
90+
// Flagged span is focusable; Enter opens the dialog. The word must NOT change
91+
// (no accidental toggle from the activation).
92+
await span.focus();
93+
await page.keyboard.press("Enter");
94+
await expect(page.locator(".word-popup .popup-cycle")).toBeVisible({ timeout: 5000 });
95+
await expect(span).toHaveAttribute("content", "dīvīsa");
96+
});
97+
98+
test("touch: tap a flagged word opens the sheet, tap a clean word does not", async ({ browser }) => {
99+
// hasTouch context so isTouch() is true and the sheet path runs.
100+
const context = await browser.newContext({ hasTouch: true, viewport: { width: 390, height: 844 } });
101+
const page = await context.newPage();
102+
test.setTimeout(360_000);
103+
await page.goto(PAGE);
104+
await expect(page.locator("#macronize_btn")).toBeEnabled({ timeout: 240_000 });
105+
106+
await macronize(page, "provinciarum divisa");
107+
const clean = page.locator("#resultText .ipa").nth(0); // prōvinciārum — unambiguous
108+
const flagged = page.locator("#resultText .ipa").nth(1); // divisa — 2 readings
109+
await expect(clean).toHaveAttribute("content", "prōvinciārum");
110+
await expect(clean).not.toHaveClass(/ambig|unknown/); // premise: provinciarum is clean
111+
await expect(flagged).toHaveAttribute("content", "dīvīsa");
112+
await expect(flagged).toHaveClass(/ambig/); // premise: divisa is flagged
113+
114+
// Tap the CLEAN word → no sheet, no toggle (caret lands for editing).
115+
await clean.tap();
116+
await expect(page.locator(".word-popup")).not.toBeVisible({ timeout: 5000 });
117+
await expect(clean).toHaveAttribute("content", "prōvinciārum");
118+
119+
// Tap the FLAGGED word → the sheet (readings-first) appears.
120+
await flagged.tap();
121+
await expect(page.locator(".word-popup.sheet")).toBeVisible({ timeout: 5000 });
122+
const readings = page.locator(".word-popup .popup-section", { hasText: /^Possible readings/ });
123+
await expect(readings).toBeVisible({ timeout: 5000 });
124+
125+
// Tap a reading row → the word changes to that spelling and the sheet closes.
126+
const rows = page.locator(".word-popup table.readings tr");
127+
const rowCount = await rows.count();
128+
expect(rowCount).toBeGreaterThan(1);
129+
await rows.nth(1).tap();
130+
await expect(flagged).not.toHaveAttribute("content", "dīvīsa", { timeout: 5000 });
131+
await expect(page.locator(".word-popup.sheet")).not.toBeVisible({ timeout: 5000 });
132+
133+
await context.close();
134+
});
135+
});

wiktionary_pron/e2e/macronizer.spec.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@ test.describe("macronizer", () => {
3333
"prōvinciārum",
3434
{ timeout: 120_000 },
3535
);
36+
// Phase 1 (editing overhaul): the output is REAL text now — selectable and
37+
// copyable. The content attribute stays in sync, and textContent must match.
38+
const first = page.locator("#resultText .ipa").first();
39+
await expect(first).toHaveText("prōvinciārum", { timeout: 5_000 });
3640
});
3741

3842
test("dark mode toggle changes its own icon, not the home button's", async ({

wiktionary_pron/e2e/popup-check.spec.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ test("popup shows RFTagger disagreement + Morpheus dedup for currito", async ({
1111
await page.click("#macronize_btn");
1212
await expect(page.locator("#resultText .ipa").first()).toBeVisible({ timeout: 120_000 });
1313

14-
await page.locator("#resultText .ipa").first().click();
14+
// Desktop: hovering a flagged word opens the popup (single-click toggles a vowel).
15+
await page.locator("#resultText .ipa").first().hover();
1516

1617
// 0. Readings come FIRST (the debug detail is collapsed behind <details>)
1718
const readings = page.locator(".word-popup .popup-section", { hasText: /^Possible readings/ });
@@ -53,7 +54,8 @@ test("v/u words cycle reversibly — divisa's original spelling must come back",
5354
await expect(span).toHaveAttribute("content", "dīvīsa");
5455
const initial = await span.getAttribute("content");
5556

56-
await span.click(); // hover already opened the popup; this pins it (may cycle once)
57+
// Desktop: hovering the flagged word opens the popup (a single click would toggle).
58+
await span.hover();
5759
const nextBtn = page.locator(".word-popup .popup-cycle");
5860
await expect(nextBtn).toBeVisible({ timeout: 5000 });
5961

wiktionary_pron/help/macronizer.html

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -63,10 +63,10 @@ <h2 id="reading">Reading the output</h2>
6363

6464
<ul>
6565
<li><strong>Yellow</strong> — the word has <strong>more than one possible reading</strong>, and
66-
the tool picked the one it thought fit the sentence. <strong>Click the word</strong> (or focus
67-
it with Tab and press Enter) to cycle through the alternatives; the text updates, and so do the
68-
Copy and export buttons. If the tool guessed <i>Galliā</i> where you wanted <i>Gallia</i>,
69-
this is how you fix it.
66+
the tool picked the one it thought fit the sentence. <strong>Tap the word</strong> on a phone,
67+
or on a computer focus it with Tab and press Enter, to choose a reading from the list; the
68+
text updates, and so do the Copy and export buttons. If the tool guessed <i>Galliā</i> where
69+
you wanted <i>Gallia</i>, this is how you fix it.
7070
</li>
7171
<li><strong>Red</strong> — the word is <strong>not in the wordlist</strong>. Morpheus is asked
7272
to analyse it from its stem and ending; if even Morpheus doesn't recognise it, the vowel
@@ -78,9 +78,10 @@ <h2 id="reading">Reading the output</h2>
7878
</li>
7979
</ul>
8080

81-
<p>Hover a word (or tap it on a phone) to open its analysis: the RFTagger tag decoded into plain
82-
features, the lemma, whether it was found in the wordlist, and every reading Morpheus found.
83-
The reading currently shown in the text is highlighted among the candidates.</p>
81+
<p>Hover a word on a computer (or tap a highlighted word on a phone) to open its analysis: the
82+
RFTagger tag decoded into plain features, the lemma, whether it was found in the wordlist, and
83+
every reading Morpheus found. The reading currently shown in the text is highlighted among the
84+
candidates; tap a reading to choose it.</p>
8485

8586
<h2>The options</h2>
8687

0 commit comments

Comments
 (0)