diff --git a/.changes/tour-announcement.json b/.changes/tour-announcement.json new file mode 100644 index 00000000..25a7e035 --- /dev/null +++ b/.changes/tour-announcement.json @@ -0,0 +1 @@ +{ "type": "user-facing", "releaseNoteId": "release:0.18.2" } diff --git a/CHANGELOG.md b/CHANGELOG.md index b5ee3921..a9682a6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ All notable Loop Studio releases, newest first. Behavioral changes are pinned in versioned spec documents (see the [README](README.md#technical-reference)); this file is the narrative history, not the contract. +## v0.18.2 — 2026-10-06 + +A fix release (issue #308): the guided tour says each step once and never loses focus. + +- **Each step is announced once.** Step 1 is read as the tour opens, by its title and text; every later step is read once, as its number, title and text, when Next or Back reaches it. The visible step counter no longer repeats it. The welcome card's question is now read with its title. +- **Focus stays where you are.** The tour opens with focus on Next and keeps it on Next or Back while the steps change; going Back to the first step moves focus to Next instead of losing it. +- **Ending the tour lands somewhere.** Done, Escape and the close button, on a first run or a replay, return focus to the Help button (to the overflow button when Help is folded into it, to More on a phone), never to the page itself. + +**No migration.** One new spoken sentence and three release-note lines in 18 languages, 16 of them without native review. The informational `meta.tool` string is now `loop-studio/0.18.2`. + ## v0.18.1 — 2026-10-06 A fix release (issue #307): the menus answer the keyboard the same way, and the phone's sheets take focus and step back one level at a time. diff --git a/README.md b/README.md index 94ca1e99..2b4336bc 100644 --- a/README.md +++ b/README.md @@ -140,7 +140,16 @@ Additional feature-specific design documents (localization, mobile, module system, large-graph readability, simulation playback, edge routing, data import, …) live under [`docs/`](docs/). -## Latest — v0.18.1 +## Latest — v0.18.2 + +A fix release: the guided tour says each step once. + +- **Each step is announced once**: step 1 as the tour opens, every later step as Next or + Back reaches it, with its number, title and text +- **Focus stays on Next or Back** while the steps change, and ending the tour returns it + to the Help button (More on a phone) + +## v0.18.1 A fix release: the menus answer the keyboard the same way. @@ -168,16 +177,8 @@ A fix release: the Temporary session button looks like the menu buttons beside i - **The same height, corners, text size and colours** as the toolbar's menu buttons, and their hover and keyboard focus; its orange border still marks a temporary session -## v0.17.1 - -A fix release: share links use the browser's own compression. - -- **Share links are compressed with the browser's built-in Compression Streams**; the - bundled compression code is removed. Existing links still open and the link format is - unchanged -- **A browser without them** makes no link and says so, and the open diagram is kept - -See [`CHANGELOG.md`](CHANGELOG.md) for the full notes of these releases, v0.17.0 +See [`CHANGELOG.md`](CHANGELOG.md) for the full notes of these releases, v0.17.1 (share +links compressed with the browser's own Compression Streams), v0.17.0 (password-protected share links), v0.16.0 (the storage gate, temporary sessions and the Storage and privacy area), the v0.15 releases and every earlier one. diff --git a/docs/guided-tour.md b/docs/guided-tour.md index b5f182a7..ad61455a 100644 --- a/docs/guided-tour.md +++ b/docs/guided-tour.md @@ -162,11 +162,46 @@ menu mid-tour, so this is not a user-reachable flow in this slice — the E2E - `Back` / `Next` buttons; `Next` on step 6 is `Done` and ends the tour (→ `completed`, written **on the `Done` press**, not on merely reaching step 6). -- keyboard focus is **trapped** inside the popover while the tour is open; on - end, focus returns to the control that opened it (the Welcome card's - `Start tour`, or the Help menu's `Take a tour`). +- keyboard focus is **trapped** inside the popover while the tour is open. - the popover is a labelled dialog (`role="dialog"`, `aria-modal`, - `aria-labelledby` the step title); the `N / 6` position is announced. + `aria-labelledby` the `N / 6` position and the step title, + `aria-describedby` the step body). + +**Announcement and focus (issue #308, Lumi 2026-10-06).** Each step is said +**once**, and focus is never lost: + +- **Step 1** is said by the dialog's name (its `1 / 6` and its title, so the + number is not lost) and description (its body) when focus enters it, all + three once. Focus enters on **`Next`**. +- **Every later step** is said by one hidden live region inside the popover + (`aria-live="polite"`, `aria-atomic="true"`), written **once per `Next` or + `Back` press**, as `N / 6. title. body`. The sentence is its own catalog key + (`tour.nav.announce`) in every language, so the punctuation and order are + the language's own, not English joined in code. The region starts empty, so + step 1 is not said twice; a re-render that is not a step change (a language + switch) writes nothing. +- The visible `N / 6` is **not** live, and neither is the title, so neither + repeats the announcement. A step read twice (title change and live region + together) is a failure, not a pass. +- Focus stays on `Back` / `Next` while the steps change in place. On `Back` to + step 1, focus moves to `Next` **before** `Back` is disabled (`disabled`, not + `aria-disabled`); otherwise it falls to `` inside the dialog + (MEASURED on v0.18.1). +- **Every exit** (`Done`, `Escape`, the close control), first run or replay, + lands on the **Help** button, which is where a replay starts: the + overflow button when the toolbar has folded Help into it, the **More** + button on a phone. A target that is gone, hidden, inert or disabled is + skipped for the next. Only when none of them is usable, the last resort is + the top bar's first usable menu button outside the palette (opening a menu + is harmless; Enter on a palette piece would insert it). Never ``. +- **The Welcome card** is named by its title and described by its question + (`aria-describedby`); its buttons, their order and the first focus (`Skip`) + are unchanged. +- The phone tour has the same contract; its layout is unchanged. +- Merge condition: a real check with Windows Narrator in desktop Edge and + desktop Chrome. A real mobile screen reader is **not** a merge condition and + is recorded as unverified; the phone is checked by automated focus and + accessibility-tree tests (`e2e/tour-announcement.spec.ts`). - every string (titles, bodies, `Back` / `Next` / `Done` / `Skip`, the `N / 6` template) comes from the catalog (§GT8) — nothing hard-coded. @@ -463,6 +498,11 @@ and KO `satisfies MessageCatalog`, e.g.: `tour.welcome.skip` - `tour.nav.back` · `tour.nav.next` · `tour.nav.done` · `tour.nav.position` = `"{n} / {total}"` +- `tour.nav.announce` = `"{n} / {total}. {title}. {body}"` in English — the + step's spoken sentence (§GT4, issue #308), worded per language: `。` in + Japanese and Chinese, a space in Thai, the full stop elsewhere. `{title}` + and `{body}` are the step's own catalog strings. It is never shown, so it + needs no direction pinning. - `tour.desktop..title` / `.body` for steps `pieces` · `canvas` · `inspector` · `playback` · `timeline` · `files` - `tour.mobile..title` / `.body` for the six mobile steps @@ -536,7 +576,13 @@ The implementation slice must ship E2E covering: **no** `tour` / `guided-tour` key; exported bytes and digest are identical with the key `completed` vs absent. 14. **a11y** — `Escape` ends; focus is trapped while open and returns to the - trigger on end; `Back` / `Next` reachable by keyboard. + trigger on end; `Back` / `Next` reachable by keyboard. Issue #308 + (`e2e/tour-announcement.spec.ts`): step 1 by the dialog's name and + description with focus on `Next` and an empty live region; steps 2–6 each + written to the live region exactly once; the only live region in the tour + is that one; `Back` to step 1 leaves focus on `Next`; `Done`, `Escape` and + the close control, first run and replay, at 1280 px, 760 px and 390 px, + land on Help, the overflow button or More, never ``. 15. **Missing target** — force a step's `data-tour` node out of the DOM; the tour shows the centred fallback card, does not advance or crash, and `Next` / `Back` still work. @@ -625,6 +671,14 @@ The implementation slice must ship E2E covering: background language menu is unreachable during the tour; the E2E asserts only the reactivity (external `setLocale` → same step re-renders), not a non-existent user path. *(Lumi, rev 2.)* +- **One announcement per step** (§GT4, issue #308). Found with Narrator on + 2026-10-02: from step 2 on only `2 / 6` was read, because the position was the + only live text and the title and body changed in place. Decided: a hidden + polite, atomic live region written per `Next` / `Back` press, the dialog's + name (number and title) and description for step 1, focus entering on `Next`, a translated + sentence per language, and every exit landing on Help / More. Moving focus + to the heading at each step was rejected: it reads the step but takes focus + off `Next`. *(Lumi, 2026-10-06.)* ## GT11. Slices diff --git a/e2e/tour-announcement.spec.ts b/e2e/tour-announcement.spec.ts new file mode 100644 index 00000000..69a5630a --- /dev/null +++ b/e2e/tour-announcement.spec.ts @@ -0,0 +1,380 @@ +import type { Locator, Page } from '@playwright/test' +import { expect, openApp, test } from './support/loop' + +// Issue #308 - what the guided tour says, and where focus is, step by step +// (docs/guided-tour.md §GT4, the announcement contract). Each step is said +// ONCE: step 1 by the dialog's name (its title) and description (its body) +// when focus enters it, every later step by one polite, atomic live region, +// written once per Next or Back press. The visible `N / 6` and the title are +// not live, so neither repeats it. Focus starts on Next, stays on Back or +// Next, moves to Next before Back is disabled on step 1, and every exit +// (Done, Escape, the close control), first run or replay, lands on the Help +// button (the overflow button when the toolbar has folded Help into it, More +// on a phone), never on . + +const KEY = 'loop-studio/guided-tour/1' + +type Bridge = { __loop: { tour: { getState: () => any }; i18n: { getState: () => any } } } + +/** the first-run state (no key) or a decided one, before the FIRST boot only */ +const seedKey = (page: Page, v: 'completed' | null) => + page.addInitScript( + ([k, val]) => { + try { + if (sessionStorage.getItem('__tour_seeded')) return + sessionStorage.setItem('__tour_seeded', '1') + if (val == null) localStorage.removeItem(k) + else localStorage.setItem(k, val) + } catch { + /* ignore */ + } + }, + [KEY, v] as const, + ) + +/** Records, from before the app boots: every text the tour's live regions + * come to hold (one entry per change, a repeat of the same text is not a new + * entry), and every time focus leaves an element for nowhere (no + * `relatedTarget`) and is still on once the task is over. A move to + * another element (Tab) has a `relatedTarget`; checked in a microtask instead, + * a native Tab reads between its focusout and focusin. */ +const record = (page: Page) => + page.addInitScript(() => { + const w = window as unknown as { __said: string[]; __lost: string[] } + w.__said = [] + w.__lost = [] + const last = new WeakMap() + const start = () => { + new MutationObserver((recs) => { + const touched = new Set() + for (const r of recs) { + const el = (r.target.nodeType === 1 ? (r.target as Element) : r.target.parentElement)?.closest('.tour [aria-live]') + if (el) touched.add(el) + } + for (const el of touched) { + const text = (el.textContent ?? '').trim() + if (text && last.get(el) !== text) w.__said.push(text) + last.set(el, text) + } + }).observe(document.documentElement, { subtree: true, childList: true, characterData: true }) + document.addEventListener( + 'focusout', + (e) => { + if ((e as FocusEvent).relatedTarget) return + const from = (e.target as HTMLElement).className?.toString() ?? '' + setTimeout(() => { + if (document.activeElement === document.body || document.activeElement == null) w.__lost.push(from) + }, 0) + }, + true, + ) + } + if (document.documentElement) start() + else document.addEventListener('DOMContentLoaded', start) + }) + +/** what the live regions said since the last call */ +const said = (page: Page) => + page.evaluate(() => (window as unknown as { __said: string[] }).__said.splice(0)) +const lost = (page: Page) => page.evaluate(() => (window as unknown as { __lost: string[] }).__lost.slice()) + +const popover = (page: Page) => page.locator('.tour-popover') +const welcome = (page: Page) => page.locator('.tour-card') +const nextBtn = (page: Page) => popover(page).locator('.tour-popover__foot .btn--primary') +const backBtn = (page: Page) => popover(page).locator('.tour-popover__foot .btn:not(.btn--primary)') +const closeBtn = (page: Page) => popover(page).locator('.tour-popover__x') +const announcer = (page: Page) => popover(page).locator('.tour-popover__announce') + +/** the step on screen, as its announcement must read in English */ +const shownStep = async (page: Page) => { + const pos = (await popover(page).locator('.tour-popover__pos').textContent())!.trim() + const title = (await popover(page).locator('.tour-popover__title').textContent())!.trim() + const body = (await popover(page).locator('.tour-popover__body').textContent())!.trim() + return { pos, title, body, announcement: `${pos}. ${title}. ${body}` } +} + +/** the dialog says step `n` by its name and description; nothing is live but + * the one announcer */ +async function expectStepDialog(page: Page, n: number) { + const s = await shownStep(page) + expect(s.pos).toBe(`${n} / 6`) + await expect(popover(page)).toHaveAttribute('role', 'dialog') + // the number and the title together, so step 1 is said with its number + await expect(popover(page)).toHaveAccessibleName(`${s.pos} ${s.title}`) + await expect(popover(page)).toHaveAccessibleDescription(s.body) + const live = page.locator('.tour [aria-live]') + await expect(live).toHaveCount(1) + await expect(live).toHaveClass(/tour-popover__announce/) + await expect(live).toHaveAttribute('aria-live', 'polite') + await expect(live).toHaveAttribute('aria-atomic', 'true') + await expect(popover(page).locator('.tour-popover__pos')).not.toHaveAttribute('aria-live', /.*/) + await expect(popover(page).locator('.tour-popover__title').locator('xpath=ancestor-or-self::*[@aria-live]')).toHaveCount(0) + return s +} + +/** from the Welcome card, keyboard only: Start tour */ +async function startFirstRun(page: Page) { + await expect(welcome(page)).toBeVisible() + await welcome(page).getByRole('button', { name: 'Start tour' }).focus() + await page.keyboard.press('Enter') + await expect(popover(page)).toBeVisible() +} + +/** desktop replay from Help, keyboard only */ +async function startReplayDesktop(page: Page) { + const help = page.locator('[data-tour="help-trigger"]') + await help.focus() + await page.keyboard.press('Enter') + await expect(page.getByRole('menuitem', { name: 'Restart the tour' })).toBeFocused() + await page.keyboard.press('Enter') + await expect(popover(page)).toBeVisible() +} + +/** Next, with focus on it, until the last step */ +async function walkToLast(page: Page) { + for (let n = 2; n <= 6; n++) { + await expect(nextBtn(page)).toBeFocused() + await page.keyboard.press('Enter') + await expect(popover(page).locator('.tour-popover__pos')).toHaveText(`${n} / 6`) + } + await expect(nextBtn(page)).toHaveText('Done') +} + +type Exit = 'Done' | 'Escape' | 'close control' +async function endBy(page: Page, how: Exit) { + if (how === 'Done') { + await walkToLast(page) + await page.keyboard.press('Enter') + } else if (how === 'Escape') { + await page.keyboard.press('Escape') + } else { + await closeBtn(page).focus() + await page.keyboard.press('Enter') + } + await expect(popover(page)).toHaveCount(0) +} + +/** what Chromium's own accessibility tree says about an element */ +async function chromiumAx(page: Page, selector: string) { + const cdp = await page.context().newCDPSession(page) + await cdp.send('Accessibility.enable') + const { root } = await cdp.send('DOM.getDocument', { depth: 0 }) + const { nodeId } = await cdp.send('DOM.querySelector', { nodeId: root.nodeId, selector }) + const { nodes } = await cdp.send('Accessibility.getPartialAXTree', { nodeId, fetchRelatives: false }) + await cdp.detach() + return { role: nodes[0]?.role?.value, name: nodes[0]?.name?.value, description: nodes[0]?.description?.value } +} + +async function expectFocusOn(page: Page, target: Locator) { + await expect(target).toBeFocused() + expect(await lost(page)).toEqual([]) +} + +test.describe('the tour says each step once, and keeps focus (desktop)', () => { + test('first run: the Welcome card and step 1 by name and description, steps 2-6 once each by the live region, focus on Next', async ({ page }) => { + await seedKey(page, null) + await record(page) + await openApp(page) + await expect(welcome(page)).toBeVisible() + await expect(welcome(page)).toHaveAccessibleName('Welcome to Loop Studio') + // the question is the card's description, through aria-describedby + const describedBy = await welcome(page).getAttribute('aria-describedby') + expect(describedBy).toBeTruthy() + await expect(welcome(page).locator(`[id="${describedBy}"]`)).toHaveText('A quick two-minute tour of the six parts of the workspace?') + await expect(welcome(page)).toHaveAccessibleDescription('A quick two-minute tour of the six parts of the workspace?') + // the Welcome card's own order is unchanged: Skip first + await expect(welcome(page).getByRole('button', { name: 'Skip' })).toBeFocused() + + expect(await chromiumAx(page, '.tour-card')).toEqual({ + role: 'dialog', + name: 'Welcome to Loop Studio', + description: 'A quick two-minute tour of the six parts of the workspace?', + }) + + await startFirstRun(page) + const first = await expectStepDialog(page, 1) + await expect(nextBtn(page)).toBeFocused() + // Chromium's tree: number, title and body, all three, on the dialog itself + expect(await chromiumAx(page, '.tour-popover')).toEqual({ + role: 'dialog', + name: `1 / 6 ${first.title}`, + description: first.body, + }) + // step 1 is the dialog's name and description: the live region says nothing + await expect(announcer(page)).toHaveText('') + expect(await said(page)).toEqual([]) + + for (let n = 2; n <= 6; n++) { + await page.keyboard.press('Enter') + await expect(popover(page).locator('.tour-popover__pos')).toHaveText(`${n} / 6`) + const s = await expectStepDialog(page, n) + await expect(announcer(page)).toHaveText(s.announcement) + expect(await said(page), `step ${n} is said exactly once`).toEqual([s.announcement]) + await expect(nextBtn(page)).toBeFocused() + } + await expect(nextBtn(page)).toHaveText('Done') + expect(await lost(page)).toEqual([]) + }) + + test('Back to step 1 moves focus to Next before Back is disabled; each step back is said once', async ({ page }) => { + await seedKey(page, 'completed') + await record(page) + await openApp(page) + await startReplayDesktop(page) + await expect(nextBtn(page)).toBeFocused() + await page.keyboard.press('Enter') + await page.keyboard.press('Enter') + await expect(popover(page).locator('.tour-popover__pos')).toHaveText('3 / 6') + await said(page) + + await page.keyboard.press('Shift+Tab') + await expect(backBtn(page)).toBeFocused() + await page.keyboard.press('Enter') + let s = await expectStepDialog(page, 2) + expect(await said(page)).toEqual([s.announcement]) + await expect(backBtn(page)).toBeFocused() + + await page.keyboard.press('Enter') + s = await expectStepDialog(page, 1) + expect(await said(page)).toEqual([s.announcement]) + await expect(backBtn(page)).toBeDisabled() + await expect(nextBtn(page)).toBeFocused() + expect(await lost(page), 'focus never fell to ').toEqual([]) + }) + + test('a language switch mid-tour announces nothing; the next step is said once, in the new language', async ({ page }) => { + await seedKey(page, 'completed') + await record(page) + await openApp(page) + await startReplayDesktop(page) + await page.keyboard.press('Enter') + await expect(popover(page).locator('.tour-popover__pos')).toHaveText('2 / 6') + expect(await said(page)).toHaveLength(1) + await page.evaluate(() => (window as unknown as Bridge).__loop.i18n.getState().setLocale('ko')) + await expect(nextBtn(page)).toHaveText('다음') + expect(await said(page)).toEqual([]) + await nextBtn(page).focus() + await page.keyboard.press('Enter') + await expect(popover(page).locator('.tour-popover__pos')).toHaveText('3 / 6') + const title = (await popover(page).locator('.tour-popover__title').textContent())!.trim() + const body = (await popover(page).locator('.tour-popover__body').textContent())!.trim() + expect(await said(page)).toEqual([`3 / 6. ${title}. ${body}`]) + await page.evaluate(() => (window as unknown as Bridge).__loop.i18n.getState().setLocale('en')) + }) + + for (const run of ['first run', 'replay'] as const) { + for (const how of ['Done', 'Escape', 'close control'] as const) { + test(`${run}, ended by ${how}: focus lands on the Help button, never on `, async ({ page }) => { + await seedKey(page, run === 'first run' ? null : 'completed') + await record(page) + await openApp(page) + if (run === 'first run') await startFirstRun(page) + else await startReplayDesktop(page) + await endBy(page, how) + await expectFocusOn(page, page.locator('[data-tour="help-trigger"]')) + }) + } + } + + // Help first; the overflow button only when Help is folded into it; More on + // a phone. Only when none is usable: a menu button of the top bar, never a + // palette piece and never . At 1280 px the overflow button is in the + // page but hidden, so with Help hidden this reaches the last resort. + test('with the Help button gone from view, an exit lands on a menu button of the top bar, not on a palette piece or ', async ({ page }) => { + await seedKey(page, 'completed') + await record(page) + await openApp(page) + await startReplayDesktop(page) + await page.evaluate(() => { + const h = document.querySelector('[data-tour="help-trigger"]')! + h.style.display = 'none' + }) + await page.keyboard.press('Escape') + await expect(popover(page)).toHaveCount(0) + // a menu button of the top bar: opening a menu is harmless, while Enter on + // a palette piece would insert it + const where = await page.evaluate(() => { + const a = document.activeElement as HTMLElement | null + return { + body: a === document.body, + inTopBar: !!a?.closest('header.toolbar'), + tag: a?.tagName, + menuButton: !!a && (a.hasAttribute('aria-haspopup') || a.hasAttribute('aria-expanded')), + inPalette: !!a?.closest('[data-tour="palette"]'), + } + }) + expect(where).toEqual({ body: false, inTopBar: true, tag: 'BUTTON', menuButton: true, inPalette: false }) + expect(await lost(page)).toEqual([]) + }) +}) + +test.describe('the tour at 760 px: Help is folded into the overflow button', () => { + test.use({ viewport: { width: 760, height: 800 } }) + for (const run of ['first run', 'replay'] as const) { + test(`${run}, ended by Escape: focus lands on the overflow button`, async ({ page }) => { + await seedKey(page, run === 'first run' ? null : 'completed') + await record(page) + await openApp(page) + if (run === 'first run') { + await startFirstRun(page) + } else { + const more = page.locator('.toolbar__overflow-btn') + await more.focus() + await page.keyboard.press('Enter') + const help = page.locator('.toolbar__overflow-pop [data-tour="help-trigger"]') + await help.focus() + await page.keyboard.press('Enter') + await expect(page.getByRole('menuitem', { name: 'Restart the tour' })).toBeFocused() + await page.keyboard.press('Enter') + await expect(popover(page)).toBeVisible() + } + await expect(nextBtn(page)).toBeFocused() + await endBy(page, 'Escape') + await expectFocusOn(page, page.locator('.toolbar__overflow-btn')) + }) + } +}) + +test.describe('the tour on a phone: the same contract', () => { + test.use({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true }) + + async function startReplayPhone(page: Page) { + await page.locator('[data-tour="mobile-more"]').click() + await page.locator('.sheet').getByRole('button', { name: /^Help/ }).click() + await page.locator('.sheet').getByRole('button', { name: 'Restart the tour' }).click() + await expect(popover(page)).toBeVisible() + } + + test('first run: step 1 by name and description with focus on Next, every later step once', async ({ page }) => { + await seedKey(page, null) + await record(page) + await openApp(page) + await expect(welcome(page)).toHaveAccessibleDescription('A quick two-minute tour of the six parts of the workspace?') + await startFirstRun(page) + await expectStepDialog(page, 1) + await expect(nextBtn(page)).toBeFocused() + expect(await said(page)).toEqual([]) + for (let n = 2; n <= 6; n++) { + await page.keyboard.press('Enter') + await expect(popover(page).locator('.tour-popover__pos')).toHaveText(`${n} / 6`) + const s = await expectStepDialog(page, n) + expect(await said(page), `step ${n} is said exactly once`).toEqual([s.announcement]) + await expect(nextBtn(page)).toBeFocused() + } + }) + + for (const run of ['first run', 'replay'] as const) { + for (const how of ['Done', 'Escape', 'close control'] as const) { + test(`${run}, ended by ${how}: focus lands on More, never on `, async ({ page }) => { + await seedKey(page, run === 'first run' ? null : 'completed') + await record(page) + await openApp(page) + if (run === 'first run') await startFirstRun(page) + else await startReplayPhone(page) + await expect(nextBtn(page)).toBeFocused() + await endBy(page, how) + await expectFocusOn(page, page.locator('[data-tour="mobile-more"]')) + }) + } + } +}) diff --git a/e2e/whats-new.spec.ts b/e2e/whats-new.spec.ts index 2dc454c0..55e25d70 100644 --- a/e2e/whats-new.spec.ts +++ b/e2e/whats-new.spec.ts @@ -239,7 +239,7 @@ test.describe('closing the notice and opening the panel are different things', ( expect(text).not.toContain('text unavailable') } } - expect(RELEASE_NOTES.map((n) => n.version)).toEqual(['0.18.1', '0.18.0', '0.17.2', '0.17.1', '0.17.0', '0.16.0', '0.15.3', '0.15.2', '0.15.1', '0.15.0', '0.14.0']) + expect(RELEASE_NOTES.map((n) => n.version)).toEqual(['0.18.2', '0.18.1', '0.18.0', '0.17.2', '0.17.1', '0.17.0', '0.16.0', '0.15.3', '0.15.2', '0.15.1', '0.15.0', '0.14.0']) await page.keyboard.press('Escape') await expect(panel).toHaveCount(0) diff --git a/package-lock.json b/package-lock.json index 64878c0a..aec136b7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "loop-studio", - "version": "0.18.1", + "version": "0.18.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "loop-studio", - "version": "0.18.1", + "version": "0.18.2", "dependencies": { "@fontsource/ibm-plex-mono": "^5.3.0", "@fontsource/ibm-plex-sans": "^5.3.0", diff --git a/package.json b/package.json index 909a6454..57ace8fd 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "loop-studio", "private": true, - "version": "0.18.1", + "version": "0.18.2", "type": "module", "engines": { "node": ">=22.12.0" diff --git a/scripts/icu-argument-disposition.json b/scripts/icu-argument-disposition.json index fd46eb05..29400b9a 100644 --- a/scripts/icu-argument-disposition.json +++ b/scripts/icu-argument-disposition.json @@ -11,7 +11,7 @@ }, "$implemented": "A row classed `auto` / `ltr` / `per-fragment` is an OPEN obligation: the evidence says the value needs bounding and the code does not bound it yet. C3.5 was scoped to fix the two families that were already evidenced — the unrecognized-column header and the five `import.loc.*` calls — and those moved to `isolate-obligations.json`. The checker asserts these rows are NOT wrapped, so implementing one without moving its row is red in both directions.", "totals": { - "arguments": 212, + "arguments": 216, "rows": 53, "byClass": { "attribute": 10, diff --git a/src/components/GuidedTour.tsx b/src/components/GuidedTour.tsx index 1c417b6d..72195e92 100644 --- a/src/components/GuidedTour.tsx +++ b/src/components/GuidedTour.tsx @@ -17,6 +17,39 @@ import { Icon } from '../ui/icons' const PLATFORM = () => (window.innerWidth < 768 ? 'mobile' : 'desktop') as 'mobile' | 'desktop' +// issue #308 - where focus goes when the tour ends, by Done, Escape or the +// close control, whether it was a first run or a replay: never . The Help +// button, where a replay starts; when the toolbar has folded Help into the +// overflow menu, that menu's button; on a phone the More button, where the +// phone's replay starts. A target that is gone, hidden, inert or disabled is +// skipped. Only when none of them is usable, the last resort is the top bar's +// first usable menu button: opening a menu is harmless. Never a palette piece, +// which Enter would insert, and never . +// `visibility: hidden` keeps an element's boxes, so client rects alone are not +// enough: MEASURED, the overflow button at 1280 px is in the page, hidden that +// way, and focusing it left focus on . +const usable = (el: HTMLElement | null): el is HTMLElement => + !!el && + el.isConnected && + el.getClientRects().length > 0 && + getComputedStyle(el).visibility === 'visible' && + !el.closest('[inert]') && + !(el as HTMLButtonElement).disabled + +function tourExitTarget(): HTMLElement | null { + const named = [ + '[data-tour="help-trigger"]', + '.toolbar__overflow-btn', + '[data-tour="mobile-more"]', + ].map((sel) => document.querySelector(sel)) + const found = named.find(usable) + if (found) return found + const menus = document.querySelectorAll( + 'header.toolbar button[aria-haspopup], header.toolbar button[aria-expanded]', + ) + return [...menus].find((el) => usable(el) && !el.closest('[data-tour="palette"]')) ?? null +} + export function GuidedTour() { const phase = useTourStore((s) => s.phase) return ( @@ -79,17 +112,22 @@ function WelcomeCard() {
{/* §GT4 — the scrim swallows background input; a click on it is inert */}
+ {/* issue #308 - the question is the dialog's description, read with its + name when the card opens */}

{t('tour.welcome.title')}

-

{t('tour.welcome.body')}

+

+ {t('tour.welcome.body')} +

-
+
+ {announcement} +
) diff --git a/src/i18n/es419Copy.test.ts b/src/i18n/es419Copy.test.ts index 02ff3973..be957509 100644 --- a/src/i18n/es419Copy.test.ts +++ b/src/i18n/es419Copy.test.ts @@ -282,6 +282,8 @@ describe('es-419 copy — mechanical review', () => { // issue #307: the key names in the 0.18.1 release note 'whatsNew.v0181.move', 'whatsNew.v0181.phone', + // issue #308: the key name in the 0.18.2 release note + 'whatsNew.v0182.end', ], Tab: ['whatsNew.v0181.move'], Ctrl: ['toolbar.undo.title', 'toolbar.redo.title'], diff --git a/src/i18n/itCopy.test.ts b/src/i18n/itCopy.test.ts index 1515f03e..89a186ff 100644 --- a/src/i18n/itCopy.test.ts +++ b/src/i18n/itCopy.test.ts @@ -175,6 +175,8 @@ const IDENTICAL_TO_EN: ReadonlyArray = [ ['catalog share.protect.password', 'Password'], // issue #300: the Italian word ['catalog dist.seed', 'seed'], ['catalog tour.nav.position', '{n} / {total}'], + // issue #308 - the step announcement, worded with the same full stops + ['catalog tour.nav.announce', '{n} / {total}. {title}. {body}'], ['catalog help.contextual.hint.mc.name', 'Monte Carlo'], ['catalog canvas.frame.areaName', 'Area {n}'], ['catalog inspector.edge.flowPlaceholder', '1, all, 2D6, 1-3, 25%'], @@ -203,15 +205,15 @@ const IDENTICAL_TO_EN: ReadonlyArray = [ // ------------------------------------------------------------------ shape describe('it copy — the surfaces exist and are complete', () => { it('has exactly the base key set', () => { - expect(KEYS).toHaveLength(999) // issue #301 decision 1: +4 (share.unavailable and the three 0.17.1 release-note lines); the session chip: +3 (the three 0.17.2 release-note lines); the licence screen: +12 (nine strings of the view and the three 0.18.0 release-note lines); the menu keyboard: +3 (the three 0.18.1 release-note lines) + expect(KEYS).toHaveLength(1003) // issue #301 decision 1: +4 (share.unavailable and the three 0.17.1 release-note lines); the session chip: +3 (the three 0.17.2 release-note lines); the licence screen: +12 (nine strings of the view and the three 0.18.0 release-note lines); the menu keyboard: +3 (the three 0.18.1 release-note lines); the tour announcement: +4 (tour.nav.announce and the three 0.18.2 release-note lines) expect(Object.keys(IT).sort()).toEqual([...KEYS].sort()) }) it('covers all four runtime surfaces, at the measured sizes', () => { const per: Record = {} for (const r of RUNTIME) per[r.surface.split('/')[0]!] = (per[r.surface.split('/')[0]!] ?? 0) + 1 - expect(per).toEqual({ catalog: 999, template: 196, frame: 7, module: 19 }) - expect(RUNTIME).toHaveLength(1221) + expect(per).toEqual({ catalog: 1003, template: 196, frame: 7, module: 19 }) + expect(RUNTIME).toHaveLength(1225) }) it('EVERY row has a non-empty ENGLISH side — the vacuity guard', () => { @@ -292,9 +294,10 @@ describe('it copy — what is identical to English, exactly', () => { expect([...actual].sort()).toEqual([...IDENTICAL_TO_EN].sort()) }) - it('the declared set is what it claims — 39 pairs, no duplicate key', () => { - expect(IDENTICAL_TO_EN).toHaveLength(39) - expect(new Set(IDENTICAL_TO_EN.map(([k]) => k)).size).toBe(39) + // issue #308: 39 -> 40 (tour.nav.announce) + it('the declared set is what it claims — 40 pairs, no duplicate key', () => { + expect(IDENTICAL_TO_EN).toHaveLength(40) + expect(new Set(IDENTICAL_TO_EN.map(([k]) => k)).size).toBe(40) }) }) diff --git a/src/i18n/locales/ar/ui.ts b/src/i18n/locales/ar/ui.ts index 312e6f22..7f362a40 100644 --- a/src/i18n/locales/ar/ui.ts +++ b/src/i18n/locales/ar/ui.ts @@ -358,6 +358,7 @@ const ui = { // HELD — see the file header. English value, decision not taken. 'tour.nav.position': '{n} / {total}', 'tour.nav.close': 'إغلاق الجولة', + 'tour.nav.announce': '{n} / {total}. {title}. {body}', 'tour.desktop.pieces.title': 'القطع', 'tour.desktop.pieces.body': 'اللبنات الأساسية — مَجمَع ومصدر ومصرف وبوّابة وغيرها. انقر واحدة، أو اسحبها إلى اللوحة، لإضافتها.', @@ -577,6 +578,9 @@ const ui = { 'whatsNew.v0181.open': 'أصبحت القائمة التي تُفتح من لوحة المفاتيح تنقل التركيز إلى أول عنصر فيها، ويؤدي السهم للأسفل أو للأعلى على زر قائمة مغلقة إلى فتحها عند أول عنصر أو آخره.', 'whatsNew.v0181.move': 'داخل القائمة تتنقل الأسهم وHome وEnd بين العناصر، ويغلق Escape وTab القائمة ويعيدان التركيز إلى زرها، وكذلك بعد اختيار عنصر لا يفتح شيئًا آخر.', 'whatsNew.v0181.phone': 'على الهاتف تتلقى كل لوحة التركيز عند فتحها، ويعود Escape مستوى واحدًا إلى لوحة «المزيد».', + 'whatsNew.v0182.steps': 'في الجولة التعريفية، يُعلَن الآن رقم كل خطوة وعنوانها ونصها مرة واحدة، ويُعلَن سؤال بطاقة الترحيب مع عنوانها.', + 'whatsNew.v0182.focus': 'تُفتح الجولة والتركيز على «التالي»، ويبقى التركيز على «التالي» أو «رجوع» أثناء تغيّر الخطوات، وكذلك عندما يصل «رجوع» إلى الخطوة الأولى.', + 'whatsNew.v0182.end': 'أيًّا كانت طريقة إنهاء الجولة، بزر «تم» أو Escape أو زر الإغلاق، ينتقل التركيز إلى زر «مساعدة»، أو إلى «المزيد» على الهاتف، بدلًا من أن يضيع.', } as const export default ui diff --git a/src/i18n/locales/de/ui.ts b/src/i18n/locales/de/ui.ts index 3326fa2c..fe51f106 100644 --- a/src/i18n/locales/de/ui.ts +++ b/src/i18n/locales/de/ui.ts @@ -374,6 +374,7 @@ const ui = { 'tour.nav.done': 'Fertig', 'tour.nav.position': '{n} / {total}', 'tour.nav.close': 'Die Tour schließen', + 'tour.nav.announce': '{n} / {total}. {title}. {body}', 'tour.desktop.pieces.title': 'Bausteine', 'tour.desktop.pieces.body': 'Die Bausteine — Speicher, Quelle, Senke, Verteiler und die übrigen. Einen anklicken oder auf die Arbeitsfläche ziehen, um ihn hinzuzufügen.', @@ -612,6 +613,9 @@ const ui = { 'whatsNew.v0181.open': 'Ein mit der Tastatur geöffnetes Menü setzt den Fokus jetzt auf den ersten Eintrag, und Pfeil nach unten oder oben auf einer geschlossenen Menüschaltfläche öffnet es beim ersten oder letzten Eintrag.', 'whatsNew.v0181.move': 'Im Menü bewegen die Pfeiltasten, Pos1 und Ende durch die Einträge; Escape und Tab schließen es und kehren zur Menüschaltfläche zurück, auch nach der Wahl eines Eintrags, der nichts weiter öffnet.', 'whatsNew.v0181.phone': 'Auf dem Smartphone erhält jedes Blatt beim Öffnen den Fokus, und Escape führt eine Ebene zurück zum Blatt Mehr.', + 'whatsNew.v0182.steps': 'In der Tour werden Nummer, Titel und Text jedes Schritts jetzt einmal angesagt, und die Frage der Willkommenskarte wird mit ihrem Titel angesagt.', + 'whatsNew.v0182.focus': 'Die Tour öffnet mit dem Fokus auf Weiter, und der Fokus bleibt beim Wechsel der Schritte auf Weiter oder Zurück, auch wenn Zurück den ersten Schritt erreicht.', + 'whatsNew.v0182.end': 'Wie auch immer die Tour endet, mit Fertig, Escape oder ihrer Schließen-Schaltfläche: Der Fokus geht auf die Schaltfläche Hilfe, auf dem Smartphone auf Mehr, statt verloren zu gehen.', } satisfies Record export default ui diff --git a/src/i18n/locales/en/ui.ts b/src/i18n/locales/en/ui.ts index 8eac511f..cfaebf7b 100644 --- a/src/i18n/locales/en/ui.ts +++ b/src/i18n/locales/en/ui.ts @@ -310,6 +310,7 @@ const ui = { 'tour.nav.done': 'Done', 'tour.nav.position': '{n} / {total}', 'tour.nav.close': 'Close the tour', + 'tour.nav.announce': '{n} / {total}. {title}. {body}', 'tour.desktop.pieces.title': 'Pieces', 'tour.desktop.pieces.body': 'The building blocks — Pool, Source, Drain, Gate, and the rest. Click one, or drag it onto the canvas, to add it.', 'tour.desktop.canvas.title': 'Canvas', @@ -521,6 +522,9 @@ const ui = { 'whatsNew.v0181.open': 'A menu opened with the keyboard now puts focus on its first item, and Arrow Down or Arrow Up on a closed menu button opens it at the first or last item.', 'whatsNew.v0181.move': 'Inside a menu, the arrow keys, Home and End move through the items; Escape and Tab close it and return to the menu button, also after choosing an item that opens nothing else.', 'whatsNew.v0181.phone': 'On a phone, each sheet takes focus when it opens, and Escape goes back one level to the More sheet.', + 'whatsNew.v0182.steps': 'In the guided tour, each step’s number, title and text are now announced once, and the welcome card’s question is announced with its title.', + 'whatsNew.v0182.focus': 'The tour opens with focus on Next, and focus stays on Next or Back while the steps change, also when Back reaches the first step.', + 'whatsNew.v0182.end': 'However the tour ends, with Done, Escape or its close button, focus goes to the Help button, or to More on a phone, instead of being lost.', } as const export type UiKey = keyof typeof ui diff --git a/src/i18n/locales/es-419/ui.ts b/src/i18n/locales/es-419/ui.ts index 8e352d24..9932d7bc 100644 --- a/src/i18n/locales/es-419/ui.ts +++ b/src/i18n/locales/es-419/ui.ts @@ -320,6 +320,7 @@ const ui = { 'tour.nav.done': 'Listo', 'tour.nav.position': '{n} / {total}', 'tour.nav.close': 'Cerrar el recorrido', + 'tour.nav.announce': '{n} / {total}. {title}. {body}', 'tour.desktop.pieces.title': 'Piezas', 'tour.desktop.pieces.body': 'Los bloques de construcción: Depósito, Fuente, Sumidero, Distribuidor y los demás. Haga clic en uno, o arrástrelo al lienzo, para agregarlo.', 'tour.desktop.canvas.title': 'Lienzo', @@ -535,6 +536,9 @@ const ui = { 'whatsNew.v0181.open': 'Un menú abierto con el teclado ahora pone el foco en su primer elemento, y Flecha abajo o Flecha arriba en un botón de menú cerrado lo abre en el primer o el último elemento.', 'whatsNew.v0181.move': 'Dentro de un menú, las flechas, Inicio y Fin recorren los elementos, y Escape y Tab lo cierran y vuelven al botón del menú, también después de elegir un elemento que no abre nada más.', 'whatsNew.v0181.phone': 'En un teléfono, cada panel recibe el foco al abrirse y Escape vuelve un nivel, al panel Más.', + 'whatsNew.v0182.steps': 'En el recorrido guiado, el número, el título y el texto de cada paso ahora se anuncian una sola vez, y la pregunta de la tarjeta de bienvenida se anuncia con su título.', + 'whatsNew.v0182.focus': 'El recorrido se abre con el foco en Siguiente, y el foco se queda en Siguiente o Atrás mientras cambian los pasos, también cuando Atrás llega al primer paso.', + 'whatsNew.v0182.end': 'Termine como termine el recorrido, con Listo, Escape o su botón de cierre, el foco pasa al botón Ayuda, o a Más en un teléfono, en lugar de perderse.', } as const export type UiKey = keyof typeof ui diff --git a/src/i18n/locales/es-ES/ui.ts b/src/i18n/locales/es-ES/ui.ts index e5e124c6..6d8f979a 100644 --- a/src/i18n/locales/es-ES/ui.ts +++ b/src/i18n/locales/es-ES/ui.ts @@ -334,6 +334,7 @@ const ui = { 'tour.nav.done': 'Listo', 'tour.nav.position': '{n} / {total}', 'tour.nav.close': 'Cerrar el recorrido', + 'tour.nav.announce': '{n} / {total}. {title}. {body}', 'tour.desktop.pieces.title': 'Piezas', 'tour.desktop.pieces.body': 'Los bloques de construcción: Depósito, Fuente, Sumidero, Distribuidor y los demás. Haga clic en uno, o arrástrelo al lienzo, para añadirlo.', 'tour.desktop.canvas.title': 'Lienzo', @@ -549,6 +550,9 @@ const ui = { 'whatsNew.v0181.open': 'Un menú abierto con el teclado ahora pone el foco en su primer elemento, y Flecha abajo o Flecha arriba en un botón de menú cerrado lo abre en el primer o el último elemento.', 'whatsNew.v0181.move': 'Dentro de un menú, las flechas, Inicio y Fin recorren los elementos, y Escape y Tab lo cierran y vuelven al botón del menú, también después de elegir un elemento que no abre nada más.', 'whatsNew.v0181.phone': 'En un teléfono, cada panel recibe el foco al abrirse y Escape vuelve un nivel, al panel Más.', + 'whatsNew.v0182.steps': 'En el recorrido guiado, el número, el título y el texto de cada paso ahora se anuncian una sola vez, y la pregunta de la tarjeta de bienvenida se anuncia con su título.', + 'whatsNew.v0182.focus': 'El recorrido se abre con el foco en Siguiente, y el foco se queda en Siguiente o Atrás mientras cambian los pasos, también cuando Atrás llega al primer paso.', + 'whatsNew.v0182.end': 'Termine como termine el recorrido, con Listo, Escape o su botón de cierre, el foco pasa al botón Ayuda, o a Más en un teléfono, en lugar de perderse.', } as const export type UiKey = keyof typeof ui diff --git a/src/i18n/locales/fr/ui.ts b/src/i18n/locales/fr/ui.ts index f065450e..a6a139dc 100644 --- a/src/i18n/locales/fr/ui.ts +++ b/src/i18n/locales/fr/ui.ts @@ -367,6 +367,7 @@ const ui = { 'tour.nav.done': 'Terminé', 'tour.nav.position': '{n} / {total}', 'tour.nav.close': 'Fermer la visite', + 'tour.nav.announce': '{n} / {total}. {title}. {body}', 'tour.desktop.pieces.title': 'Éléments', 'tour.desktop.pieces.body': 'Les briques de base — réservoir, source, puits, aiguillage, et les autres. Cliquez sur l’une d’elles, ou faites-la glisser sur le canevas, pour l’ajouter.', @@ -606,6 +607,9 @@ const ui = { 'whatsNew.v0181.open': 'Un menu ouvert au clavier place désormais le focus sur son premier élément, et Flèche bas ou Flèche haut sur un bouton de menu fermé l’ouvre au premier ou au dernier élément.', 'whatsNew.v0181.move': 'Dans un menu, les flèches, Début et Fin parcourent les éléments, et Échap et Tab le ferment en revenant au bouton du menu, y compris après le choix d’un élément qui n’ouvre rien d’autre.', 'whatsNew.v0181.phone': 'Sur un téléphone, chaque panneau prend le focus à son ouverture et Échap revient d’un niveau, au panneau Plus.', + 'whatsNew.v0182.steps': 'Dans la visite guidée, le numéro, le titre et le texte de chaque étape sont désormais annoncés une seule fois, et la question de la carte de bienvenue est annoncée avec son titre.', + 'whatsNew.v0182.focus': 'La visite s’ouvre avec le focus sur Suivant, et le focus reste sur Suivant ou Précédent pendant le changement d’étape, y compris quand Précédent ramène à la première étape.', + 'whatsNew.v0182.end': 'Quelle que soit la façon de terminer la visite, Terminé, Échap ou son bouton de fermeture, le focus va sur le bouton Aide, ou sur Plus sur un téléphone, au lieu d’être perdu.', } satisfies Record export default ui diff --git a/src/i18n/locales/it/ui.ts b/src/i18n/locales/it/ui.ts index 7ec10c24..0cb22b13 100644 --- a/src/i18n/locales/it/ui.ts +++ b/src/i18n/locales/it/ui.ts @@ -340,6 +340,7 @@ const ui = { 'tour.nav.done': 'Fatto', 'tour.nav.position': '{n} / {total}', 'tour.nav.close': 'Chiudi il giro', + 'tour.nav.announce': '{n} / {total}. {title}. {body}', 'tour.desktop.pieces.title': 'Elementi', 'tour.desktop.pieces.body': 'I mattoni di base — Serbatoio, Sorgente, Scarico, Ripartitore e gli altri. Fai clic su uno, oppure trascinalo sull’area di disegno, per aggiungerlo.', 'tour.desktop.canvas.title': 'Area di disegno', @@ -551,6 +552,9 @@ const ui = { 'whatsNew.v0181.open': 'Un menu aperto con la tastiera ora porta lo stato attivo sul primo elemento, e Freccia giù o Freccia su su un pulsante di menu chiuso lo apre sul primo o sull’ultimo elemento.', 'whatsNew.v0181.move': 'Nel menu, le frecce, Home e Fine scorrono gli elementi; Esc e Tab lo chiudono e tornano al pulsante del menu, anche dopo aver scelto un elemento che non apre altro.', 'whatsNew.v0181.phone': 'Sul telefono ogni pannello riceve lo stato attivo all’apertura, ed Esc torna indietro di un livello, al pannello Altro.', + 'whatsNew.v0182.steps': 'Nel giro guidato, il numero, il titolo e il testo di ogni passaggio vengono ora annunciati una sola volta, e la domanda della scheda di benvenuto viene annunciata con il suo titolo.', + 'whatsNew.v0182.focus': 'Il giro si apre con lo stato attivo su Avanti, e lo stato attivo resta su Avanti o Indietro mentre i passaggi cambiano, anche quando Indietro torna al primo passaggio.', + 'whatsNew.v0182.end': 'Comunque si chiuda il giro, con Fatto, Esc o il pulsante di chiusura, lo stato attivo va sul pulsante Guida, o su Altro sul telefono, invece di andare perso.', } satisfies Record export default ui diff --git a/src/i18n/locales/ja/ui.ts b/src/i18n/locales/ja/ui.ts index 6bb612d3..6ddf1c64 100644 --- a/src/i18n/locales/ja/ui.ts +++ b/src/i18n/locales/ja/ui.ts @@ -336,6 +336,7 @@ const ui = { 'tour.nav.done': '完了', 'tour.nav.position': '{n} / {total}', 'tour.nav.close': 'ツアーを閉じる', + 'tour.nav.announce': '{n} / {total}。{title}。{body}', 'tour.desktop.pieces.title': 'パーツ', 'tour.desktop.pieces.body': '基本要素 — プール、ソース、ドレイン、ゲートなど。クリックするか、キャンバスへドラッグして追加します。', @@ -556,6 +557,9 @@ const ui = { 'whatsNew.v0181.open': 'キーボードで開いたメニューは最初の項目にフォーカスが移り、閉じたメニューボタンで下矢印・上矢印を押すと最初または最後の項目から開きます。', 'whatsNew.v0181.move': 'メニュー内では矢印キーと Home・End で項目を移動し、Escape と Tab でメニューを閉じてメニューボタンに戻ります。ほかに何も開かない項目を選んだ後も同じです。', 'whatsNew.v0181.phone': 'スマートフォンでは、シートが開くとフォーカスがシート内に移り、Escape で一つ上の「その他」シートに戻ります。', + 'whatsNew.v0182.steps': 'ガイドツアーでは、各ステップの番号・タイトル・説明が一度ずつ読み上げられ、ようこそカードの質問もタイトルと一緒に読み上げられます。', + 'whatsNew.v0182.focus': 'ツアーはフォーカスが「次へ」にある状態で始まり、ステップが切り替わる間も、「戻る」で最初のステップに戻ったときも、フォーカスは「次へ」または「戻る」に留まります。', + 'whatsNew.v0182.end': '「完了」、Escape、閉じるボタンのどれでツアーを終えても、フォーカスは失われず「ヘルプ」ボタン(スマートフォンでは「その他」ボタン)に移ります。', } satisfies Record export default ui diff --git a/src/i18n/locales/ko/ui.ts b/src/i18n/locales/ko/ui.ts index b115f430..83c6ef98 100644 --- a/src/i18n/locales/ko/ui.ts +++ b/src/i18n/locales/ko/ui.ts @@ -313,6 +313,7 @@ const ui = { 'tour.nav.done': '완료', 'tour.nav.position': '{n} / {total}', 'tour.nav.close': '투어 닫기', + 'tour.nav.announce': '{n} / {total}. {title}. {body}', 'tour.desktop.pieces.title': '조각', 'tour.desktop.pieces.body': '풀, 소스, 드레인, 게이트 등 기본 구성 요소입니다. 클릭하거나 캔버스로 끌어다 놓아 추가하세요.', 'tour.desktop.canvas.title': '캔버스', @@ -524,6 +525,9 @@ const ui = { 'whatsNew.v0181.open': '키보드로 연 메뉴는 이제 첫 항목에 포커스가 가고, 닫힌 메뉴 버튼에서 아래쪽·위쪽 화살표를 누르면 첫 항목이나 마지막 항목에서 열립니다.', 'whatsNew.v0181.move': '메뉴 안에서는 방향키와 Home·End로 항목을 오가며, Escape와 Tab은 메뉴를 닫고 메뉴 버튼으로 돌아갑니다. 다른 창을 열지 않는 항목을 고른 뒤에도 마찬가지입니다.', 'whatsNew.v0181.phone': '휴대폰에서는 시트가 열릴 때 포커스가 시트 안으로 들어가고, Escape를 누르면 한 단계 위인 더 보기 시트로 돌아갑니다.', + 'whatsNew.v0182.steps': '가이드 투어에서 이제 각 단계의 번호, 제목, 설명이 한 번씩 안내되고, 환영 카드의 질문도 제목과 함께 안내됩니다.', + 'whatsNew.v0182.focus': '투어가 열리면 포커스가 다음 버튼에 있고, 단계가 바뀌는 동안에도, 이전 버튼으로 첫 단계에 돌아왔을 때도 포커스는 다음 또는 이전 버튼에 머뭅니다.', + 'whatsNew.v0182.end': '완료, Escape, 닫기 버튼 중 어느 것으로 투어를 끝내도 포커스가 사라지지 않고 도움말 버튼으로, 휴대폰에서는 더 보기 버튼으로 갑니다.', } satisfies Record export default ui diff --git a/src/i18n/locales/nl/ui.ts b/src/i18n/locales/nl/ui.ts index c8e01bcc..dc1274e2 100644 --- a/src/i18n/locales/nl/ui.ts +++ b/src/i18n/locales/nl/ui.ts @@ -320,6 +320,7 @@ const ui = { 'tour.nav.done': 'Klaar', 'tour.nav.position': '{n} / {total}', 'tour.nav.close': 'De rondleiding sluiten', + 'tour.nav.announce': '{n} / {total}. {title}. {body}', 'tour.desktop.pieces.title': 'Onderdelen', 'tour.desktop.pieces.body': 'De bouwstenen — Voorraad, Bron, Afvoer, Verdeler en de rest. Klik op een onderdeel, of sleep het naar het tekengebied, om het toe te voegen.', 'tour.desktop.canvas.title': 'Tekengebied', @@ -532,6 +533,9 @@ const ui = { 'whatsNew.v0181.open': 'Een menu dat met het toetsenbord wordt geopend, zet de focus nu op het eerste item, en Pijl omlaag of Pijl omhoog op een gesloten menuknop opent het bij het eerste of laatste item.', 'whatsNew.v0181.move': 'In een menu gaan de pijltoetsen, Home en End door de items; Escape en Tab sluiten het en gaan terug naar de menuknop, ook na het kiezen van een item dat niets anders opent.', 'whatsNew.v0181.phone': 'Op een telefoon krijgt elk blad de focus bij het openen, en Escape gaat één niveau terug naar het blad Meer.', + 'whatsNew.v0182.steps': 'In de rondleiding worden het nummer, de titel en de tekst van elke stap nu één keer aangekondigd, en de vraag op de welkomstkaart wordt met de titel aangekondigd.', + 'whatsNew.v0182.focus': 'De rondleiding opent met de focus op Volgende, en de focus blijft op Volgende of Terug terwijl de stappen wisselen, ook wanneer Terug de eerste stap bereikt.', + 'whatsNew.v0182.end': 'Hoe de rondleiding ook eindigt, met Klaar, Escape of de sluitknop, de focus gaat naar de knop Help, of naar Meer op een telefoon, in plaats van verloren te gaan.', } as const export default ui diff --git a/src/i18n/locales/pt-BR/ui.ts b/src/i18n/locales/pt-BR/ui.ts index 0d324f01..113a0c55 100644 --- a/src/i18n/locales/pt-BR/ui.ts +++ b/src/i18n/locales/pt-BR/ui.ts @@ -326,6 +326,7 @@ const ui = { 'tour.nav.done': 'Concluir', 'tour.nav.position': '{n} / {total}', 'tour.nav.close': 'Fechar o tour', + 'tour.nav.announce': '{n} / {total}. {title}. {body}', 'tour.desktop.pieces.title': 'Peças', 'tour.desktop.pieces.body': 'Os blocos de montagem — Reservatório, Fonte, Sumidouro, Distribuidor e os demais. Clique em um, ou arraste-o para a tela, para adicioná-lo.', 'tour.desktop.canvas.title': 'Tela', @@ -537,6 +538,9 @@ const ui = { 'whatsNew.v0181.open': 'Um menu aberto pelo teclado passa a colocar o foco no primeiro item, e, quando um botão de menu fechado tem o foco, a Seta para baixo ou a Seta para cima abre o menu no primeiro ou no último item.', 'whatsNew.v0181.move': 'Dentro de um menu, as setas, Home e End percorrem os itens, e Esc e Tab fecham o menu e voltam ao botão do menu, também depois de escolher um item que não abre mais nada.', 'whatsNew.v0181.phone': 'No celular, cada painel recebe o foco ao abrir, e Esc volta um nível, para o painel Mais.', + 'whatsNew.v0182.steps': 'No tour guiado, o número, o título e o texto de cada etapa passam a ser anunciados uma única vez, e a pergunta do cartão de boas-vindas é anunciada com o título.', + 'whatsNew.v0182.focus': 'O tour abre com o foco em Avançar, e o foco fica em Avançar ou Voltar enquanto as etapas mudam, também quando Voltar chega à primeira etapa.', + 'whatsNew.v0182.end': 'Seja qual for a forma de encerrar o tour, com Concluir, Esc ou o botão de fechar, o foco vai para o botão Ajuda, ou para Mais no celular, em vez de se perder.', } as const export type UiKey = keyof typeof ui diff --git a/src/i18n/locales/pt-PT/ui.ts b/src/i18n/locales/pt-PT/ui.ts index e3ca2360..e423e09e 100644 --- a/src/i18n/locales/pt-PT/ui.ts +++ b/src/i18n/locales/pt-PT/ui.ts @@ -340,6 +340,7 @@ const ui = { 'tour.nav.done': 'Concluir', 'tour.nav.position': '{n} / {total}', 'tour.nav.close': 'Fechar o tour', + 'tour.nav.announce': '{n} / {total}. {title}. {body}', 'tour.desktop.pieces.title': 'Peças', 'tour.desktop.pieces.body': 'Os blocos de montagem — Reservatório, Fonte, Sumidouro, Distribuidor e os demais. Clique em um, ou arraste-o para a tela, para adicioná-lo.', 'tour.desktop.canvas.title': 'Tela', @@ -551,6 +552,9 @@ const ui = { 'whatsNew.v0181.open': 'Um menu aberto pelo teclado passa a colocar o foco no primeiro item, e, quando um botão de menu fechado tem o foco, a Seta para baixo ou a Seta para cima abre o menu no primeiro ou no último item.', 'whatsNew.v0181.move': 'Dentro de um menu, as setas, Home e End percorrem os itens, e Esc e Tab fecham o menu e voltam ao botão do menu, também depois de escolher um item que não abre mais nada.', 'whatsNew.v0181.phone': 'No telemóvel, cada painel recebe o foco ao abrir, e Esc volta um nível, para o painel Mais.', + 'whatsNew.v0182.steps': 'No tour guiado, o número, o título e o texto de cada etapa passam a ser anunciados uma única vez, e a pergunta do cartão de boas-vindas é anunciada com o título.', + 'whatsNew.v0182.focus': 'O tour abre com o foco em Avançar, e o foco fica em Avançar ou Voltar enquanto as etapas mudam, também quando Voltar chega à primeira etapa.', + 'whatsNew.v0182.end': 'Seja qual for a forma de encerrar o tour, com Concluir, Esc ou o botão de fechar, o foco vai para o botão Ajuda, ou para Mais no telemóvel, em vez de se perder.', } as const export type UiKey = keyof typeof ui diff --git a/src/i18n/locales/ru/ui.ts b/src/i18n/locales/ru/ui.ts index 1b089ca7..8f97b73d 100644 --- a/src/i18n/locales/ru/ui.ts +++ b/src/i18n/locales/ru/ui.ts @@ -375,6 +375,7 @@ const ui = { 'tour.nav.done': 'Готово', 'tour.nav.position': '{n} / {total}', 'tour.nav.close': 'Закрыть тур', + 'tour.nav.announce': '{n} / {total}. {title}. {body}', 'tour.desktop.pieces.title': 'Элементы', 'tour.desktop.pieces.body': 'Строительные блоки — Накопитель, Источник, Сток, Распределитель и остальные. Нажмите на любой из них или перетащите его на холст, чтобы добавить.', @@ -605,6 +606,9 @@ const ui = { 'whatsNew.v0181.open': 'Меню, открытое с клавиатуры, теперь переводит фокус на первый пункт, а стрелка вниз или вверх на закрытой кнопке меню открывает его на первом или последнем пункте.', 'whatsNew.v0181.move': 'Внутри меню стрелки, Home и End перемещают по пунктам, а Escape и Tab закрывают его и возвращают к кнопке меню — в том числе после выбора пункта, который больше ничего не открывает.', 'whatsNew.v0181.phone': 'На телефоне каждая панель при открытии получает фокус, а Escape возвращает на уровень выше, к панели «Ещё».', + 'whatsNew.v0182.steps': 'В туре номер, заголовок и текст каждого шага теперь объявляются один раз, а вопрос приветственной карточки объявляется вместе с её заголовком.', + 'whatsNew.v0182.focus': 'Тур открывается с фокусом на кнопке «Далее», и фокус остаётся на «Далее» или «Назад», пока шаги меняются, в том числе когда «Назад» возвращает к первому шагу.', + 'whatsNew.v0182.end': 'Как бы ни закончился тур — кнопкой «Готово», клавишей Escape или кнопкой закрытия, — фокус переходит на кнопку «Справка», а на телефоне — на «Ещё», и не теряется.', } as const export type UiKey = keyof typeof ui diff --git a/src/i18n/locales/th/ui.ts b/src/i18n/locales/th/ui.ts index a4ab9594..b1bc8276 100644 --- a/src/i18n/locales/th/ui.ts +++ b/src/i18n/locales/th/ui.ts @@ -373,6 +373,7 @@ const ui = { 'tour.nav.done': 'เสร็จสิ้น', 'tour.nav.position': '{n} / {total}', 'tour.nav.close': 'ปิดทัวร์', + 'tour.nav.announce': '{n} / {total} {title} {body}', 'tour.desktop.pieces.title': 'ชิ้นส่วน', 'tour.desktop.pieces.body': 'บล็อกพื้นฐาน — ถังพัก แหล่งกำเนิด ทางระบาย ตัวกระจาย และอื่น ๆ คลิกหนึ่งอัน หรือลากลงบนผืนผ้าใบ เพื่อเพิ่ม', @@ -597,6 +598,9 @@ const ui = { 'whatsNew.v0181.open': 'เมนูที่เปิดด้วยแป้นพิมพ์จะย้ายโฟกัสไปที่รายการแรก และการกดลูกศรลงหรือลูกศรขึ้นบนปุ่มเมนูที่ปิดอยู่จะเปิดเมนูที่รายการแรกหรือรายการสุดท้าย', 'whatsNew.v0181.move': 'ภายในเมนู ปุ่มลูกศร Home และ End ใช้เลื่อนไปตามรายการ ส่วน Escape และ Tab จะปิดเมนูและกลับไปที่ปุ่มเมนู รวมถึงหลังจากเลือกรายการที่ไม่ได้เปิดสิ่งอื่น', 'whatsNew.v0181.phone': 'บนโทรศัพท์ แผ่นแต่ละแผ่นจะรับโฟกัสเมื่อเปิด และ Escape จะย้อนกลับหนึ่งระดับไปที่แผ่นเพิ่มเติม', + 'whatsNew.v0182.steps': 'ในทัวร์แนะนำ หมายเลข ชื่อ และคำอธิบายของแต่ละขั้นตอนจะถูกประกาศเพียงครั้งเดียว และคำถามในการ์ดต้อนรับจะถูกประกาศพร้อมกับชื่อการ์ด', + 'whatsNew.v0182.focus': 'ทัวร์จะเปิดโดยมีโฟกัสอยู่ที่ปุ่มถัดไป และโฟกัสจะอยู่ที่ปุ่มถัดไปหรือย้อนกลับระหว่างที่เปลี่ยนขั้นตอน รวมถึงเมื่อย้อนกลับมาถึงขั้นตอนแรก', + 'whatsNew.v0182.end': 'ไม่ว่าจะจบทัวร์ด้วยปุ่มเสร็จสิ้น Escape หรือปุ่มปิด โฟกัสจะไม่หายไป แต่จะไปที่ปุ่มช่วยเหลือ หรือปุ่มเพิ่มเติมบนโทรศัพท์', } as const export type UiKey = keyof typeof ui diff --git a/src/i18n/locales/tr/ui.ts b/src/i18n/locales/tr/ui.ts index 530bc1e3..60129e67 100644 --- a/src/i18n/locales/tr/ui.ts +++ b/src/i18n/locales/tr/ui.ts @@ -356,6 +356,7 @@ const ui = { 'tour.nav.done': 'Bitti', 'tour.nav.position': '{n} / {total}', 'tour.nav.close': 'Turu kapat', + 'tour.nav.announce': '{n} / {total}. {title}. {body}', 'tour.desktop.pieces.title': 'Parçalar', 'tour.desktop.pieces.body': 'Yapı taşları — Havuz, Kaynak, Gider, Dağıtıcı ve diğerleri. Eklemek için birine tıklayın ya da onu tuvale sürükleyin.', @@ -591,6 +592,9 @@ const ui = { 'whatsNew.v0181.open': 'Klavyeyle açılan bir menü artık odağı ilk öğesine taşır; kapalı bir menü düğmesinde aşağı veya yukarı ok tuşu menüyü ilk ya da son öğede açar.', 'whatsNew.v0181.move': 'Menünün içinde ok tuşları, Home ve End öğeler arasında gezinir; Escape ve Tab menüyü kapatıp menü düğmesine döner. Başka bir şey açmayan bir öğe seçildikten sonra da böyledir.', 'whatsNew.v0181.phone': 'Telefonda her sayfa açılınca odağı alır ve Escape bir üst düzeye, Diğer sayfasına döner.', + 'whatsNew.v0182.steps': 'Rehberli turda her adımın numarası, başlığı ve metni artık bir kez duyurulur; karşılama kartındaki soru da başlığıyla birlikte duyurulur.', + 'whatsNew.v0182.focus': 'Tur, odak İleri düğmesindeyken açılır ve adımlar değişirken, Geri ilk adıma döndüğünde de odak İleri veya Geri düğmesinde kalır.', + 'whatsNew.v0182.end': 'Tur Bitti, Escape ya da kapatma düğmesiyle nasıl biterse bitsin, odak kaybolmaz; Yardım düğmesine, telefonda ise Diğer düğmesine gider.', } as const export type UiKey = keyof typeof ui diff --git a/src/i18n/locales/vi/ui.ts b/src/i18n/locales/vi/ui.ts index 2e2b13b3..193880e7 100644 --- a/src/i18n/locales/vi/ui.ts +++ b/src/i18n/locales/vi/ui.ts @@ -326,6 +326,7 @@ const ui = { 'tour.nav.done': 'Xong', 'tour.nav.position': '{n} / {total}', 'tour.nav.close': 'Đóng phần giới thiệu', + 'tour.nav.announce': '{n} / {total}. {title}. {body}', 'tour.desktop.pieces.title': 'Các thành phần', 'tour.desktop.pieces.body': 'Những khối dựng nên mô hình — Bể chứa, Nguồn, Điểm xả, Bộ chia và những cái khác. Nhấn một cái, hoặc kéo nó vào khung vẽ, để thêm.', 'tour.desktop.canvas.title': 'Khung vẽ', @@ -537,6 +538,9 @@ const ui = { 'whatsNew.v0181.open': 'Menu mở bằng bàn phím giờ đưa tiêu điểm đến mục đầu tiên, và phím mũi tên xuống hoặc lên trên nút menu đang đóng sẽ mở menu ở mục đầu hoặc mục cuối.', 'whatsNew.v0181.move': 'Trong menu, các phím mũi tên, Home và End di chuyển giữa các mục; Escape và Tab đóng menu và quay lại nút menu, kể cả sau khi chọn một mục không mở gì khác.', 'whatsNew.v0181.phone': 'Trên điện thoại, mỗi bảng nhận tiêu điểm khi mở, và Escape quay lại một cấp, về bảng Thêm.', + 'whatsNew.v0182.steps': 'Trong phần giới thiệu, số thứ tự, tiêu đề và nội dung của mỗi bước giờ chỉ được thông báo một lần, và câu hỏi trên thẻ chào mừng được thông báo cùng tiêu đề.', + 'whatsNew.v0182.focus': 'Phần giới thiệu mở ra với tiêu điểm ở nút Tiếp, và tiêu điểm vẫn ở nút Tiếp hoặc Quay lại khi các bước thay đổi, kể cả khi Quay lại về đến bước đầu tiên.', + 'whatsNew.v0182.end': 'Dù kết thúc phần giới thiệu bằng Xong, Escape hay nút đóng, tiêu điểm sẽ không bị mất mà chuyển đến nút Trợ giúp, hoặc nút Thêm trên điện thoại.', } satisfies Record export default ui diff --git a/src/i18n/locales/zh-Hans/ui.ts b/src/i18n/locales/zh-Hans/ui.ts index b18dc4be..a3a2f1a1 100644 --- a/src/i18n/locales/zh-Hans/ui.ts +++ b/src/i18n/locales/zh-Hans/ui.ts @@ -317,6 +317,7 @@ const ui = { 'tour.nav.done': '完成', 'tour.nav.position': '{n} / {total}', 'tour.nav.close': '关闭导览', + 'tour.nav.announce': '{n} / {total}。{title}。{body}', 'tour.desktop.pieces.title': '元件', 'tour.desktop.pieces.body': '构成模型的积木——资源池、源、汇、分流门等等。点击一个,或把它拖到画布上,即可添加。', 'tour.desktop.canvas.title': '画布', @@ -529,6 +530,9 @@ const ui = { 'whatsNew.v0181.open': '用键盘打开的菜单现在会把焦点放在第一项上;在关闭的菜单按钮上按下箭头或上箭头,会从第一项或最后一项打开菜单。', 'whatsNew.v0181.move': '在菜单中,方向键和 Home、End 用于在各项之间移动;Escape 和 Tab 会关闭菜单并回到菜单按钮,选择不打开其他内容的项目后也是如此。', 'whatsNew.v0181.phone': '在手机上,表单打开时焦点会进入其中,按 Escape 会返回上一级的“更多”表单。', + 'whatsNew.v0182.steps': '在导览中,每一步的编号、标题和说明现在只播报一次,欢迎卡片中的问题也会与标题一起播报。', + 'whatsNew.v0182.focus': '导览打开时焦点位于“下一步”,切换步骤时以及用“上一步”回到第一步时,焦点都停留在“下一步”或“上一步”上。', + 'whatsNew.v0182.end': '无论用“完成”、Escape 还是关闭按钮结束导览,焦点都不会丢失,而是回到“帮助”按钮(在手机上是“更多”按钮)。', } satisfies Record export default ui diff --git a/src/i18n/locales/zh-Hant/ui.ts b/src/i18n/locales/zh-Hant/ui.ts index 2669d5ac..908dbe15 100644 --- a/src/i18n/locales/zh-Hant/ui.ts +++ b/src/i18n/locales/zh-Hant/ui.ts @@ -339,6 +339,7 @@ const ui = { 'tour.nav.done': '完成', 'tour.nav.position': '{n} / {total}', 'tour.nav.close': '關閉導覽', + 'tour.nav.announce': '{n} / {total}。{title}。{body}', 'tour.desktop.pieces.title': '元件', 'tour.desktop.pieces.body': '組成模型的積木——資源池、來源、匯、分流閘等等。點一下,或把它拖到畫布上,即可加入。', @@ -555,6 +556,9 @@ const ui = { 'whatsNew.v0181.open': '用鍵盤開啟的選單現在會把焦點放在第一個項目上;在關閉的選單按鈕上按下方向鍵或上方向鍵,會從第一個或最後一個項目開啟選單。', 'whatsNew.v0181.move': '在選單中,方向鍵與 Home、End 用來在項目之間移動;Escape 與 Tab 會關閉選單並回到選單按鈕,選擇不會開啟其他內容的項目後也是如此。', 'whatsNew.v0181.phone': '在手機上,表單開啟時焦點會進入其中,按 Escape 會返回上一層的「更多」表單。', + 'whatsNew.v0182.steps': '在導覽中,每一步的編號、標題與說明現在只會播報一次,歡迎卡片中的問題也會與標題一起播報。', + 'whatsNew.v0182.focus': '導覽開啟時焦點位於「下一步」,切換步驟時以及用「上一步」回到第一步時,焦點都會停留在「下一步」或「上一步」上。', + 'whatsNew.v0182.end': '無論用「完成」、Escape 或關閉按鈕結束導覽,焦點都不會遺失,而是回到「說明」按鈕(在手機上是「更多」按鈕)。', } satisfies Record export default ui diff --git a/src/i18n/nlCopy.test.ts b/src/i18n/nlCopy.test.ts index ce847302..e42fdc4b 100644 --- a/src/i18n/nlCopy.test.ts +++ b/src/i18n/nlCopy.test.ts @@ -235,6 +235,8 @@ const IDENTICAL_TO_EN: ReadonlyArray = [ ['catalog tour.desktop.inspector.title', 'Inspector'], ['catalog tour.help.menuLabel', 'Help'], ['catalog tour.nav.position', '{n} / {total}'], + // issue #308 - the step announcement, worded with the same full stops + ['catalog tour.nav.announce', '{n} / {total}. {title}. {body}'], ['frame/gacha-banner-zones zone_pickup', 'Premium Pickup'], ['template/gacha-banner-zones pity_pickup', 'Pity'], ['template/gacha-banner-zones pity_standard', 'Pity'], @@ -259,15 +261,15 @@ const IDENTICAL_TO_EN: ReadonlyArray = [ // ------------------------------------------------------------------ shape describe('nl copy — the surfaces exist and are complete', () => { it('has exactly the base key set', () => { - expect(KEYS).toHaveLength(999) // issue #301 decision 1: +4 (share.unavailable and the three 0.17.1 release-note lines); the session chip: +3 (the three 0.17.2 release-note lines); the licence screen: +12 (nine strings of the view and the three 0.18.0 release-note lines); the menu keyboard: +3 (the three 0.18.1 release-note lines) + expect(KEYS).toHaveLength(1003) // issue #301 decision 1: +4 (share.unavailable and the three 0.17.1 release-note lines); the session chip: +3 (the three 0.17.2 release-note lines); the licence screen: +12 (nine strings of the view and the three 0.18.0 release-note lines); the menu keyboard: +3 (the three 0.18.1 release-note lines); the tour announcement: +4 (tour.nav.announce and the three 0.18.2 release-note lines) expect(Object.keys(NL).sort()).toEqual([...KEYS].sort()) }) it('covers all four runtime surfaces, at the measured sizes', () => { const per: Record = {} for (const r of RUNTIME) per[r.surface.split('/')[0]!] = (per[r.surface.split('/')[0]!] ?? 0) + 1 - expect(per).toEqual({ catalog: 999, template: 196, frame: 7, module: 19 }) - expect(RUNTIME).toHaveLength(1221) + expect(per).toEqual({ catalog: 1003, template: 196, frame: 7, module: 19 }) + expect(RUNTIME).toHaveLength(1225) }) it('EVERY row has a non-empty ENGLISH side — the vacuity guard', () => { diff --git a/src/i18n/ptPtCopy.test.ts b/src/i18n/ptPtCopy.test.ts index f76861f9..1edb3601 100644 --- a/src/i18n/ptPtCopy.test.ts +++ b/src/i18n/ptPtCopy.test.ts @@ -58,7 +58,7 @@ describe('pt-PT copy — the region audit over pt-BR', () => { // ---------------------------------------------------------------- shape it('has exactly the same key set as pt-BR', () => { expect(Object.keys(ptValues).sort()).toEqual(KEYS.slice().sort()) - expect(KEYS).toHaveLength(999) // issue #301 decision 1: +4 (share.unavailable and the three 0.17.1 release-note lines); the session chip: +3 (the three 0.17.2 release-note lines); the licence screen: +12 (nine strings of the view and the three 0.18.0 release-note lines); the menu keyboard: +3 (the three 0.18.1 release-note lines) + expect(KEYS).toHaveLength(1003) // issue #301 decision 1: +4 (share.unavailable and the three 0.17.1 release-note lines); the session chip: +3 (the three 0.17.2 release-note lines); the licence screen: +12 (nine strings of the view and the three 0.18.0 release-note lines); the menu keyboard: +3 (the three 0.18.1 release-note lines); the tour announcement: +4 (tour.nav.announce and the three 0.18.2 release-note lines) }) it('differs from pt-BR on exactly the audited keys', () => { @@ -128,7 +128,10 @@ describe('pt-PT copy — the region audit over pt-BR', () => { // The menu keyboard (0.18.1) moved it from 268 to 269: one of its three // release-note lines - `telemóvel` not `celular`; the other two were // written to read the same in both. - expect(DELTA).toHaveLength(269) + // The tour announcement (0.18.2) moved it from 269 to 270: one of its + // three release-note lines - `telemóvel` not `celular`; the other two + // and the step announcement's template read the same in both. + expect(DELTA).toHaveLength(270) // and it is a real audit, not a rewrite — most of the catalog agrees. // // The password strings of issue #300 are counted apart. The word for a @@ -172,7 +175,7 @@ describe('pt-PT copy — the region audit over pt-BR', () => { // outside them, the quarter bound stands as it always did const rest = KEYS.filter((k) => !isPasswordKey(k)) const restDelta = DELTA.filter((k) => !isPasswordKey(k)) - expect(restDelta).toHaveLength(241) + expect(restDelta).toHaveLength(242) expect(restDelta.length).toBeLessThan(rest.length / 4) }) diff --git a/src/i18n/ruCopy.test.ts b/src/i18n/ruCopy.test.ts index 7e6d5f17..9c2cbb4e 100644 --- a/src/i18n/ruCopy.test.ts +++ b/src/i18n/ruCopy.test.ts @@ -43,7 +43,7 @@ describe('ru copy — the first Cyrillic catalog', () => { // ------------------------------------------------------------------ shape it('has exactly the base key set', () => { expect(Object.keys(ruValues).sort()).toEqual(KEYS.slice().sort()) - expect(KEYS).toHaveLength(999) // issue #301 decision 1: +4 (share.unavailable and the three 0.17.1 release-note lines); the session chip: +3 (the three 0.17.2 release-note lines); the licence screen: +12 (nine strings of the view and the three 0.18.0 release-note lines); the menu keyboard: +3 (the three 0.18.1 release-note lines) + expect(KEYS).toHaveLength(1003) // issue #301 decision 1: +4 (share.unavailable and the three 0.17.1 release-note lines); the session chip: +3 (the three 0.17.2 release-note lines); the licence screen: +12 (nine strings of the view and the three 0.18.0 release-note lines); the menu keyboard: +3 (the three 0.18.1 release-note lines); the tour announcement: +4 (tour.nav.announce and the three 0.18.2 release-note lines) }) // ----------------------------------------------------------------- script diff --git a/src/i18n/thCopy.test.ts b/src/i18n/thCopy.test.ts index 919e0434..610222ed 100644 --- a/src/i18n/thCopy.test.ts +++ b/src/i18n/thCopy.test.ts @@ -88,11 +88,11 @@ const englishFor = (surface: string, id: string): string => { describe('th copy — the first no-space script', () => { it('has exactly the base key set', () => { expect(Object.keys(TH).sort()).toEqual(KEYS.slice().sort()) - expect(KEYS).toHaveLength(999) // issue #301 decision 1: +4 (share.unavailable and the three 0.17.1 release-note lines); the session chip: +3 (the three 0.17.2 release-note lines); the licence screen: +12 (nine strings of the view and the three 0.18.0 release-note lines); the menu keyboard: +3 (the three 0.18.1 release-note lines) + expect(KEYS).toHaveLength(1003) // issue #301 decision 1: +4 (share.unavailable and the three 0.17.1 release-note lines); the session chip: +3 (the three 0.17.2 release-note lines); the licence screen: +12 (nine strings of the view and the three 0.18.0 release-note lines); the menu keyboard: +3 (the three 0.18.1 release-note lines); the tour announcement: +4 (tour.nav.announce and the three 0.18.2 release-note lines) }) it('covers all three runtime surfaces', () => { - expect(RUNTIME.filter(([s]) => s === 'catalog')).toHaveLength(999) + expect(RUNTIME.filter(([s]) => s === 'catalog')).toHaveLength(1003) expect(RUNTIME.filter(([s]) => s.startsWith('template/'))).toHaveLength(196) expect(RUNTIME.filter(([s]) => s.startsWith('frame/'))).toHaveLength(7) expect(RUNTIME.filter(([s]) => s.startsWith('module/'))).toHaveLength(19) @@ -410,6 +410,8 @@ const LATIN_KEYS = [ // issue #307 - the key names in two 0.18.1 release-note lines 'catalog:whatsNew.v0181.move', 'catalog:whatsNew.v0181.phone', + // issue #308 - `Escape` in a 0.18.2 release-note line + 'catalog:whatsNew.v0182.end', // the storage gate and the Storage and privacy area (issue #297) 'catalog:gate.lead', 'catalog:gate.portable.note', @@ -427,7 +429,7 @@ describe('a Latin run is declared, twice over — by key and by word', () => { it('the strings carrying a Latin run are exactly the declared ones', () => { expect(withLatin.slice().sort()).toEqual(LATIN_KEYS.slice().sort()) - expect(LATIN_KEYS).toHaveLength(168) // issue #307: +2 (two 0.18.1 release-note lines) + expect(LATIN_KEYS).toHaveLength(169) // issue #307: +2 (two 0.18.1 release-note lines); issue #308: +1 (one 0.18.2 release-note line) }) it('the Latin vocabulary is exactly the declared one', () => { diff --git a/src/i18n/trCopy.test.ts b/src/i18n/trCopy.test.ts index 97a1846b..4206c0d6 100644 --- a/src/i18n/trCopy.test.ts +++ b/src/i18n/trCopy.test.ts @@ -30,7 +30,7 @@ const KEYS = Object.keys(EN) describe('tr copy — the first Turkish catalog', () => { it('has exactly the base key set', () => { expect(Object.keys(TR).sort()).toEqual(KEYS.slice().sort()) - expect(KEYS).toHaveLength(999) // issue #300: +33; issue #301 decision 1: +4 (share.unavailable and the three 0.17.1 release-note lines); the session chip: +3 (the three 0.17.2 release-note lines); the licence screen: +12 (nine strings of the view and the three 0.18.0 release-note lines); the menu keyboard: +3 (the three 0.18.1 release-note lines) + expect(KEYS).toHaveLength(1003) // issue #300: +33; issue #301 decision 1: +4 (share.unavailable and the three 0.17.1 release-note lines); the session chip: +3 (the three 0.17.2 release-note lines); the licence screen: +12 (nine strings of the view and the three 0.18.0 release-note lines); the menu keyboard: +3 (the three 0.18.1 release-note lines); the tour announcement: +4 (tour.nav.announce and the three 0.18.2 release-note lines) }) }) @@ -177,6 +177,7 @@ describe('a value left in English is declared, not accidental', () => { 'regExpr.row.generic', // — {code} 'timeline.csv', // CSV 'tour.nav.position', // {n} / {total} + 'tour.nav.announce', // {n} / {total}. {title}. {body} - issue #308 ] it('the identical-to-English set is exactly the declared one', () => { diff --git a/src/i18n/viCopy.test.ts b/src/i18n/viCopy.test.ts index 68b4608b..d067b13d 100644 --- a/src/i18n/viCopy.test.ts +++ b/src/i18n/viCopy.test.ts @@ -132,11 +132,11 @@ const englishFor = (surface: string, id: string): string => { describe('vi copy — the first locale English hides inside', () => { it('has exactly the base key set', () => { expect(Object.keys(VI).sort()).toEqual(KEYS.slice().sort()) - expect(KEYS).toHaveLength(999) // issue #301 decision 1: +4 (share.unavailable and the three 0.17.1 release-note lines); the session chip: +3 (the three 0.17.2 release-note lines); the licence screen: +12 (nine strings of the view and the three 0.18.0 release-note lines); the menu keyboard: +3 (the three 0.18.1 release-note lines) + expect(KEYS).toHaveLength(1003) // issue #301 decision 1: +4 (share.unavailable and the three 0.17.1 release-note lines); the session chip: +3 (the three 0.17.2 release-note lines); the licence screen: +12 (nine strings of the view and the three 0.18.0 release-note lines); the menu keyboard: +3 (the three 0.18.1 release-note lines); the tour announcement: +4 (tour.nav.announce and the three 0.18.2 release-note lines) }) it('covers all four runtime surfaces', () => { - expect(RUNTIME.filter(([s]) => s === 'catalog')).toHaveLength(999) + expect(RUNTIME.filter(([s]) => s === 'catalog')).toHaveLength(1003) expect(RUNTIME.filter(([s]) => s.startsWith('template/'))).toHaveLength(196) expect(RUNTIME.filter(([s]) => s.startsWith('frame/'))).toHaveLength(7) expect(RUNTIME.filter(([s]) => s.startsWith('module/'))).toHaveLength(19) @@ -178,6 +178,8 @@ const IDENTICAL_TO_EN: [string, string][] = [ ['catalog:regExpr.row.generic', '— {code}'], ['catalog:timeline.csv', 'CSV'], ['catalog:tour.nav.position', '{n} / {total}'], + // issue #308 - the step announcement, worded with the same full stops + ['catalog:tour.nav.announce', '{n} / {total}. {title}. {body}'], ['frame/gacha-banner-zones:zone_pickup', 'Premium Pickup'], ['frame/gacha-banner-zones:zone_standard', 'Premium Standard'], ['template/gacha-banner-zones:pity_pickup', 'Pity'], @@ -201,7 +203,7 @@ describe('a string identical to English is declared, with its value', () => { expect(actual.slice().sort((a, b) => a[0].localeCompare(b[0]))).toEqual( IDENTICAL_TO_EN.slice().sort((a, b) => a[0].localeCompare(b[0])), ) - expect(IDENTICAL_TO_EN).toHaveLength(28) + expect(IDENTICAL_TO_EN).toHaveLength(29) // issue #308: +1 (tour.nav.announce) }) it('every declared pair still reads back the value it declares', () => { @@ -551,6 +553,8 @@ const ASCII_KEYS = [ 'catalog:whatsNew.v0181.move', 'catalog:whatsNew.v0181.open', 'catalog:whatsNew.v0181.phone', + // issue #308 - `Escape` in a 0.18.2 release-note line + 'catalog:whatsNew.v0182.end', // the storage gate and the Storage and privacy area (issue #297) 'catalog:gate.lead', 'catalog:gate.portable.note', @@ -572,7 +576,7 @@ describe('a kept English word is declared, twice over — by key and by word', ( ([s, id]) => s + ':' + id, ) expect(actual.slice().sort()).toEqual(ASCII_KEYS.slice().sort()) - expect(ASCII_KEYS).toHaveLength(192) // issue #307: +3 (the three 0.18.1 release-note lines) + expect(ASCII_KEYS).toHaveLength(193) // issue #307: +3 (the three 0.18.1 release-note lines); issue #308: +1 (one 0.18.2 release-note line) }) it('the kept vocabulary is exactly the declared one', () => { diff --git a/src/releaseNotes/releaseNotes.ts b/src/releaseNotes/releaseNotes.ts index 16391b15..742c9af2 100644 --- a/src/releaseNotes/releaseNotes.ts +++ b/src/releaseNotes/releaseNotes.ts @@ -38,6 +38,14 @@ export const releaseNoteIdFor = (version: string): ReleaseNoteId => `release:${v * mechanics, no test counts, no pull-request numbers. */ export const RELEASE_NOTES: readonly ReleaseNote[] = [ + { + // issue #308 - the guided tour announces each step once and never drops + // focus; the date is the day it is deployed, set right before the merge + id: 'release:0.18.2', + version: '0.18.2', + date: '2026-10-06', + items: ['whatsNew.v0182.steps', 'whatsNew.v0182.focus', 'whatsNew.v0182.end'], + }, { // issue #307 - one keyboard contract for every menu, and the phone's // sheets taking focus and stepping back one level; the date is the day it