From 4e17549b75f57c0e8274bf4cbe1c3af959e2e195 Mon Sep 17 00:00:00 2001 From: Hanrim <148833226+MerciHanrim@users.noreply.github.com> Date: Fri, 9 Oct 2026 13:29:24 +0900 Subject: [PATCH] feat(playback): speed tiers, a full Step, and the phone's Playback speed with 12 pairs a step (v0.24.0) Part of #330, the third of its three playback pull requests. - Three tiers read once when a step starts: at 400 ms or slower the token and its +N badge travel; 200-399 ms the token travels and +N shows on arrival; below 200 ms the moved path flashes, then the token appears at the end with +N. - Step always draws the full tier. - The phone: More > Playback speed (Slow 1 s, Normal 0.6 s, Fast 0.3 s, Very fast 0.12 s), and a phone profile fixed at step start: the first 12 of the desktop's 24 pairs, no departure ring; Gate paths, arrival cues, Pool pulses and Converter marks kept. Reduced motion and L0 unchanged. - Presentation only: engine, RNG, files, share links, digests and simulation results unchanged. - A current limit, recorded in docs/simulation-playback.md: about 300 simultaneous moves draw at 70-200 ms a frame, before this change as after it. - Three phone departure baselines replaced (no departure ring on the phone). --- .changes/playback-speed-tiers.json | 1 + CHANGELOG.md | 12 + README.md | 28 ++- docs/mobile.md | 21 +- docs/simulation-playback.md | 44 ++++ docs/visual-language.md | 5 + e2e/mobile.spec.ts | 8 +- e2e/playback-speed-tiers.spec.ts | 235 ++++++++++++++++++ .../play-depart-dark-L2-mobile-win32.png | Bin 17593 -> 17273 bytes ...y-depart-forced-colors-L2-mobile-win32.png | Bin 18133 -> 17846 bytes .../play-depart-light-L2-mobile-win32.png | Bin 19252 -> 18939 bytes e2e/whats-new.spec.ts | 2 +- package-lock.json | 4 +- package.json | 2 +- scripts/arrow-units.json | 4 +- src/components/edges/LoopEdge.tsx | 28 ++- src/components/edges/playback-caps.ts | 7 + src/components/edges/playbackBadge.ts | 8 +- src/components/edges/playbackBudget.ts | 12 +- src/components/mobile/MobileMoreMenu.tsx | 58 ++++- src/i18n/es419Copy.test.ts | 6 +- src/i18n/itCopy.test.ts | 6 +- src/i18n/locales/ar/ui.ts | 12 + src/i18n/locales/de/ui.ts | 12 + src/i18n/locales/en/ui.ts | 12 + src/i18n/locales/es-419/ui.ts | 12 + src/i18n/locales/es-ES/ui.ts | 12 + src/i18n/locales/fr/ui.ts | 12 + src/i18n/locales/it/ui.ts | 12 + src/i18n/locales/ja/ui.ts | 12 + src/i18n/locales/ko/ui.ts | 12 + src/i18n/locales/nl/ui.ts | 12 + src/i18n/locales/pt-BR/ui.ts | 12 + src/i18n/locales/pt-PT/ui.ts | 12 + src/i18n/locales/ru/ui.ts | 12 + src/i18n/locales/th/ui.ts | 12 + src/i18n/locales/tr/ui.ts | 12 + src/i18n/locales/vi/ui.ts | 12 + src/i18n/locales/zh-Hans/ui.ts | 12 + src/i18n/locales/zh-Hant/ui.ts | 12 + src/i18n/nlCopy.test.ts | 6 +- src/i18n/ptPtCopy.test.ts | 2 +- src/i18n/ruCopy.test.ts | 2 +- src/i18n/thCopy.test.ts | 7 +- src/i18n/trCopy.test.ts | 3 +- src/i18n/viCopy.test.ts | 7 +- src/index.css | 22 ++ src/releaseNotes/releaseNotes.ts | 9 + src/store/playbackTier.test.ts | 34 +++ src/store/playbackTier.ts | 39 +++ src/store/simStore.ts | 23 +- src/store/uiStore.ts | 1 + 52 files changed, 799 insertions(+), 63 deletions(-) create mode 100644 .changes/playback-speed-tiers.json create mode 100644 e2e/playback-speed-tiers.spec.ts create mode 100644 src/store/playbackTier.test.ts create mode 100644 src/store/playbackTier.ts diff --git a/.changes/playback-speed-tiers.json b/.changes/playback-speed-tiers.json new file mode 100644 index 00000000..ae2e3bc6 --- /dev/null +++ b/.changes/playback-speed-tiers.json @@ -0,0 +1 @@ +{ "type": "user-facing", "releaseNoteId": "release:0.24.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index a330da8b..c08c89f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ 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.24.0 — 2026-10-09 + +Playback speed changes what a step draws (issue #330, the last of three), and the phone gets a speed choice of its own, as [`docs/simulation-playback.md`](docs/simulation-playback.md) §PB6.1 and [`docs/mobile.md`](docs/mobile.md) §MV4 describe. + +- **Three speed tiers.** At 400 ms a step or slower, the round marker travels with its `+N` beside it, as before. From 200 to 399 ms it travels and `+N` appears when it arrives. Below 200 ms the moved path flashes, then the marker appears at the end with `+N`. The tier is read when a step starts, so changing the speed during a step changes its pace but not what it draws; the next step takes the new tier. +- **Step always draws the full movement,** whatever the speed is set to. +- **Playback speed on the phone.** More → Playback speed offers Slow (1 s a step), Normal (0.6 s, the default), Fast (0.3 s) and Very fast (0.12 s); the current one is checked. There is still no seed in the phone's run bar. +- **Fewer moving markers on the phone.** A phone shows at most 12 marker-and-badge pairs a step — the first 12 of the same stable order as the desktop's 24 — and no departure ring; every other move keeps its path highlight and arrival cue. This is fixed when a step starts, so rotating the phone mid-step makes nothing in that step appear or disappear. +- Reduced motion and the far-zoomed map view keep their forms, unchanged. + +**No migration.** Engine, RNG, files, share links, digests and simulation results are unchanged; the speed is still not saved. A current limit, measured: a step with about 300 simultaneous moves draws slowly (roughly 70–200 ms a frame on a desktop development build), before this change as after it. Twelve release-note and phone strings in 18 languages, 16 of them without native review. The informational `meta.tool` string is now `loop-studio/0.24.0`. + ## v0.23.0 — 2026-10-09 Playback shows what happens inside the nodes (issue #330, second of three): a Pool lights up as its first marker arrives, and a Converter shows a conversion mark while its markers move, as [`docs/simulation-playback.md`](docs/simulation-playback.md) §PB4.7 describes. diff --git a/README.md b/README.md index 844ba814..de604194 100644 --- a/README.md +++ b/README.md @@ -140,7 +140,19 @@ Additional feature-specific design documents (localization, mobile, module system, large-graph readability, simulation playback, edge routing, data import, …) live under [`docs/`](docs/). -## Latest — v0.23.0 +## Latest — v0.24.0 + +Playback speed changes what a step draws, and the phone gets a speed choice of its own. + +- **Three speed tiers**: at 0.4 s a step or slower the marker travels with its `+N`; faster, + `+N` appears when it arrives; below 0.2 s the path flashes and the marker appears at its + end; Step always shows the full movement +- **Playback speed on the phone**: More → Playback speed offers Slow, Normal, Fast and Very + fast +- **Fewer moving markers on the phone**: at most 12 a step, and no departure ring; every + other move keeps its highlighted path and arrival + +## v0.23.0 Playback shows what happens inside the nodes: a Pool lights up as a marker arrives, and a Converter shows that it converts. @@ -181,18 +193,8 @@ language search no longer zooms the page in. zoom the page in and leave it zoomed; every phone text field is now large enough that it does not -## v0.21.3 - -A fix release: the edit lock is exact, and its button shows the state. - -- **A new document starts unlocked**, and every other one brings its own lock and an empty - undo history, so Undo never goes back into the previous document -- **While locked, nothing edits the document**: the palette, Insert module, Undo, Redo and - the data import wait; selecting, viewing, running and exporting stay available -- **The lock button reads at a glance**: an open padlock when you can edit, a closed, - highlighted one when editing is locked - -See [`CHANGELOG.md`](CHANGELOG.md) for the full notes of these releases, v0.21.2 (values +See [`CHANGELOG.md`](CHANGELOG.md) for the full notes of these releases, v0.21.3 (the edit +lock is exact, and its button shows the state), v0.21.2 (values and detail rows sit inside their node), v0.21.1 (Focus mode dims the connections too), v0.21.0 (compact nodes, so more of a large graph fits in view), v0.20.0 (flow diff --git a/docs/mobile.md b/docs/mobile.md index c36f7d7b..d23450c8 100644 --- a/docs/mobile.md +++ b/docs/mobile.md @@ -192,8 +192,23 @@ The mobile layout is unaffected — it is always view-only regardless of the fie full-width at the breakpoint, with **real** `padding-bottom: env(safe-area-inset-bottom)` (MV7). - Keeps **Reset / Step / Play·Pause**, the **Monte Carlo** button, and the - `step N` counter. The speed slider and seed field are **not rendered** on - mobile (view/run uses defaults; a power user is on desktop). + `step N` counter. The speed slider and seed field are **not rendered** in the + bar (view/run uses defaults; a power user is on desktop). +- **Playback speed (issue #330 PR 3, v0.24.0) — §MV4 reopened narrowly.** A + stepped choice in the More sheet, never a slider in the bar: `⋯ → Playback + speed` opens a sub-sheet with **Slow** (1000 ms a step), **Normal** (600, the + default), **Fast** (300) and **Very fast** (120), the current one pressed + (`aria-pressed`); a choice sets the same session-only `speedMs` as the + desktop slider and closes the sheets. Each speed falls in exactly one tier of + `docs/simulation-playback.md` §PB6.1. A speed the desktop slider set that is + none of the four leaves none pressed. There is still **no run seed** in the + bar; the Monte Carlo dialog keeps its base seed. +- **Fewer moving elements on the phone.** Every phone speed draws its tier with + the phone display profile (`docs/simulation-playback.md` §PB6.1): at most 12 + token-and-badge pairs a step (the first 12 of the desktop's stable order) and + no departure ring; past the 12 a move keeps its path highlight and arrival + cue. Fixed when a step starts, so a rotation mid-step changes nothing in that + step. Reduced motion and L0 keep their contracts. - 44 px minimum touch targets. - **Its height is measured, not assumed** (issue #303). The bar wraps to a second row below about 390 px and, at 390 px, in six languages; the @@ -485,7 +500,7 @@ opening. Its mobile placement rules: | MV-D4 | simulation-input Inspector tabs | **read-only on mobile** in this cut; any future live sim-input editing is a separate, separately-gated decision | | MV-D5 | document replacement | Import / Template **allowed**, but **confirm-before-replace** (MV3b), reusing the existing atomic `loadDoc()` path | | MV-D6 | layout switch | **never** mutates the doc / undo history / latch (MV3c); desktop width fully restores the editing UI | -| MV-D7 | run bar | fixed bottom: Reset / Step / Play·Pause / Monte Carlo + `step N`; **no** speed slider or seed field | +| MV-D7 | run bar | fixed bottom: Reset / Step / Play·Pause / Monte Carlo + `step N`; **no** speed slider or seed field; the four-step Playback speed lives under `⋯` (§MV4, issue #330 PR 3) | | MV-D8 | Timeline | collapsible bottom sheet, collapsed by default | | MV-D9 | secondary actions | a `More` menu (Share / Import / Export / Templates / Theme / stamp); palette + undo/redo + New not rendered | | MV-D10 | MiniMap | **not rendered** on mobile — and, on desktop, hidden when the canvas pane is smaller than ~640 × 380 px (`Canvas.tsx` `minimapFits`), where the fixed ~202 × 152 overlay would dominate rather than help | diff --git a/docs/simulation-playback.md b/docs/simulation-playback.md index cc3e09a0..270cf20b 100644 --- a/docs/simulation-playback.md +++ b/docs/simulation-playback.md @@ -465,6 +465,50 @@ step count, event lists, beat order, or which `to` is committed. Two runs at different speeds from the same seed commit byte-identical `SimState` at every step and identical series. +**The speed tiers (issue #330 PR 3, v0.24.0).** What a step DRAWS depends on its +beat, in three tiers with exact boundaries on the continuous desktop slider +(120 … 2400 ms, `src/store/playbackTier.ts`): + +| tier | beat | the round token | `+N` | +|---|---|---|---| +| **full** | ≥ 400 ms | travels its path (§PB4) | beside it, moving with it | +| **fast** | 200 … 399 ms | travels its path | only from its arrive beat, beside it at the end | +| **very fast** | < 200 ms | does not travel: the moved path flashes (`.pb-path--flash`) from the edge's onset to its arrive beat, then the token appears at the end | with the token, at the arrive beat | + +- The tier is read **once, when a step starts**, and travels with the + transition (`transition.tier`). A speed change during a step re-rates that + step's clock (§PB6.2) but changes its tier only from the next step. +- **Step** (the button) always draws the **full** tier, whatever the slider says. +- Everything else is the same in every tier: the onsets (§PBO2), the depart and + arrive cues, the Gate path, the 24 pairs and what lies past them (§PB4.5), the + own-label rule (judged on what is on screen: the token alone while `+N` is + not shown), Focus mode, the Pool pulse and the conversion mark (§PB4.7, at + the same arrival moment). State-connection beads are not tiered. +- Reduced motion ignores the tier (§PB9). L0 elides the token in every tier + (§PB4.4). +- The phone's four speeds (`docs/mobile.md` §MV4) fall one in each of full + (1000 and 600 ms), fast (300) and very fast (120). +- **The display profile, fixed per step like the tier** (`transition.profile`). + In the mobile view/run layout (`src/ui/media.ts`) a step uses the **phone** + profile: at most **12** token-and-badge pairs — the FIRST 12 of the same + stable order as the desktop 24 (§PB4.5), so a phone pair is always a desktop + pair — and **no departure ring** (the `emit` burst, §PBO3). Every move past + the 12 keeps its path highlight (the Gate path included) and its arrival cue, + as past the 24; the arrival rings, the chosen pairs' `+N`, the Pool pulse and + the conversion mark are unchanged. Step draws the full tier with the phone + profile's 12 and no departure ring. The profile is read when the step + starts, so rotating or resizing the screen during a step makes nothing in + that step appear or disappear; the next step takes the new layout. Under + reduced motion the profile is `desktop` (the static form of §PB9 and its 24 + are unchanged), and L0 keeps its contract: no token, the path pulse for the + pairs of the step's budget. +- **A current limit, measured (not addressed here):** a step with about 300 + simultaneous moves runs at roughly 70–200 ms a frame on a desktop dev build, + on `main` before this change as after it; hiding the Pool pulse or the + connection cues does not change it, so it is the per-frame cost of updating + that many moving connections, not a cue. The bundled Templates (largest + ≈ 40 moves a step) stay near 17 ms median. + **PB6.2 — speed change mid-transition: recompute the remaining phases only.** Progress is held as `τ ∈ [0,1]`, never as an absolute end-timestamp. On a change at frame time `t₀` with current `τ₀`: diff --git a/docs/visual-language.md b/docs/visual-language.md index 23dc2e4a..6e49a010 100644 --- a/docs/visual-language.md +++ b/docs/visual-language.md @@ -542,6 +542,11 @@ byte-identical across L2/L1/L0 (§VL12.5). inside it, which fades after the step. Under reduced motion both are held static for the committed step; in forced colours the pulse is a 2 px system-colour line instead of a tint. +- **Playback speed tiers, issue #330 PR 3 (v0.24.0)** + (`docs/simulation-playback.md` §PB6.1): at 400 ms a step or slower the token + travels with its `+N`; from 200 to 399 ms it travels and `+N` shows on + arrival; below 200 ms the moved path flashes and the token appears at the end + with `+N`. Step always draws the full form; reduced motion is unchanged. - Motion never conveys information that isn't also in a static frame. --- diff --git a/e2e/mobile.spec.ts b/e2e/mobile.spec.ts index 5aaefa65..5d6b6e4b 100644 --- a/e2e/mobile.spec.ts +++ b/e2e/mobile.spec.ts @@ -1783,7 +1783,8 @@ test.describe('sheet row secondary label contrast (§MV5 / WCAG 1.4.3)', () => { } await escapeSheet(page, which) } - expect(checked, 'the walk must reach every text sub-label: 5 More + 5 Templates + 4 enabled Export').toBe(14) + // issue #330 PR 3 — More gained the Playback speed row (its sub names the speed) + expect(checked, 'the walk must reach every text sub-label: 6 More + 5 Templates + 4 enabled Export').toBe(15) expect(bad, 'hovered secondary labels below 4.5:1').toEqual([]) }) @@ -1808,9 +1809,10 @@ test.describe('sheet row secondary label contrast (§MV5 / WCAG 1.4.3)', () => { } await escapeSheet(page, which) } - // the four submenu rows carry their affordance IN the sub-label, so they + // the five submenu rows carry their affordance IN the sub-label, so they // are covered by the same contract rather than by the row's own text - expect(markers.sort(), 'the ▸ markers are part of this contract').toEqual(['Export', 'Filters', 'Help', 'Templates']) + // (issue #330 PR 3: + Playback speed) + expect(markers.sort(), 'the ▸ markers are part of this contract').toEqual(['Export', 'Filters', 'Help', 'Playback speed', 'Templates']) expect(bad, 'focused secondary labels below 4.5:1').toEqual([]) }) diff --git a/e2e/playback-speed-tiers.spec.ts b/e2e/playback-speed-tiers.spec.ts new file mode 100644 index 00000000..39780a5a --- /dev/null +++ b/e2e/playback-speed-tiers.spec.ts @@ -0,0 +1,235 @@ +import { readFileSync } from 'node:fs' +import type { Page } from '@playwright/test' +import { expect, importGraph, openApp, resetAll, test } from './support/loop' + +// issue #330 PR 3 (v0.24.0) — the speed tiers and the phone profile +// (docs/simulation-playback.md §PB6.1, docs/mobile.md §MV4). Full (≥ 400 ms): +// the token travels with its `+N`; fast (200–399): it travels, `+N` from its +// arrive beat; very fast (< 200): the moved path flashes, then the token +// appears at the end with `+N`. Tier and profile are read once when a step +// starts; Step is always full. The phone draws at most 12 pairs (the first 12 +// of the desktop's 24) and no departure ring. A presentation layer only. + +type Loop = Record any }> +const call = (page: Page, fn: string, ...a: unknown[]) => + page.evaluate(([f, args]) => (window as unknown as { __loop: Loop }).__loop.sim.getState()[f as string](...(args as unknown[])), [fn, a] as const) +const sim = (page: Page) => + page.evaluate(() => { + const s = (window as unknown as { __loop: Loop }).__loop.sim.getState() + const t = s.transition + return { step: s.stepIndex as number, tau: (t?.tau ?? null) as number | null, tier: (t?.tier ?? null) as string | null, profile: (t?.profile ?? null) as string | null } + }) +const setViewport = (page: Page, zoom: number, x = 40, y = 120) => + page.evaluate(([z, vx, vy]) => (window as unknown as { __loop: { rf: { setViewport: (v: object, o: object) => void } } }).__loop.rf.setViewport({ x: vx, y: vy, zoom: z }, { duration: 0 }), [zoom, x, y] as const) + +const EQUILIBRIUM = readFileSync('examples/equilibrium.json', 'utf8') +/** 30 Sources, each pushing 1 a step into its own Pool: 30 moves a step */ +const MANY = JSON.stringify({ + schema: 'loop-studio/graph', + version: 1, + nodes: Array.from({ length: 30 }, (_, i) => [ + { id: `s${String(i).padStart(2, '0')}`, type: 'source', position: { x: 0, y: i * 70 }, data: { kind: 'source', label: `S${i}`, activation: 'automatic', mode: 'pushAny' } }, + { id: `p${String(i).padStart(2, '0')}`, type: 'pool', position: { x: 240, y: i * 70 }, data: { kind: 'pool', label: `P${i}`, activation: 'passive', initial: 0, capacity: null, mode: 'pullAny' } }, + ]).flat(), + edges: Array.from({ length: 30 }, (_, i) => { + const n = String(i).padStart(2, '0') + return { id: `e${n}`, type: 'loop', source: `s${n}`, target: `p${n}`, sourceHandle: 'out', targetHandle: 'in', data: { kind: 'resource', flow: '1' } } + }), +}) + +async function load(page: Page, graph: string, zoom = 1) { + await page.emulateMedia({ reducedMotion: 'no-preference' }) + await openApp(page) + await resetAll(page) + await importGraph(page, graph) + await call(page, 'reset') + await setViewport(page, zoom) +} + +/** Start a step at `ms` (Play, or Step), then hold it: re-rate to a slow 4000 + * ms beat (the tier and profile stay as read at the start) and pause once + * `pred` holds */ +async function startAt(page: Page, ms: number, how: 'play' | 'step') { + await page.evaluate(([ms, how]) => { + const s = (window as unknown as { __loop: Loop }).__loop.sim.getState() + s.setSpeed(ms) + if (how === 'play') s.play() + else s.stepOnce() + }, [ms, how] as const) + await expect.poll(async () => ((await sim(page)).tau != null ? 1 : -1), { timeout: 10000, intervals: [5] }).toBe(1) + await page.evaluate(() => { + const s = (window as unknown as { __loop: Loop }).__loop.sim.getState() + s.setSpeed(4000) + }) +} +async function holdWhen(page: Page, pred: (s: Awaited>) => boolean) { + await call(page, 'play') + await expect.poll(async () => (pred(await sim(page)) ? 1 : -1), { timeout: 20000, intervals: [16] }).toBe(1) + await call(page, 'pause') +} +const token = (page: Page, id: string) => page.locator(`.react-flow__edge[data-id="${id}"] g.pb-move`) +const badge = (page: Page, id: string) => page.locator(`.pb-badge[data-badge-for="${id}"]`) +const flash = (page: Page, id: string) => page.locator(`.react-flow__edge[data-id="${id}"] .pb-path--flash`) + +test.afterEach(async ({ page }) => { + await page.emulateMedia({ reducedMotion: null }).catch(() => {}) +}) + +test('full tier (≥ 400 ms): the token travels with its +N beside it', async ({ page }) => { + await load(page, EQUILIBRIUM) + await startAt(page, 600, 'play') + await holdWhen(page, (s) => s.tau != null && s.tau > 0.35 && s.tau < 0.7) + expect((await sim(page)).tier).toBe('full') + await expect(token(page, 'tpl-e1')).toHaveCount(1) + await expect(badge(page, 'tpl-e1')).toHaveText('+3') + await expect(flash(page, 'tpl-e1')).toHaveCount(0) +}) + +test('fast tier (200–399 ms): the token travels without +N, which shows from the arrive beat; the tier is read once per step', async ({ page }) => { + await load(page, EQUILIBRIUM) + await startAt(page, 300, 'play') + await holdWhen(page, (s) => s.tau != null && s.tau > 0.35 && s.tau < 0.7) + // re-rated to 4000 ms mid-step, still the fast tier it started with + expect((await sim(page)).tier).toBe('fast') + await expect(token(page, 'tpl-e1')).toHaveCount(1) + await expect(badge(page, 'tpl-e1')).toHaveCount(0) + await holdWhen(page, (s) => s.tau != null && s.tau >= 0.81 && s.tau < 0.93) + expect((await sim(page)).tier).toBe('fast') + await expect(badge(page, 'tpl-e1')).toHaveText('+3') + // the next step reads the speed it now has (4000 ms): full + await holdWhen(page, (s) => s.step === 1 && s.tau != null && s.tau > 0.3 && s.tau < 0.7) + expect((await sim(page)).tier).toBe('full') + await expect(badge(page, 'tpl-e1')).toHaveCount(1) +}) + +test('very fast tier (< 200 ms): the moved path flashes, then the token appears at the end with +N', async ({ page }) => { + await load(page, EQUILIBRIUM) + await startAt(page, 120, 'play') + await holdWhen(page, (s) => s.tau != null && s.tau > 0.35 && s.tau < 0.7) + expect((await sim(page)).tier).toBe('veryFast') + await expect(flash(page, 'tpl-e1')).toHaveCount(1) + await expect(token(page, 'tpl-e1')).toHaveCount(0) + await expect(badge(page, 'tpl-e1')).toHaveCount(0) + await holdWhen(page, (s) => s.tau != null && s.tau >= 0.81 && s.tau < 0.93) + await expect(flash(page, 'tpl-e1')).toHaveCount(0) + await expect(token(page, 'tpl-e1')).toHaveCount(1) + await expect(badge(page, 'tpl-e1')).toHaveText('+3') + // at the END of the path: the target handle's point + const at = await page.evaluate(() => { + const g = document.querySelector('.react-flow__edge[data-id="tpl-e1"] g.pb-move')! + const m = /translate\(([-\d.]+) ([-\d.]+)\)/.exec(g.getAttribute('transform')!)! + const p = document.querySelector('.react-flow__edge[data-id="tpl-e1"] path.react-flow__edge-path') as SVGPathElement + const end = p.getPointAtLength(p.getTotalLength()) + return Math.hypot(Number(m[1]) - end.x, Number(m[2]) - end.y) + }) + expect(at).toBeLessThan(1) +}) + +test('Step draws the full tier whatever the speed', async ({ page }) => { + await load(page, EQUILIBRIUM) + await startAt(page, 120, 'step') + await holdWhen(page, (s) => s.tau != null && s.tau > 0.35 && s.tau < 0.7) + expect((await sim(page)).tier).toBe('full') + await expect(token(page, 'tpl-e1')).toHaveCount(1) + await expect(badge(page, 'tpl-e1')).toHaveText('+3') +}) + +test('the phone profile: the first 12 of the desktop pairs, no departure ring, past 12 a path highlight and an arrival cue', async ({ page }) => { + // desktop first: the 24 pairs and their departure rings + await load(page, MANY, 0.6) + await startAt(page, 600, 'play') + await holdWhen(page, (s) => s.tau != null && s.tau < 0.12) + expect((await sim(page)).profile).toBe('desktop') + const pairs = () => page.evaluate(() => [...document.querySelectorAll('.pb-badge')].map((b) => (b as HTMLElement).dataset.badgeFor!).sort()) + const desktop = await pairs() + expect(desktop).toHaveLength(24) + expect(await page.locator('.pb-cue--depart').count()).toBeGreaterThan(0) + // the phone layout + await page.setViewportSize({ width: 390, height: 844 }) + await load(page, MANY, 0.6) + await startAt(page, 600, 'play') + await holdWhen(page, (s) => s.tau != null && s.tau < 0.12) + expect((await sim(page)).profile).toBe('phone') + const phone = await pairs() + expect(phone).toEqual(desktop.slice(0, 12)) + await expect(page.locator('g.pb-move')).toHaveCount(12) + await expect(page.locator('.pb-cue--depart')).toHaveCount(0) + // the 18 past the 12: a path highlight each, and an arrival cue at the arrive beat + await expect(page.locator('.pb-path--over-cap')).toHaveCount(18) + await holdWhen(page, (s) => s.tau != null && s.tau >= 0.81 && s.tau < 0.93) + await expect(page.locator('.pb-cue--arrive')).toHaveCount(30) + await expect(page.locator('.nodef__pulse')).toHaveCount(30) +}) + +test('the profile is fixed for the step: a resize mid-step changes nothing until the next step', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }) + await load(page, MANY, 0.6) + await startAt(page, 600, 'play') + await holdWhen(page, (s) => s.tau != null && s.tau > 0.3 && s.tau < 0.6) + await expect(page.locator('g.pb-move')).toHaveCount(12) + // to a desktop width, mid-step + await page.setViewportSize({ width: 1400, height: 900 }) + await setViewport(page, 0.6) + await page.waitForTimeout(300) + expect((await sim(page)).profile).toBe('phone') + await expect(page.locator('g.pb-move')).toHaveCount(12) + // the next step takes the new layout + await holdWhen(page, (s) => s.step === 1 && s.tau != null && s.tau > 0.3 && s.tau < 0.6) + expect((await sim(page)).profile).toBe('desktop') + await expect(page.locator('g.pb-move')).toHaveCount(24) +}) + +test('the phone: ⋯ → Playback speed offers four speeds, Normal by default, and a choice sets the speed', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }) + await load(page, EQUILIBRIUM) + await page.locator('[data-tour="mobile-more"]').click() + await expect(page.locator('[data-more-row="speed"]')).toContainText('Normal') + await page.locator('[data-more-row="speed"]').click() + const rows = page.locator('[data-speed]') + await expect(rows).toHaveCount(4) + expect(await rows.evaluateAll((els) => els.map((e) => [(e as HTMLElement).dataset.speed, e.getAttribute('aria-pressed'), e.textContent]))).toEqual([ + ['slow', 'false', 'Slow1 s a step'], + ['normal', 'true', 'Normal0.6 s a step'], + ['fast', 'false', 'Fast0.3 s a step'], + ['veryFast', 'false', 'Very fast0.12 s a step'], + ]) + for (const r of await rows.all()) expect((await r.boundingBox())!.height).toBeGreaterThanOrEqual(44) + await page.locator('[data-speed="fast"]').click() + await expect(page.locator('.sheet')).toHaveCount(0) + expect(await page.evaluate(() => (window as unknown as { __loop: Loop }).__loop.sim.getState().speedMs)).toBe(300) + await page.locator('[data-tour="mobile-more"]').click() + await expect(page.locator('[data-more-row="speed"]')).toContainText('Fast') + await page.locator('[data-more-row="speed"]').click() + await expect(page.locator('[data-speed="fast"]')).toHaveAttribute('aria-pressed', 'true') + // Escape goes back one level, to the More sheet + await page.keyboard.press('Escape') + await expect(page.locator('[data-more-row="speed"]')).toBeFocused() +}) + +test('reduced motion keeps its static form within 24 on the phone too', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }) + await load(page, MANY, 0.6) + await page.emulateMedia({ reducedMotion: 'reduce' }) + await call(page, 'stepOnce') + await expect.poll(async () => (await sim(page)).tau).toBe(null) + await expect(page.locator('g.pb-move')).toHaveCount(0) + await expect(page.locator('.pb-badge[data-playback-badge="static"]')).toHaveCount(24) +}) + +test('the tiers are a view only: values at 120 ms equal the same steps advanced without cues', async ({ page }) => { + await load(page, EQUILIBRIUM) + await call(page, 'setSpeed', 120) + await call(page, 'play') + await expect.poll(async () => (await sim(page)).step, { timeout: 20000 }).toBeGreaterThanOrEqual(8) + await call(page, 'pause') + // Pause holds an in-flight step (§PB5.1); Step finishes it + if ((await sim(page)).tau != null) await call(page, 'stepOnce') + await expect.poll(async () => (await sim(page)).tau).toBe(null) + const played = await page.evaluate(() => { + const s = (window as unknown as { __loop: Loop }).__loop.sim.getState() + return { step: s.stepIndex as number, values: { ...s.values } } + }) + await call(page, 'reset') + for (let i = 0; i < played.step; i++) await call(page, 'advance') + expect(await page.evaluate(() => ({ ...(window as unknown as { __loop: Loop }).__loop.sim.getState().values }))).toEqual(played.values) +}) diff --git a/e2e/playback-visual.spec.ts-snapshots/play-depart-dark-L2-mobile-win32.png b/e2e/playback-visual.spec.ts-snapshots/play-depart-dark-L2-mobile-win32.png index 009203999ca1883a43b30973c99c87cec4836869..cce7ad79d07998199de41f1ada09a92d49262db8 100644 GIT binary patch literal 17273 zcmeIaXIPWnx-N>pQ2Eo|d#b!24s$bc%(^!+k6W^M+7$5RV-L07M>V~DsnlGs%OfIcqh2mHFayXPLBkUZnC-L9Aogro+k)iz(yi?>q%n!s8xrI02@BNY zoDfHpH8>f27^ZR6`SgAKo5;8CIrU^&vs&!w>*cQl?*(nk%rzXELRaB>lw>3E#aG^m zec~j$%?2mq{iuHR%RMZ)<&e89lWmPmYf?f&LKYmJTjUZH6y)Z{`NECRM*0~t zlOTjp0xulT!NH-d{N^}ue1tp4q0V^!M4hpX0VstL5+CLi(_9TOBajKS-LO<#F>|(1t>z&e9E`?i!X?es9gn@y{$=MN7>dMT(F#K*%+{P5{2>SdJ9~S3(XTJx^NDx(_tPqZQT}<~lCr3opIAR0rf^7{!-@Ch z+qv-1RzjS;^s9l&AHaIL^mi!_4+3ki1Hd9(Z=al=zpr|7)gd4_yJc#{S9(L(x4-eA zB)`$?$TDVbdt4h3HSGV*aBE@@bMxlHSH9Aw)stEZtIc)1B&T4eumL$_bF)J?LvJiP zfpgeHtY!J&NTRp6!u4r@ri`#%tT>o_k7+Yga`Zs#Nns}Ui;|`9EvMZKN*4%e{5EWV z_u)89Q}(;JYrIATzmk&9>}gQK%&$gorUpRN^s3^T|rfSvKt0Q0RW|V<5RQ z@-Ew)Mm^W09Y|;c>cW2r+YAUtt^S7H|OqqD3 zlf8`{r;=pKlAWoC%rp)_iJmet+7;t+qM|>2Fyj>#cA~#8FSaV2y4W7v28;nxd+Bc6 z@bBsWDk#YRwWxf3b!${RqV#)^>#4J+@DOCE+6H9f!+nopANJCjQ0Hr%{6KOuSStw?AleNgF@&^Q}w}pj7+3Q)$W-WA_Q8ymgO})bm=*PznASw zb1M0r$$_9{n(TU2^X@pM-5*&Bfp248eQsBOx@Btk;>8OnbT4W1MaZu)uxsOL`?_}k zG}yd6de~`wtxn~hA*WVXp0U*3@bINNNX?{3emIm(0ru9Ek`aUT=JdE|K1 z_~-02mN2M-H}>}GV9|4-vavHN-$IMac0gUkd;I%rH!lt^FWkNIkT{MwMZy85CAe?Y z(AUSu1&`8_G9h@R6PpX!hLVyI6nF0)2fgRR9|S<%b=~)UYMV0eg>t&g8ea8V=tB!P z?tCY3ffUb+bYO%r9rCO1%`m>_rxll2OraV`gL-hrOaY2L-&g6L!d*#2*>(5Jva^DPWzK}L?KSm{n)p8cPcKJ9#1$PfUyPSH8ute%wiG2 z`*}*|U2*v}#FO=kmVI1QHMEU#Txw(bq2i31eithMpDJXWnkMEwrrK?e#X>WgGEJ)C z$8}acz&92xz199rg_keu{Ut6}_Cevl4QUpMeSn#L0BjGy#l@vZoW=N7q)dPRRk<;! zSrFZHHl5$Y9~pVu(P>RiZsm9f6+d@59~bPgYq&kNhgZ5Dcv=v&zj8Rk?<=wfHzMsY@~AtC96bC_JR3q5|F!uMens#yy{|x^ylEq~Fnf<{ZueE+sgEkvvq)puGJO=6 zC+qG2YwFwfWU}ZNt^O<7GdSGX9N^yNGct>_`AwD<;)2*Wc~)<;xbaONhM0CHIiH|I zmP_5%A!JhGrdPd3hAdXQyO*?BD~Ifv9;22UY9b@WoTbdBMxX;+BTbZ_3nWIaI=Sl1iW^^ zeXkE-5xCh=y#O3g8FsrgM7jN$v4jKVHCX8Zi z^wH~b69e4_70CG5$W|cC!VMSEe5;XpFr@}SbvG<6J-x1eiFOfOmaU&zJRfm z8s?MMR+IU7_tvU)$G+yc)X=Vss-+DrCZ?I0sPcHEdnmOkMR<5RJvhJp!Cgv2{S;Rj z-#oc-Nq^^R#{>SZJm@Jgkf$@Id~2&lq`7n6!y!VDk$Fogpy6sqdt7N3Q_%YR?6bx9 zGv==*ZL*u(y9@o~XLE9{-G2@7{)nC{yw+}RU&~S7Y-{TGrb}Lni16_h7Z*p2i6Ha^6KJ;<+xxVHXw5E$ zNBJvmdwh{*Ibtdjs6n%CRk z<-TA#&ftjZR@>5cj#4~9x=j7xQM=4nQL>rWRXoxz2(qAw`<%X#k~X!tvl+N)Hy13_ zimk1z6!n-#7y71I#pb_o5PgiCdvfvZu=r>y%f)4LU8>Z3r4iHEIE9zBM)9EHBBbIO z1PsfJu(k)Wo1U6b4fnCl{?)ZLv(ggs`@l6ig!Zcm=|09u2|YcV@A6z8tUA{7LGl6n zRvJhz#rr%{37DZ8*ek^&+VjCA%#=!-0=@ZAM{1Ly1uNfhHlk_1T~4tDhrA&-8?f^p zU3grYlytx_=CJ4D(av;hm+DoZ9=j7u0h;*io|D>C4D9HVBin27zFN3i%xe4D+1YfH z>FNVR;j*BG0{{=qd$J<~+MgoBQB>Nb(%h#iV;#i~f51L-)O2Q=y2i{%Kh?5r83?x9 zAX}eWGbv(e^C`~#T3^{vV|D4|TDvXxCDViY!$KnUj`||h?NGg4>nxq#pQZK_`AXw~ zQ(@o{8b+)`i>H{MCd&z*;xV#RXFo&v79@^wl-`tt$3-UlEuB{RFPP7yY0W?H$Ha9& zU^@`P;qkM*393};P3Lx0w9fJGA7Nc_k_9X=(8I(5WqDy7ptr~OaGxkL|8OpXlS`!= z-vCLU;a_qo;#KXzC+Zwcpk3#Z4b9k~fs+VF8vjL~hAilE!?3|@?CUw5VD9F;yuHsi zyE;1`Gct~lmt+-hV@n0m9-+kVNm+Vofp#licD=lylXNk+#9b5TpSGe%CQMvWSy|?} zy9Ov~5jtRMhLm&~oAzt1e&~JPVh>D`rUgcH9I|GVmK}; zHe*x78Nm4tNk+1#L7-x0__NwxXMD=Co3&PR=RgYPqGK-QpqcpF3L`G8P@~RJUXle@ z6B9{I$SUW0Kv&_7@B?syAxDks-yyBbeL6<3Qwb?H|%)uC#IV(+XOm!^z7 z$n0fnN7Z^!A%rb3yG?ro=BxDU6X%_AEKV{fH@#&ub8>Q)mmdkaADsuM@@7o4Bw1J} zD=Yi>5Yy_MNZXx_qJ;*iX4^`mC$ff-_+s_hfUF}J8*t#il4(_EM8#Ad@H{oVfl1A)X|oc z8d#8biq3K8@yf;7tcCq8o2h#V?x6+}nZt&dHVq69I0&SHGC4OuJd8mV9UO1gO71SptmQHrSvdD|Zc^QXLKHsn;* z!6YAseESC7X8L?M458`Y4&FPBgPx_KXQ!VuR7jNdYVI|xWS-(rVHI2~T^xMs-ahSh z@~cE9xkVu#z<8#U#ABP@iQ!?IKZ=9jPt>z_Io6r`9~c-<-WAd_c7|=G+Sy3(WYRXA zp4AL=pMo!z$4;h9y0$(06%4cL2WBpg3$ugx8E;$o_Zh@)bS6_fmXuiduO^q4mX?%b zZNF3~wS0NnD8Iciiw$bKjo|==)#I6AdgciX09QC*^sXKG&&Z+OZfrNsi&7u-9-I1<~hY|eXZpd zffoxXyPyl?x^6d(n6Bz>cx$VBa5t&Qt7s%>6zSSdZbHytS>sbGM!y3Qm_f_MV?)j^ zmdzfKX3kc#WAOQpGIY5KHel+Hi9Mp6gTu-1IaF=a*2y}4YKrBgHDq;lb=)*?v%QbO zh8xVI({}--7GAON`ZYzlz9iCAf!;nzhsb;G6&P9sG!!)2&Yf<9W`!)Of?5k=4(hxU zt$2x{IC0#-d8dEitiRp1Two+9a_JNZD4+_pE%EPiIvJ>Fni`p?b?kGzmAPEtP^`;c z<3C9dzaVs%+@xX@6cV`;9&5V&?8Cx^#f7Z6_+Dc=Bwh!>B3^B60o=_AxtUH+KVA+N z$T7Asf)m$QfJoL@CMY!FI`r&ne3Vx%2E1oRdS8 zni&$~qeHr;`kXBAH>kN4L~Oou>DSVUO7{@W=UouC$A2LEMah#4%YT@HLDo!Hfpq>+ zQ1$B7b})qgb|`b;(N`OVr8@^&g3O%fY2NE&UBL?z;gsEo{hO!^Pi^yO&%hwi@J&^( zW8z|ZlJ@lA;LE8Wdrs5#D@3GC$1w9Q~O%bB?S5|@d%A2!0Pob$3)CEg<5NrFBN zJQcT|aJU6EoXtgx%m(bPC|#yHs}U!`{ZrV}&6Kg%T5oo%W+e` zn6-}Phs6+TwH=gx((U*+l~0@c2is&A80k+>OB`N(fP19{ipo%bGvI;j50VO@V9RfX zTNr&&J*m9`m=4Nodm97Q5AzfIl1|_wC`@c5v@%(%6abK2{X7qD7t&EKYt{sX4^aWr zL5e3sPg!@`ohl4j6rF4E1?Ovoy>3MCLfv@qZiNy8t;QUr-fYlS(dA+-Vkb(UAFaU1 zJd3qLsKB)=l;Z!Gu@%hd5?3O82RAx9K+MZWczG;7=*x;>6|j2z5zD&!Y3h1pvgJ>VFV-BDg3dqVT-ub|K8cjlot?#c`i|Y?z&jieiOkcm zpVa~)h`EmW;70uvYQTQ49&cg2iDJFEoVz|G^fir9L`=Bc$+48iRUK# zEf(38=rr|VXx(c|-uHEii?eeO;YK~zCnYF;u6bu88`)X*W)}`o_hRK<@=ukGh9qHd zWhnt4&g9-NWerSK>lTh17y^D=^1%*AFFm^2z{5D4?37$%VBjK#Uy!#FzgcY^<9dYkxM2IG;^gt!on0b|IF;70ay{M% zdGi5-Cy-LTaUkcG7?9eDW+pfDo}IQ&LEH3%Ur>Jb-1(NT*!TLMXL~mKyskqrVieO= zp5x*Jig_2$$i#{8ACoJPl!%?>WhqK=&%Z#)*o`D>!sg)n;-^m!l!x5lrox6^FEoAR zt0rgpN)-E|w9)~Oqr-}eX&N-jM&Ky(rn*|BO7T4ftO6dreYEaW;S^zsq65&KbaUpK zS1|lyORuUQDItXqNsk}T_#$uy!@BEZ{>01Fx#AB3w+Zd|%R?*yQyD`N!hnF^^7U+nUOq;@Vp22h8R{<0@&x z&Iz0x3GFA#IKq6jhnbT)LVEOkwIGV&XP!P^JN)~_*5OfA>QD!X$wv-;^WH$9*0XE4&h126M>rp zTr>F}puF(e*yyBCCWqt1S|VTN@ApGh97>C$3<|Wkg#%5+X!APJ$VmO_c`yu`5CJf? z)=dA{YQh=WwYMv_$R{Y6v?@yX$zPtO*&a$$d;Q7JU28kJM{n76+{MfJZ}DoM+!tfX z&dkhQ=zc;UT~!|;Od%mE%4)M}^oIA>^^lNI)f=g)+$~D+`Ul;Uu^QVcBkdNn1-Z;;)lHb3zVy=v2a3 zQB+~9cRVLX<|B~Fv{@F_9>%VQaVhX(z+CBf+66R6U;#;g)DjvFVpV zB<9<>W_i5#r+Z+HKsfI0AJt}K-|zcsawjVEWTm7$a$+&&=jDJ}`yne@TnV>!&Eo*~ z{?NS3nx)f*$5*p+O*Ln|uezli@`mn%k;@~OEM_I!gb_GSOsxF$*X8ZQ{f`*ZC!qZW z$DT-0qkhy-<>{&&U437L{SA}lDXTkVz+)NNC!7y|&(6+v*?oL-Uo>6I_--I9+m6s{ z>*!eQys^G6SN>3BrS_}i5qaUO*_G%xU*E$5WGADek2F`ZyeyEkq+{>p$Nuz^wYjbN zy|AW6{1MQl%#_UW2}4|xpEq!)MnxJ3r|L+v6sHJ*Rg z)B`T-h>L5HN~#;(94=K^FX6SE7QMEfQy+KS>bFBeh4bl2K{v^Mi5&O5x{kXx^&`{( zhubHJr5)~?^v)}(n0Y`6gI4aJ%oTM!)Upc@%vyp0i{Z&)@y@sIlJiuw+%J3K^k-KB zA|-n2k#JnC^(Ric!UvBY-rc8C83&C~NA3qWOQkQys%rsV9ArEU{3fYmn7cB&!ihJz z?f?A#htA+5Jw1wPqz)Tw;!%kW;ehkUYVk-nVod83lLGHKlU?j9rDsyz83E3iku0?^ z8>A%r6|e7zxhvGF@Gvt-y-|ko@oBHAILPtke zofun#jF)}JE{HNr;gk2jN5|6HVkydtkKiQZvGM4bdt24nX%+N=;_lo0X!U(0Ltgk) zH_@nLZQ|LrgzJ`BssdU=mblng1oMkO{=6zvB^qfWzs@z3MXa^BzDZYq<)wNtIaxRB z-&k4pTkoF#15NwS8xG+1I)PI)=G0`G;qMv9Lhk{|9z5r~^74;_>rdHKsQPmC!O$VL8G0@d_ ztD|V){P_ z^#7g-|KQ9qP_DwJ!$1I#Oq7WnLiXT7+7IzJEa2~*{WG@>jy(I%-u?gHce&$aKWjAx z)M?2>*d9|*1Ie_Rx|nJv<;BG2_l|SH-6=`=tGE{(?3ZEW;iShe{D9v?O}cnkIXjo4 z3jru>Gb-3aoc-3HLGa*s@eaOgPq5k|W;sM6@i+0xz*AS(iv2j-OE1y|z^>k23Hlgn zzz{}EczFFFID3v|=jOFbDWx^^3>UX1gU4O1*z--0)YiGp7!>lplvvjkxWJR6Vt|YwRyb@*p z;zd{=B(dnEKeqq(qk(rkK43`|>PmY$iZEQYo=fiO@XQPoKA7K0xg$#b>LaR}NkOLE zt@5p3zuvgalQ1``E@y0t^e0ZsC!KwLbyKBcvUftC>v2Xx7*oG+Jz&43u8w2Ft$LGE zfB?XQyONSpMe^)XG$ezlLx$oJ2s;V-eB}q}O|<7wo?w$yy-%D^y#ug{QWvCW)+^as z+t4Vzt$Rjl)qP!J;5bUcZuA(6OUj}kjK5jp0WP~H@#-G3Ok#t5qBhpn$U-F2-ZAm+ z&d;AOMITdK|A1A1(tmlU2`Z2q7c$FhC~h>Cj7ImfQ)x&@{dr7u_ZR4z1; z@|@?pRaI4EDPwK2Hji2yJgtJueZVHKITZ$$mwjAx?OnE#SPJWrFRy89r?Cn7$SH7` z-5!-3l_b1ib`W1|KkKbGE=>)#unb20RET-e@#6gAOPk}+JDRsuM%EYRYjtAOMHO-$ znblpiA|X=BkX~gGU?C{^?&krPkw^ElmEuz!w6#*R%{k$b-z+=(dXiI3`Ka3nf4u5V zV%Qr5k*L1JL&a>1*$Ob4xb8cbxzL+6Z8?yoFn31ut))3g)q$NXr@K14I9<*owg$By zW{)Ef>4_Gf@wn-ND>WXnnYhA~ONu3X9X%jtEtjWJIQaZ;Vm*72{b<1` zxT0oxu^r`yk(-4rMJz)xi$3ng3F9j)X3hiP6LyVA+ zTBtTJWAM&5H4><4Bb4CneJEtCmx0K}&!+e#MBaESL2sk-Nbc9{`Y!j%x0u~eN`Q;g z%cJb6n^vXu><--v?NYIEafp0|_@zyzNTL1ZugE^bdsbZS0{r z(0vHeyq`Z>Vn|QfZYf%SUbcBjsL8$&5&}j;&PV2^P2#gzZiiZbcrM4pu4L#|S=v-z zR%doT{I>E7Qhw0`gESSYxSrY)=c1I_(GAi~#Nn>DuxMl(6`glQpKK7zL9pl?# zRrT3(X6wV*hjiCwf1qmqzKVJ9!D!aj>{C&<7rN(e?t5T9v3Xr6%Ihrz5`;yw<%nZxL zbwj%_FI@Gi_QTb<&JtDgYr0cS^l|hIh^rsYVBKnY>nlCsSKmJ+$e)`6!W_*Pf}N@<_X>tc8@Ateznl;bv!Fy!yj~qg);C zkKt?cgjuZEudt;i;^z0ZFxzG3bp&`6y3tIy0pqdM9o8PU4c*R~nxdyGfc`?^`xVzq zNlbNaZthAp?AOJdyxTD4xS_uOG|36jjkS`VZVeqnA--Y^2K|+HF3X1D_o2PfV?ERQ zpSgQNk3d#dc}esZe!E;kgkE2r=8-sL*!5Q%|Gj&qv?sMzZkK>2?y{#>pc-4@0>P1gXK~w z5!7F&nAuKCxlHkj2o`}`E>0NqY)?ijF3qxSWdvmyOXC@hv&BY`ua#yU~>2}D=d_);&!I?ApumOeyQ#Qs&aA`jCbQbD^$Y9 zLo^W;;D*}gME{`@iZ{%eMv#n74YJfnd&XcX6v}6x$lWs~JUL1H@L`$>?|TX@rL1HX zd>uvcqxO}sf&!LA5VyE~SrZko7AlqYTu>PU@r#I%G$yO-pxn9wQdLH(&^4-rN^eTE zYz`rNJzL_qV!rkty(Yi3yyv^5gb;@zcmJ`TEA-4d@5 z&L(`*H(MKv5-BY&c7Q+>ySB55o~Sr+fR{z{`rTCG+V(bLEK!Noro}<&n{~fYW9jqd zoU88_P0XG-mP61rH8lb!Dt-HBKRRm~A?GJ|gymreX$Bscx_p~%ae0gk3oM~-u70(5 zYN}Bj0Fkb|{JA0Iy)-@@yuLP)hCrXUWut4QmF6azYTD(X_z$^q5MfZ+++H~1B!i^I zG%P?lZ>Q^bkUS#k^qNK9uk)9iAGtiw-Z>BObfTm&2zXrE@T6u}+LPm;41fmB9Ym7w zUXd?t&jN0X^>1%#HHN%iQcGzJ(NCIp5Y$$k8yg#K+DBHs`u*iU580bGzje^jX@R*5sZz)7x(i84wr%!1IBkBeIcf}U z4dDB-g-0t%uwB?mJA^)07PH|Vn2@;1-q0@%8;&F~4b*myzW6^YrRX;fshk>{K;@V( z&Ko+usA+>cW5?-Zim;|2 z-+>=Tsvr$Q2*_PnL4coH!r+V6LdB*M&(g-_ z6oH->REnp2g@-J1>1QYBB3Q-aW})?0*>W{{PX6=F%7KNcf#y|?#~0@}XM?OTVWI9# z4ep$9Yi1Jf?n5N)II4tZyYjE#;Pz^#@`ixX$YkS5K_xhb)`ADgiAYzYG z&zY8v{Lo+mq*H7BOySLCc%MzX&`+w)x#%uy!s&u66iFwAclH}JO%&#;e*@XI1GdH`t92_BAdKabat1!|?HJJ9N z6pU5v(p9AKiQiuztB_39%E^hRKrZ-)C1&*AL!;{Qkp-O~XJ@a1nIgz}zM+AMWJq4n zXqY!i!2?bnzFa=d6jyT8z2I`%Z1=(w4!F|$lK_aihT}iOAHMd#(G8OASdy@@uI2gp zJfv{qR#WjpZw!1scOZCKoTNg0jd%&N%@U^4@wv=pmlbpQ&vQtgU z*~r3FFKA0<>Rmal<_4b#1F4pKQAsoy(XwP&!BxaCOpP$BajkU?UO}2 zL9k<;3D$hhb3oy81XWnqDOSHpN~)@HIIg9o&>J^|%8CboU5SHAKa+WoIn53!zpBnQ zB>-z-Z6 zI&5n2brIr~9WT_T8-%guEIX%ox!C>0zyQ_k-W^*xrhdl4lLXF2*}&?J*ZwB-3~7?R zH_>>ZhL>gro~ouA*Xg%dpX`ll-|)RfMeDcC$S zwEZQoU;U=A-He|j)i|kqGlZSaPY*s{oGL*L75B$mnjhT_N9JW^L8^_*OxWWKpUVai zY9#;~su46Dao~M8v%-vm;#>l?@&=qaoQf%h_g3h4QcV#WdV^GSC|2lPRX15)S$X)7 z7C7zY<8yNEpa<^j8Av>-)r?c8Z9*a^mOOwbBco$oPNlV_KYqE9RKm=%Zt4syA|fI+ zQ>7&Jj~Ql`nTalj*;ITbVP5)8M>2yhDTl|Ba`=^4BuNPn!dCWXnG3<47(8=F1S`m) zoa*bTImrDQJ$bq{i!cx78qP?Sa`!909-mY#5&NH(h910+NMuM$2QRv8XZc#;ZQ6;M=))gdK0b^F6I*Mt zj!OgM7ak*3WEIVXl`dc3&FQi?Y)pm_72u)G!J2`mnjX-&Y9Me#GQpOO&+~NCf>($y zMO($iMau$a0fCtN8>ebkb;+*}!-9OgHq<9^D|_7#jWr@}X^fj+Y{AN^Dy~>3>t4lx z1${#?b%I2C=L5{7#2{?Z8kFj^Z3z*$#YpPx-zooQ8Fs!F)^U3Y33RQ~Zh|_0e6yG6 z(Zs^#?x1pMm?ivDFt{U~K{wDwwC-?blASNWg@!Kb#)9JN zoGrS`$=%w|s5op$h_FhCOsWE>v1t`7oSd94`2;jRX`F6+WUSi8ZvgcFr2z>3qLiR* z@T*v(CO-EKvUYN4^&mi!Gm)l~Qdd_u2c#uqYM_7*01i2&d-)LU-S>{6G@)zz^Yin+ z`M%?B@$(ACH^37ThAx72UcDNA$!m?C1e98^xKswun53Q}CihW3t3$qyw^Xwj{Ubd* zXGlBy^!kFejSY~g(Zj*UIqJ#G#K^=07<1L^V6Fq~YQt_9SZP=1@@S^zbj=XpY@~!Q zhTFMJ^0uw4t&s~s3@xCec8ceGt5-t5v-zubbC$F@3h+nw-q5Hb(6$@_Rt_ZM>)#6wunva!&R{_B)(ivHNmF0il}2_YlR8 zGltYDtYLA!%)!9{ConY0M08+~@vM#l-NsMJa!JjLjW?4Jcinkz$Jb{s21O*}I|ICRY}Cw?9id zyIoR@tx?tXcVJ*(zWR?ViqcinU(1S~*x8b1s2rm)iFuZCnY9$9FD_oITR_|PrR@#K zp(`t2c2DSeRFstkE{z(}^9VUvqVWTAhZzO}uOCOZPn|RZ=BCXpVC)v^_d$r1P~~4{ za&tqE>YVLR8>-efIn3DT-VFqC)5FKR7Tc@ylffdhvQW;+8fQC*>G;TWzs-mFgk&2b z{%%;{;cVNX7tr|Si@71Zp5h;&&#NNtSVsDH#jN(=^Yhzsc0~Oj6kY_o@-n9IXn_Th z%K9d+`BigEBWR>^N!pk$j^4NC9#$tIWSX{#bL+$R0NDUbb3u?3-un3ngzD%T>EuQ0 zn{r5)Umpmf$pZX~;$67q$y#^2eaXV{IdfW)Lst?ReD%|Wv2E6sfutU9vfa;!>CbF% zy;s9DD_-IcJBO3g-l>6vi;UP?oQfWKb5GrW^K!6hv3);*6VtWf+|euG@Qas_-dXz@ zgNa+1s2pbRi(ht=wni!5llJJFT`sVV<@_F$O1kMqJ(2{>Reog^A6`7X3HRB(l+Mpz z1%d8RkheA;QiU0mHPxGzpM3~Q{XV|xl@MB@c$DeEd4G9`)LV21mrQ1?_-jE)`j~23 zQYDmw@gm7~M`|T9KMs+UZiC;7XPN7?2h-(Y+a4GaU@SdDac6VU(+WkSu`dfH@wwgE z%bJPPV-Iq~A0Ke!46bftVxpt3pFPW-n;RbnjC%b!$UXv zOlwH*Bj9a#1e<@hk<;f4jV)93#+Te4QOBoO^$NCWZvI6ptG@o8%;)HeW0B%AV+%}C zNE@n?R4dPK`$0!-_FV-&>R2@;1XW1LY-y$A>*Dd}fB5KO*mJfbgTuoiw(s*#Nb`Pb z*JzN45_Eb7)Mb@V%NA3P~+mPZFM~K-|P-<@`Fx1}C z^69llb=M!)FI9kTe5|R6j1BbqjiYcgy=gOpJRw;z3+|-*6_-rEnu6#?d1;+P<|A}B_vc5&wG zfeK0r^`x!#`cQXkGaBb)|o=`-ABY?<2Qqmym=3U1%2S3gp# zcIrb5xIDeA^>5>bg!D?rxXYOq5B&IUUE$%-<=_($slc8el}iT`szb%W>0fVZm1_cY z8krxwOHBBhAR(prx}jF=!EJAEZ%WF$4tx9x3TC+-mZIw6q%)EzRP75i)!n-qCzW@v zRJu`PQj#pco(|3F6l_xY>;L+kzXf&0(2-hYeA`(Kce|IQ2BVL8b@-@OL-pU&$3_g(dG zeCYpSh5sp?{{MGY`2VT!85-Ds|2_A)7Nt<15dFKwFWF00-z;Cs&5-m$6hD06iny9Xe#-2bRW_+59r zH(CMpCYYh|(2e%&i=#q%vfg(j@j_^?orr@5)$jDV3)^nL+cWHByLpKJq+sa0`T}>j zeSD#Gxu@i`_wm5SUgy->{x2^5!T0}7$$~975N|E!K<0M4Zwz#spOx5G*-WwNkWKNI z65`$SuU!B8ih)}+z)}(Az$qT(zf=rg?)}B~|J|kM;*04&M>mWmueP(;%y3HRRI4S0)RTVLNk(ea&hMg=y*bTn#j{M#iCB+c(^ zTx<;UcsLoWt)df9v0|G?=^|ooTdAC?E#e=dA(|wF>GdS#?HfEhY-FF(2GqMH?1Hzm zwp06-7f$Yx{pekZPS<9UIa03}X8E`KnTx-bCv0W0Op6caz!!!dk%?Zj{Y&OS_kX(x zQ**6RZQnRX5xM?~IxvW$TL`dZQz2c~~1d$Lc6 z;^ibYO(FBY=z)LOG5+7)4^%3ys9!y(J1}AY@}Fnzf33LrkH`%reaAnL?w}-#W%!>( uON7fSWMr3@m$jmKq!(zA|1}e?k-he4a9s)9dPHg=1FCAPls~up@IL^g>HZP` literal 17593 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 diff --git a/e2e/playback-visual.spec.ts-snapshots/play-depart-forced-colors-L2-mobile-win32.png b/e2e/playback-visual.spec.ts-snapshots/play-depart-forced-colors-L2-mobile-win32.png index 3b38ff2d5b4cab0f7a2759771e4393133ee8a71a..238cf9351788d52aa2d1ea244eab4c8f7999c4d5 100644 GIT binary patch literal 17846 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 literal 18133 zcmeIacT`hR*zSo98;FX6fPg^gy-5d=8bVPJkS<>;5y}%({2h-2WhpkaN!7``yp`Jinc&XSy2HHyCeFP*6~V zHC6Q~C@w@$P+Xk9b`f}m4a-(XL2;V`tg8INH*0I|y7`NPv-7=B6oy@{`&Q-ewrD$K zgZ8$m<4I*bv_ZlJ?Y)^gUga!0S}xH)Mkt(e#Ev-aH02fMN9A42fA^04(Fxa?Qr|F9 zZ)=)o;XQVL@zUVK2y1E2fs??tg!bGK;&1HinEClH;h_Zu1vQ32^RDCT68#>s zZ&EWOWb;1mQ`(D-&@R3H6n)cHcYkdnPIq9VsuEwnzyEeHn?DIB%Z_=_EF)ur7Y`37 z!qFtX$2#@(FlDywhYz#LsU)6I`?D}dDY)|~{2DH?edf+YZ|q}c7SR7T`5wKkeQt>$ zJmyQllCOENrEZN~zq9l9mh*ObhUA>?Ud>cGetf1&7~%c-C++vk_y1%%ZeVElGzw7ThQH&wJ(x>oSbUcJ*rV0@OOta&dm453QhLc74;>@beeAZYpa0uyd^n`yozl9JZ1$$mMWMK<8^XlLhR>DtyR zw>-O#pz#mT^E2|v;Wk81PneIdlpyHlwyT>hd$5Te92n3I=1okq=&rKc*y1C z#MBQrWCfF08E9Lj)}4(OK7Q}7VE9}XLFM?fiy1y3$-sNfg?Vjf(FLh3!YuRF=117| z^z?KEf5J5y8etxuVnP@Sg<1$c>7ST@%{F=*&H3a~MssF5`uP#(=kU;J@69Qz8h1%M z9W_?p;L5U0Zoe0_m`6T7Rbyj_pJ5%5w|1borHMip4V=jsqijkXi-N54 zLZi0#MS9t@t)4YwI_m=|48|E{xODrR5?gW-r3Ekswvs7rpykmv(ngtEJNdDsq+uwW zP^YO_XU9d*GDm^ivf*BR{d1$W?-ID0YP*oft;=z8y19zvAr%@m4GpK%Z>JqZC=`l% zWE0iR?&;|na85qDdGlr+*SF4z%Al9nd2*$Xx3`|I*5v)BJ)5^n#OT_xrLCzNr-^dh zL`5d9PxMXa=~4Unc+v9mvPL|MV30N~w{GA@NfBb*EO&Q#xh9zGPrWgW&2>ayv?)~8 zH+6gq275enmm77fa~yXh5pepaTKDC>H^yROVv37-`tN?0fg>D}<-I{b*~-b9cU#G5N*w8{&ys?Z|#C);W3x9wypTDa$DnpH6F z_wTQ^y{VS1{@YVE>&=^$Neyh|1zlZT#xbLl9Br7dnp(Kaj8CP66(j$k!co@N+fSc9 zwVmw9>FEtl9~o|lc&321B}-xsw`Y9*{#G_gMoG01*D`J6CO}}U{=!+v&08+QZ_=Iz zG(aBTr=v3;&T(VFr-i!(9uJ0`83d;#mno46NmONE7K}EL@7GorYe*pQIS=Jv+98F> zn)*D@XZnKt+KyWJp#?QmXGR%T`ugk^Vf*WYRw$dOva+(RB*bFA$whGueKc%)r+HI| zVJFc=r4&(Vfv$X6{ZyT&u28F)EmUP1JlH_hXPc#)1e>is{)|G6@Q=kgO`(umm$VHq zFFZWi-7nub&C7el5HM~mPIO0puQa(< z<~WF_M=7Ok7X?LDR-9g}gRotw^;{qL6;1Cp`BfPOo(RC%1sqP<*Kq+w*~&jnY%mK| zUw{3YBlGP^lGa00UWEx1x5%NHG0Ja$?K&;j&(2PNKfnGQ1+zm|{0uNgu9yN17C%*| zXCo^M?IG0gY0Ao_H} z$|R?)tt|s78UjlH6cyEUM%t8vZ8bWM^EC^4*$>)vGDi&z_EtBz-5F^PElS%(QYMY9 z@Ecbq7f!H-oZflxfQMSBB<7S%j@3&Te@=G-l!oaqDv`2QI+VX%TMs5YmOG);+@hk- z)yr)wMm>v0d~BJLs8ZUE>4txV-<)Z1tCHj-B_@#WP!#fmO-!bSJFd>owjx5l5s0ZQ zV7r}pAwfZludm({j0m{VqNQ(d4}-maxLZ)ntHWLT4O+;Ha; z?PlvA;Id2e0lO*XY4=~0vX%I+4P^6g*D;9OJ?PCE8XD5cd<+BvFVa)9qx z+Z`6gikYyf2KQpJBe$43*OljGMiuiR=Rs4_wzqW-b5Y`ft0~5yP%fPe38x8x(e>U( zp)pm+!a^g3Z6iwrFm3eVFQo|ARm4ba95n|aOn7bFJtx;j3Kd#wd9NAi?6fpb_w#}J zZCT=#Fo)P^B9m+6EoT3vDDHaKIX=kH;Gmu?+=hD=VU!2aH!{um>nN`LR} z^!&8Q6BEhLHs9yp_6mx*uPrXJMH8yKSdxA@o$i!XwUK~gf3_QPUQ=$lL2niKT>U9R zyzEQ~-$A3)J$>-~DyxG|)ZXkJh0NFaM_M!O3-6dRB%7nt($9Ajmo!Z;qmCxfBxH&* z%lOlanup#&Bx?9m-h-7ri$o@*tC&J4n7e=*1!4{StIQzrZ(mt4^O^T6>XT4htixI+ zxtwe_EKCUWkPbFyYN7kjZCAHqIt#Ske6j^m!{&u?sak)&Ci=^UHu2apgnXb& zDP~QA8rk{}m_D~wh8ab~BuKQ$k2FHz>?E% z7Kn??k@a@exRjpY_=mWjs}y+r_3Sbxh*!74WyYv3wonXrrOfM^)$^_V5~rtYoruCt zftZiklD_y1EhYAC9XB_(URf0A@!Au@_aG#avs32o6O=*X;1N@3>B;AeZt*1DpS^h% zxmI5%9#~A>kur}MEz;%s*`+mzEHLPuaQR^|CvVfmBd2fT!5I^y2h<_p{sSKKZ>~C3 zdeqh=G23am?(}tc?**Mi_yufoJ1`E8)rwI~yfn;cl&(-x9_{jS^hGsUB1UN0ciPa? zaftk_+S;G2+^X`4hXd8c7`Bu){yDvpy}kOw!~TkjOicS)|F%#LLqXvP96qiT;$tbd z(5w@A$SgZGH3cyNnWb-;#tfFo7Qt^~*LmR7o{(4WX3%A)*gM##`Uxqm7B{0n6)Zj< z5-D>C2h*CENBmB%m3E%?hQCu;4b|CdE;kHV@N~4~o`VCjP5~X^K`s zqZ}Tts(+F@O#jaHJ~qS@e9Gk$4tXp+xpluSwzXB%Fm@P9dKT|nX4*bMShfrbAem#& z|1AFL7(N+PXSF1aOYs|5h>^E_7{~5{Gc4JeM3owQ;}%_$sU{Q(@3~Hh+Lz4D zF|By362DH2cEcQx6M{Bq2C<|%MI~1k`!cd5o}H8P*GT>m&9!RR7H;SruvWsY-mv4( zF{9A_OtY((>-G+Ax}L40sk)CdsLp#;rVblbwbV9h~)^v#{%^MbIL{N-JUcWTKt~p*5841zCi$bB4l-TFEm6es#vy)vh zwvyz8gh%{+BS;7{GqaTIY@-vla^LtY-y$S2U1Js)%o>MbcBrz$8b{P44xNVCx6#`6 z_VzPO4#v?r8t13$O8ot4Y7!nxKMl)_FpX#&(e?n5`%K^59Ms|Xoo(B!xe<54Mn1lz z@J%DKuo2J!Hd_l=tP}_YQczIfACQC-g3M1ckkG!M)y zgkxo9o~tmEN;kmOn)R|9!UZ~4d($#mmY$7@+m6ZxpEgb;XSKV(PgLbiEN#67Hgt7$ zMQJ@??Frso!6XTq)?TBgHa!2+RhvZtP(1GCcj!Q zcg7}3jRLx{KT~Q`e6?YHbrn!yNw&Uh?7NFP8t| z1&nD_16%mluV43;yCAZEy4Etzyzw_s2JkE1UCch+XIkfXh$_HJs}9+{#qgWv1{7Pd zE(zD@v@;z~HPvx;aY2rStY`bIxP*{!MI(+iv+{TGYaztdG(Z*-wr7k}*O&Mdi7T|w zgu_j&o?-!O@G%pjwD6%B)!>Rrvc>LfS_6Z)*QNFKqVEaCaA=&%)6~cY{eVD2#PyxCho8IN`Gv* zke)!*{52*+GHD_m6nA>xKMPC=z6*xKcIPc9C4o-2RB7}W3%(2qsR=y23r4j2{)6OKiP7MhH{WGUX|O_UOrUlqd@^TkfnAx6k?bsYl-9_3z1Q znvlKlU_Fqrl%9P@-OMJKkyYPRYx#-eX>Q1wiEKTx>^bc8papGpsni&Ru^K5ggdh(^{q|;Q-WO@5n27&fBaPJrW>T)^m^=Ex< zSD*)Kmr7jm##UO``nFBmYZ*Mxf7a=PG~|!90xT8YAH30z;&x}ahdItK%KEgOEzL#j zi;3yqVxiieJ0WSXQlHN(*mR}(iIgN4G?!9cV&LkoT2> z^V@!8q^7!&KY-pS5emLN#iW_;cv>0Lj~RN4WKFF9teRujcsb*qw3rcRHK(8eQQ0q2 z%&3%MI#hqNXGcx3ojb?(X$ZM=WtWWk9rj*cr$?3uDGNe{#f_PZjj-vc@m?rpaS!8q z%J|w|H%mluR@!H0EJvZ;_ju8pH7NP@+tDsXpLwEX;Hkkvkk4lO`5f#UMZd`5?#dV* zz7qvvN`x9ml#ie?)2^EgBJUKHR9Gg0GK*s*AU8AzySo!PfI)Y`B`2n%vLU@{8(cW| z@m$_*4(KnPbTVz zYAzP|xNRahd3KeX!&ZK+`haue6emqiq9F^ZV*>gEn%v2ek&!K&3~hkbh& zAkE(p0h){s`UPNnSe-C(y^q3{6WD0QFHlt{{&z$oHlOuisu7`lmjf10bNh9zGxYp;$O{mh<8Zd4|Xvxn; zv>GzrUE%HN4?;rH#3MQ+x6SIa-bm-@romP;`NiiH)PxfO6O>=c$`Y5Oc@UJVrUtb7 z32e@O*?=npXe3RNkObrzxti#IvNZHvp-ILB=+RJhXn$_^m$#>)IDdi{V3yHzaqqvo z*nX}*G+bXw8Q2KW1TjvW<%TFWyUaudogg0hO9@r}`gPVb2!pr|sC479D^^%OusWmrVn57^Xba(Lt{J~#m&dhwTjC=R*-@8wjoMtxC&(_yF z*D<5?weF{vLKWu+$}g<w3EnDm$Fvn7fp=s)eJSzARI&npruVxKTI^G{^ zCB6b?9X2CpT1dgO?FMy7TLE2=3boJ?0g4M&G&Y?kFI6|r&N$l1!F1B|+MH?~LD$1TtVqS#Ybv|kNX z6nMlS~B0D|%pDnYZyf=-=(;Z~efp|`o`p1`-8KTl|<^608@EjTKGH+>#(N>x( zeZ5tpqZ;N#iXraX+A|`fr7Zc<JLEfR9M6`FJqG!1*RUsRuR3kmJ4rdg#{>B;Yf zH8eEXBn8%9qNI8hKlUOaTae+N*B<3;xc#6aD`TnNNklk9?!-vfdMx=wu=YM|itwGb zB;Ll?*dVkGo5uCAMU-snZ~VH~QQmL_a5pq!898r4^f3biLUM9uZ9wC(;JX})fIHgF z271mOZih$Ih}zlPNc)*fueglb=Mud)Ky0=Px4AX>e@%LqX|h(HQU3G$?FU)XQ@J!% zRe$xr>1(rINn&hKiSEnD+SEI0P>T5HRgh$M5R<-Z^{w)%C>|PQ>i0>aCKLAXf9K|4iBcs$`bBDc4KV7Gj z1OMjL{t$~}Mjv(1CipU*{N1NXRKrpGr4BCdNTWCB{^c)NRVJG7@DSV1bfP zjvy>JELT!g_)Yqr6NXof16d3UFF8u!vOYTk*&1#wd!2wf?~G+C60Xb2%KH8L6{g@m zzmbTYn~)g6(!($>EKFA=!q=)r`us#C;-P7SE?3KRZTjDFoQ1db$Q@ zq30ft-ShftJ1Ads*g2HT4hJqzUocZMCxR?@{#AFLVn!7$2q| z)Y=ZfT3pDM3y6sD50A*OEbc>NW_ydFH)v?mZY5l~N%#4&vJHRI4nicU`gx55h`Ir= zGrcy(z|##tvsgKnIy^iK^m-$4y?uSBKo=jAMybL1{G7bo+2k3&JLB_M@?Zxy8fnqO z(HjS}qw$hXh~+5-k*{;_E(CkeMbgE7-U~!|x1Zv-?Q>hj-T>~J7A-_Qf52p7EDS5r@v zNSe9rCo^tGXU7GW_*z!!uZEG3#x41bTMF<`H*Y9@magvZ@Bf8uXn#Mouj!yRwmasSIt=z{4dJFwB`I? z$bxJ6VQYQ`rSQ^&#gBKc2A?(U4}HmYYlaRT@4*}w=oI)lk%+u+5Atvt@s+{nLDw_H zyW)GfB?TWF<%T#P?J!6!2m~qUA9yjyj`@Ng{r<$)(<|<(Z0yioyr%)5Y<3u48&pzi z^Vx0ra4|?1Y`wVVw-3za>)UfPt9&e+k348=Jl-h7>Q9p*rR0eSMI}Ij$Q;dj@(Jk{ zqN!NV`h?s6>>Ip!V>>(d{TDJkI=U=(q1)JM{GECQSQReBtV(ZClW?QT z3jQswjr7HK4#!(0lGD@~=M_+CjMT;CYmmt%gIt)Gr?_R<&;wDR^rh0#(`1XS{@8q| zbhfF%iW}zWFGd5|R<5wxkT^ZfyobBz2}s-^H#K6?N4UM*NBpYy?)(Iz|4K0?Mk-2d zJdC{rzLmrc$TKcwjyciJJ3P3L>7s#+irZV;+Q!WQypPn%<{L>`t1=>Fx^ImAf zk9Q8}q6Nf0aTk0;l?iqCzz^RiU(}I;JF-9LtkZ`+Ppr$fn2Am^gFEspE~(wrDFgYL zj+~LyRYtkIG?^x5Ol~5!$(|c8Jb>{=iAAxLRM;d9 zukXHWE?v&x{tqwU1*zBe$M9V1(RKs+>gC&D3ZdMsb{Df+_e1;$>yCNbsf~7w;@r^= znk>v>gIJHxe)Z{-hZd=W6G&J@X~>>~F^%j-`5+q^SLjRR80zd(ikGB2vDB z{OvtSr3?sxohV*$IoS`K`c2o)#>DKeuC}_Ow@Y^0M%Oij%u>HP8K+ryG*dB~_Qo4< zUT6&5R66rr&{L9Zs7}Z450bKG4T|t$;&Z2`r?TFR(y;Qq^gBoM{O22i%B3KRwB-|H zEAnop+s0^RTN`VRd`bEx!@KWfw=A08cELG;eTaT_Z2;3hZePDM+9fRCvj5dOlqH}~ zO5R+Y7I=Rhrnmd^pH)s;BO@c%3l~_kq`z2zRqwvLxdfyWfFH0ZXoiBr?T|HVOn1}>|`=kDOjYG zu-jB3dmVkc-*vR(LVIU#cDjz#H@6U+`S$Pcyz8{)W_2U?R8lTWm}ai^n~_$M#H?NH z*Z=IeW-YA8$*trTf=WSoi3$1++vO%2kKKZmM2DsKqcvEW#i07Hu$39%kKpe*f2Y5D zSDt(?{i_cw7Gg515k94)0Om=1P0j4aBrryVBULm1m86et`la(oJ zcj=u0qI3nH%B3+DOyPt}$DBe-1(n3_o|%2}(M+0v49dj}0HJ_^W?1jd3w9sOF)c8X zlWx{x0PmefHHz6Tky=r<^M5RL~WNs{p zd&T=d_^C%`ash6vA;h#SF9Eil1WyPS0B{%ur9aOU4l2&bhn)S9n*Za!-%rZQEYyZF z-5Fl5MAsG~3TH+1tn`C}E1f%8aw-HQ;~T5Ut%tir;=3+NLmGc&ZS!iiI?+E~su z(w0*pz=~fXu6E8HtpDaUojm?yOjM@>`AI=sVgK)ynU4Jxw>A@0eKV`1_ z`yO{=TUuI_Ln%$jQve)OM!phoxCOvB55?_bVE6reeS5LBV<76{@eBrW%k6oxsGQt# z*3f)w(azMqN$?o}6NQ~`yO|mop3f#!%sim?223^K=iaL3=eBq&tr1ZvA3Y6j`{f3} z=-+KzzJ<9%wl?l<68Vrw1#WKkD+F;K6R(&^x>a5n7qu*J4v+5L1Bi~DxdUSCnQy+V zg}$q~&zv_5216o|09Jsi-1iPA6uls)o^tu&44}L~__+8zghXLl=O_s{{8m<0{qQT1 zwR7#lVuFHGSYgd&2MPyMW!T}y(LCC2w$W&N8s{l%?vovzyHHWp^15YTp%yY2t3ic` z_1>DSZf0^_jS%h42=>r=`xHViMQ?o6Y^gre;s-<=*{jrGYDwFk>c7v- z%m5MMB62NPz4moJ}o^t+7L zLTBiNBnCo&6}$&YP0%p3fVqxYaE{~WMvL^LKivRkv^lixa*yYrLXi|uqMW3Fzzm4- zpgmVEUly=vCj9;$3iy6GE%->*;KoeqFh8#Z=|mb(fI8-*y1dWM1)%F7%{_kKpvS~Po!4#Y=C_xAS-^7ALL_jY!wJ2?RHBEWFi z<;biqnutm~5s`F78ImY*?sVyiv?6<>jpbcMgMd8A|=i&;LMoD=x?K zpc$tH+|SDWU?7#U?udX!2!U`UK0ZFUUA9fqJsqIAjlc?^8X5qjxuL#(0D}Q)B<$_m z5ys@x_jvmpX_g`@mM0x4wXc8OC`q$5Cj6qe5-_dRB(d%bvDhC21fE6d?my4E(AG=c z^a!;%jk1lF2ZGDhe~<(~-B_Su7!_gRDj<#K;^y96XtxsIdSsi+jc)FUr0MMd%mCbI z@_qdrfQMn^5{}~mHt=tx~`A8|=G&Vbg z!#(#&0SWVEuOlOz$T{tQ-22*=@_&5&yJJcQdVU;`^#$QE+_}LdsGsun41i{oaGN)W zYevi?#7_Wo+}+zdB}LyqW5vxE0FVI2mD3tTUeZ%I7)#{PsEq)i$B@&>Le!Y`OZtp;Bb3A0E;Yo=@ z21B1bd9(;1e6Y zy|H%sbv=|YAjqAt&rG!E%KiwfguVTn6B!Sb&R<2Hf;#S7ZUmwPv`rBBJ~yh3_OMKW zRzU&gXz$&PA6(^8Q5NEU&op2D%)9OPfKGqez%15+*3-FP4nOwXfZJ>6mWEMfk_1C$ zk|~%{PhxBph24Pk#NG@7-}My}1^81_%NN}g^Enn~7ei+v!s-FX7hs1`HUS5%0{{zt z$bu~Gtr_5){Rx1cMaET@;~~O=8(rN$KV8QHy7fnlv6r5ign*6tw4CVkq>hc?w_9@7 zi^Ac8{HJ?4e8yow#rZQ;WAE%-9${+HX&S`PtPUis#SzyPB!4E(eC-Z^ z*{HSAE}9a20?iM@n5&~BnvBS;Zf(Ru0#6W7)YJOawjW zesA2nnImP+V{Cc~a5igKX55D1&KzVvevww(}JSy>5} z1n?UouxQ#z8~i1OtH|Kfy)Gb_Vx;8*P?KO)mbU!?xz4gSUdUOX$u%4f18gDX&d!b- zy6~X3z!!K6@})A*>niH*sG6GHrgawb@8%5W44JZ}@&uC(6?;SZTBh~_2UkH2nZjS{ zg-)g<2sy|~On&F@V)e~mN$+Fo{#7r0lP2FVtZWpxk%K{M;t*2X?~J(4Ux~8WdU~|K zUxxFp`}F2Yd`hky{exNcvT=YI<@B(5eR{lCq?--bBLR7+6p$LJjswOd=t^>5mbiV~ z<)MOjMLy`~tSkXP&~lr;4CHE&-E|wb?tIP1<{Bl1Pr-DB9-8?BoK*co(IDHpv0nTE zFnW>-{gVRdAvf8E!-C55kj!1ZSeZ*xR5syT*s5H@a2&Iok&1;7w4x0$+l|2x0U*Wp zseXy2h8tvTJ7)GbZi=t`4YPg1L#5zTfTN0yi#r3Ph|SZBh9m2tG>X1JKn~zFhk(JY z6)xb?JT&wST>RLDZ@&1Ra}t*h381p){WmMe4x*1Uz5B!O zZV1Z>y3CW0eA*5s!p#~KPycoRR@!{MOJ-3~5$ujg?@#~lRIdm_=0 z9_qxDl)5)vaqll(o!ETZz{?wT*L&o}@CG}&@$cWi03P(StE;z77msXglok@w6*qrB z)7iNOkmx8U$C>&HAOJq&&}Rpf;dAka0^BYqlu8HfiZ;(R}`GAN{1? zo%3BnLV~#cK(|J|p}FdWp;C*1t-h z!IZ%!>-u=ssb&YR3g-k!*T^Ehn~}|O{ww0CI}}i~!pT<62u9F^11)D#_8~MB=>R|5 z0=6)}jOgg-dm1BXkb({n0wm_b$8{&z!2Ho4K_>T5Pi(4>uZEebniM6^@f($&94{Yt zcY~GAiWl|_*?}V^Gc$+n?n=TYvD5M4q*~V)E6ZZETZZKQGb9=N&=S)zrWb6o@NHBkD=tgQu9aRu6#L(c^<$cp@w7 zw*E=exQ}x+LP%J67ZA0@6^k&o4R}0rbo};kKweEvv=I$l62P3Y6@!{R;y{T63vjr= zuD*YpecxxcH|~J)P3lXu%l2aJmgVNGB?t!tM*kv?xLT6BoQq zNJaQ;;vJ(Y1F%veVH`5puo72Y_!p0d-!(mq)PEu=HJhT@W~;p3kGVL!3xE-<<%t@S z3H6hMqlHbxllW{YyhN@@YMWpR=dCop7orY{Rl5o(8IIM>(LXRHqG zmL_Lp{Ip0ZojxV5pQlS#*A_25d1M?CvV7`Pxg+@0BgMkf^$+L*OW@z{vZZ%R+Oo1t zC)RT`n+XIjTz=}A8nk%R(?%ubgC<_jpS7l@2I&gd*Zjn>tfK*f6P)3g6phfgM>gRYyDIkFovd>E@Z$CxQ)umM;kSiHbYs zpNcV!TeAtnhX6UJn<2p~CDl+Lk?EBzJrU@ZYk z(@d3>60SM*8zXRJNrN@W3s5jUv^FbxjQ((~)_@}x35SGV7NHn-W6q)#+Uq)pm0Eyf z2RYlL`DgIlVwHcXG79r+V!Qq|;K-HXO$y77qxvkNSS~4%g|^k$G}LlF&7IYS_#ME( zx}TZ8ew~jCjLIBTJSh)4`2?Zub_?2RS_k$(yHnSHll%PClUzEJfs_f_u+b(cnmiMm zpSl+@Zv-4VsON;_SfM8r{`^?I(4HZt=KnFq$69riVEk5PTEEP=Ds@p96T86y{*+Oy zFw--bcC-^fwsn33oQ(p2h)J|hRV@v&l}lj?xJ{8(-prO@eSLWC904+}X>Du5JX2`n zL2h&M6fd)z3$QT21yjZL)oxbG;b+{$tT%s)oRwAi1Mb`fdh)?+LK_GZ+mndUu=qdG zhwWCKXW%!jWg~#s;|!}QO!b0yjT^ssahs`Cn425z6n_Rp<&G>@MKE35Sogh09zcK??~CK47GV`%Q$M(R zL31D*qp3v4YG7*0Zt(}dVdN!f$d;?@@!T2=u8ys+$gF3cn{6d9J(TbT**;WiTO8Fx z-(qEX?2w?iK8oHQ0PF2a`V$h0awjM}#oxG&`4%G*u>Jp)2{3+^U~CzzYoO+5;wSEO zs>zF0^D z&7?S6z&$PJOU<9@#18;{c~8LRq|uk&-i-NUbEj+9(LPM^76QCH3BT@*lo=nHxiXyo zq-Au!pUx>&+FpuK9QB0-gvN8?++NMpyO1ojNr&4^*3!6^?*`zIOP%t~j=S6^D;VKi zUv}57xY^MnW^)TAOwY)>2)oqT;%co@i7%24BABXqpkJPE4PfX^xWOp3zsM* zUV#ira zwa|X~6=h!UjK#0+B_?vJsd*0MjN8v2uOzvioi()XMNmpumukQz`uz{Lc;C2Ww6=Px zcmD9(M>)Fy&QAqRgUQmtNSuaR5NshR!+NYw@Xorqv;Nby;vB4fY=d7KKb&e^}QL1-|S5)D4 zjXb*%)J$z5i{bFeNj((mAv5!M9|yzdqs__T$@^sAXgXlw3kC&FQ$G-a&C8P=RVf^; z84?k4Ny#h0)yRSuEI}g9FHhr`0bEv0fz&VM7PNXlD0sHs1=!@&FFkWh9d51Izvleg z>3y#>IcK+B>3FjW{QNl~jEY!oDN z1j)f#Jf|hLwY22?3FaQvA?pLAj;R6-$f!2g0F&;nh~jp3frcc-+AA=%6i0~>m%o2% zt{ir@v#_f>(XQQwJe@8c61<)bO!&dHG@u-v4pK7_i2CeckasI_O$aciC@LwU%i#~r zE$r=W`kK~rR5r1!&A!PsAI}c3F41&EAKcx^y0XiWxOkf!>BMw6JO$3qYuCcP6d~hz zcJ%#f69dC9Kfkuc#q*;sR?=zAa@W52e~XCVlo@+!{&TR*(@_d@Q92XObXk`Bwk-$y z_B4tQn!HLBUDj75q@;{oseI|p{!Olk<}iRVPU6|5Cxr&}cORfFp#R)5V)d5BIrFoggkJ@IPZ z?f*Cg&IYI83DdmzzhC%ohvNSieI8jZJj)aml&1f_FM#64|DqT9zc1{6&SI*r#4!QU za0cVRzi)YPU@Uhc=G-vJ(-phec*CLbKSy(zf7b+p24jHWlcXq?0>bG3>*On!+-Cqp zJ+1>F$6jPNK}Wf$DIA}q0Q88Fg^SDL;r^zmpt$nt^fPQ$Puj)m6DP|Nn!llB@xkm0GFW9p`@1j@2>gZcVGNxR}gUD zC45CM0FU&wC?p=3Y-*M3Y&yMOptmpNB zrk0LQo!Hm=a3cJbF3uxXn$cEQULGo*Nl)iId4> z!$xgRiWK0MGo;)s_dZQbq!9!uM8xdXfgaF#wsE+(cN5-rg+kp(QnC=SsgXEyVQBVJ z#x3<(3Z9>RiOE@6feZ^5DY9iacsV#YL`44frHlW!|8N8YgQNDG$nC(p*UI4pxlt04 zXsGaFWMRQ-d7_SD;|K7R!$p=T|I=uarVD-ZDtiJ&C5n+F%1P#8BkBK%2m1eEP6~ex pd?(HMc_OZ31^9vT-!5~BV&{6zr73(5Kky0#SWQ>8^69G&{{=gMrG)?h diff --git a/e2e/playback-visual.spec.ts-snapshots/play-depart-light-L2-mobile-win32.png b/e2e/playback-visual.spec.ts-snapshots/play-depart-light-L2-mobile-win32.png index 72681ae7f5dada271f32b9aab6f97bff181e0559..00f49a6846eca9d37e5068012fd50bb0155b2d7a 100644 GIT binary patch literal 18939 zcmeIaWmH_xy6)RaNJt>K1rKgPgS)$H@C0{<4$_d20KtR1OVbb_xI+j|Bf;I>p^>J6 zhTH$O&suBTbN1O|uf4~(cicP9*V$0B=37;7z4bi5Im0#86|gYKFaQ7mmXe~ZHURJ_ z6aaX<{QNQM3e5~n9RPp}P?D9>{g`>Ug#Mm^z{8+zLDtboY7u6^$A9 zbri0Ao;Bg}c=G*1qMBimb>dRPMiF~UBF_RY|BC{e$Zq_e*}=hRRa9NeS{9EfzHm( z(rb_s5)*IxMkzi{6}!I33yN-A@@eUDsS(QJ-zEsB4Ude#SBMOMGn9kZP@t2HTol6- zH9V%=2SmN{JUH5OurKu#KB}dsm42P9IJaW5H&buO9zd*yr2P`4`wB)~ z4PMZ!ay8KFp^8{urs_KVgG=(;&iU}cMgg?+dHO15Y)tFHF|t;?X;0EO5#Ax%QRd@Q zt3zdNMUp&ZUJ<47qC%ARH?vkiu)}X7{0Ewa1+oQW@-1$6NcxY|cTY)R&(JBIhq1(# zDA(53SiOto6G*A>I`df2-$wZ8MfT`6xf*2BayPh9kqj|L(mVX1ob6B`%}F%Y{Qa4v ztY>jt*OC7c6D@8pt!ZGuVT4lY$J2>CY*3cjj3o~i)un;HqV=4TQja~UG_X+mblnjr z%z@3nS%l<`soDZP$xwg-s!InBR~gT4cG%zH(fs3gy|GYCKk~mAps8{MwzG>18Zi$2xF3X$r zmQ+j+0vjxldf60htk&yTM*OmGIV51-S6)Z#SanmkCB7MHNt|(mO=;>y_1VWcV(=N; zhaIr}*$W-w_RA!a!;!p563(^P&(G@6$=s_K%J*f7NbUlFuID)u9~#D<)1oIl$5c_) zPhl74<70t#Te`*f3WgUd$n*H|*#2cax=bR;<`%jSF5 z$*yuGXfYK78OAQpuAe^^t6nps7#r>HIzJ7M=sgI01$mAopmyP);|Vo| z?qND3AMZm9D6r8<9@Bj>$PDeZp1PEK_<4APK5UPlYum6VmW6c@)}J`21gI4Ykz@0x4Gqb2U zG5KyG&vl}3rSZe2tz-A1 zC|i7};}K^Od#gboA?{96@aKC_x!HlQ-^9b+xAu}XpOHD+EZ<(%K$@cQdl==(o&QFt z@yXo|bX3PQoLJwn4hn+_z8|NDOhQ~EiB&9#Ho|z`5FZr7#aF4p#ihp`@ENJGxbCL` zjGr!|c;_!3E^mxUDqDD1Sy|cGHiBGQ?+`JXwrZ;o9TKXtqz+dKs=aO?cYoMUS z_Vk;G1I~r`0;cUitYwX%2TUc_tU_tx?mUSE;?eyy1E7Gf7k7-jq$J4pU9dK5*T%nshFZc`{%KS~J=cj^sc~$E}dI3p~()DJ1aCi@_G3IRCtp-y+ z+}($Xs$_pW{BZwSli9?-GyRt8ddX5lG$AqZ4G`PefW+i#LGtg`t+si zf%lQvlBs?f-H%6};!FkR0-LoQs4O3;JX zR`*kqM>ZIc!=#KL_bVCr!)0%d4SmeYyOcL(7Xx7@ew=fs#aM3M2<7CuL<~ic;RahC3`91izOW|NL@&bzs2mcMvJI3`u;&W||vU zGD%wVCKnk&Pi8LCyz`Bi%GAToZubbtKa!>+*ie|74YLhYko{WFd?fXS&U1(JXz$q0 zv7)jJk~nAt*~uV=RX(#i*Y07-xjh>Vgr79{4R1@=F^X`{af{vu$H$G0!Fv7jB!9XL zRKHbGZc6)Q5OWjNMjg}`x9@`?celBP{_KXo9XUVe$f3==AyZl*i)e9DzlxVOsGIjR zQUaM4b-6_55ZOBUpSwqsKYTv&S!jnj8w$yV4?ym7(0+mgPNF5+({A2G>=4>m+Z@kc zdIN!@Ao!y17SVywHOD+T#8rD?nOB%6Uz5>Ba}-!B_|xCW(pDkbY(b1`m4Zs5CrO(T zs;17N1pStpxBo0hp%!v?IjV67oA&SOLLNB>>{p??+znLPZGQEc#8qO@SeQPawcysH zW*+TlRn;zXuYrDzn_MhDuoLT7z!#vz!cxPzKORF8C8>jTvP$Pqp&6rMJN`5aZDK`h zd3je?y%pFqT!z}!jAC>OxO@AEY4Rw@fbvPaek4M2t6j!>>OzpSqr~bsAl!glVP;OG z;%?KXRrKh6S6A0UePFRZn1oQxvNZfghL2qh%kb&>#FWy#z)G+SGOFNcHxHKWR9ZHt zvC!2j$7XB(WV+G_AD!vd)GuS>{ae2lT3R&7ew3~nV+X|7;ZZFTbh^A?zh{VeT0!h? z>a6FeGJ$a9OuF9peSu0jbdy6t2bU?M;_%;_geT5JjYo4G9VTO9Lof0Zwvn4xY|8Bm zFXkqkXKT-**(z?dtgNhlQtUGaJXJ7l7e{dSG6k*_y8f7L&`F2`3-d_xWULk|spc{o z2Y%Sg9PhcvEwB4Avc#qGRO3m0Uc;^(%h4v&xpLNqY}ohxFGMmjU$PnQ zxI^!MlbAGSx9@h|W-J$n=(H1otepL8ByrOc^Ve+XR7HQ|roAb)T8>}NRxjnIEEUV0 zsJF4&?(1bKsqiW}UVTbpwrwidk!z=bh`AI2ihtZDg)!%~V6qOqO~~ANShDVFRo_Qk ztpRfaZTA`#6tc%9ERS=3KLD>4{KPKdV9of`xdF@FcOlJBOtS~%u+ zI^@*Door4V-L>WlC!ft?7IcvRF&dIt+5)829l zmX~}J*<&t`DO_lHAt1ic+jg83QPF-B_W|66*zPccU0#JBQ2SXRC8r+5?#5knB5$Es zPZfxsv!xmP6mo{2z`T(P_9JS2appHAZ~NMcXEcSRSZBW1^DDZSygaXb$sUifVs;lES z^XP0&+h4|(Xb;@thRc3ak9%t!?~7>>0i9et-tNV&c)it9`3I(9-LR3)S1upOClf!i384G!nT{_v>PzyYpgk1642sMiw*f5= zZD|3|*omkN=jFXe5U;Bnp1o2`yPCJ>X$2)ei+CnjT9h|&&v2$2S$-$~BqnDYidvhzT zaAAzs`*x|Vl{D`@jwkPnAd&JXipnA<5hryWH?S@oY|qEO$MYhU161%fWUrZ?gAwi) z-T>ztDj@!^+8FMDbB#*JA~m_9k#$CEeq(H^A9?5kcf0d!9LAiD-EtI zp9f2m*fgvIrk)DMryHgF9NpjCrkD;q#SW47(1r#3TE_+VmvKA<(vz3l%`2PvpPNSy zt4ZAE~e9-G7b`F-b^fj}6bDkFsIA`H4wV`4JH!v9n1W*wPnKGxM;2ucUsp zg34|OE_Xr8dWu&s0}a;anL@?D;d zPS94nDVDcht%&sw@i2>_qAy{ftlN=&-_)};8Yo>*uuiw%NP1cP;W~1!vsSL zQWJN#ia+`6Hqq>)l(wkY8gb6f&CRV#9#5GzpEonRu;fS>*#m*FUX5N|l_gu%%-vs( z92^~0=qO^qV4Q~j+*`rG$n6jVb5{0jaw3)hi?ecej|IBh=TU~il zsALLz+2PmfziR=8e$%CIhl$T4E88>E^My4{8So-!0$hh~~hsZO6*{54FrU8AyM&Qr60j8>3Oq z*C_*)jTatz)n(KL4Gr*c3DLO)+czAUG96EXx*hH8qYQde2#4nk?=Rz@M%v@-9h{s8 z2(zAlVXK%fEVUmfolqZz4ivfi`fn`I$&6ei%93%671}Z}IL5@};m!>U!kLPaiPKjTvyt=*4&maHpmADx2rnF?@)!;{JVy$_}SEV$_FIoAV_mXuPqwk zC`miZ$efJKx^%~;07eN&* zS{w<;^XQrOQ2GZoGa=bS%v%4jBbSz)2T;RA|HB zGh*9|Oc)qc17_}UgUu%le`vW__hwNVs0(wDxVa#xt0WzvP-j}%9~v2&2JVK$g&BXl zFu0o<6%eF|>FBr%a5iNJsm{w{PwR3TH_8?$Ex@*RE<_5OZCBv|&8Gf7&i4W5mlBN? zg^i7+H}7ry{6NlnmX^ri_54DsI^`^>rOai}tjVsL`Zk1NtT za{*LdL1C25I6X5z!b`(gS$ZvfVQdV~NU1jPW!4dDmxtKT-3L|nSom&SHe4N5uI7xe zWvP5|@tWL8-7%maTfRPl^Vc^tOjx!oneF$P5bKvs{jq*m`<})bb7Qj?O8ljVcV zzM3JN)D26V>d(*rTI=ZhCm!Bwsz+YkT4S?#gfI5Ak&e!5`N)Lhx``vVF1?)iEvV#ccNbWA_Gp@W zWa?9rqSa)AnzgVJhlV z_o&qt1cKw|0qWI;nsp;P8sL-sSkJ8+Hf5FVah?6q zn}x{HI>)*fXDcI;HrzPxOu!a1S~-)|J#6*IiyZiJJ66`C)mf~sMNRE%NBV3o7Z)a% z;ZZ(Xz`)^7P@)L$@%8|nG#}l~I<9Hmr$;|X} zFVNN8?HDoHhTJ2`KpY=ck0Z8~IK5)xpcrE-y;QQce-}A1n-wh{=?%SnrOU7%@ zkU+$-b346W2**25ac|d-vy+Bh*MK#IqnIx*9>yOWapa$gHZ?V_(m7%xLh<`BCTnfO zz>0%yiVIKsGo7G-(EH4>v3w{;)6kBw*3F-e$>&=++MaI&n09`3J}EP(SYO=vK<(sc z=V)L2bGpzP!e>~EcX(4Q$`T&qkN6`Z)ohjkVdy8lB zJb&4)tjD4*U0z;ZXDW;{vS_fnsv)dY`hp>SWV_Ei&7=y*H1$$tn-l&(F7a>&6*+02 zvEnH=1NmuvpgCGys6P_a^ycE{-%bNlnO9r6|IB6`z)`HhdYR zGG@P4_LTR2x0y-1^TIDqze4m99(#8@)|Z{@l+PJR6YaR-zEDa0hS@TRF7s!y?9noE z%frSdc{9mAP&QScRm?Ik^E)x3#2s_=wT&U{` zx!}&VyDN58vv!BYy+2P=pZ&CH3$)a5gwnJOlToq{^s{O*n>L(f?7npRs`V$2F}TOc zBf8Ici1*b=lLfyry?!r~)@bc(*n(rm;|v9>+>Y4ZjcSh_iiKnu(88pA0Vl`owM*lf zzpk=_e9(%3muf#}W?IHRS7iNIa>*7f6 zulB?cjpGGdk!_fK-Xb%vHng2{c0%%gU=P>FII>Bvbqbd)ovEMJ1rcng|9H!)%#4AF zv$QnCyDBU|D^A0pmq1FyfPr}wDIJV?DG$H$Y3ki5uQ{* z*=2_d%yr0K$)^5D?rt*fM=4EYa{0;SOD=hI;S(Ru+j%7>HN)`gY$4Q-NJ6+`Jo;?v z8U{4s}VSP-Hr{%1x+m zb!a*ZZ8*^DB{!8ScIjEDJFV6yu*Ip<*7JgdaDM(8`(HMma-*xcHMzT|*PK}6a4+(b z$^-|Qn!OmxY=S3{mA1O381droH*Sy(9^U5UyNJTvRVAf;o}Z~|7m|#zaZnrqemvSv z3ooy)`C)$S8wA)I*fQL@o^I4DxCJx_%~yymHsuQAB`mBf_~q;m{rv%~IPt|f0#p@< zqeN#xd@iY79m-QuQxNUPjj%5ldXF!mlD5MOhb@_pHNKLIJ*fV?NZKetN!Q!@GeP!F zYh?-T#YW|f2%i7+VL1H_6jbZ4Amg7QsIN3-Qlx8Cs)XbD?z{5KaeL#N+o(z60)SDo z1(8Gon)8`@m?adattFep}5bAP|0 zLnO^7Mgfj+8?MxA#&1&=%IfaJDgy#iR_Y-RQ>vv%{Z6E>0}jH!LuNN`e%U{65ZO8CkvvAE>R9YjK6FvrrFjZoPNI z*L3`XeSR5+y}3D*7gtcBUM|Zjfv5J}(7SZ=)*reu+g=VSd&{c*9S?ksK#&ZN4oOqF zANLvn4Z^xyfPzjI;k;sWDdr>Qq|}t`#TR*7C5N9hvJ(o^$sHj?)OdJnLJf?oVRxwJ z=tuo?c_(4t{Csb2{V1_Tu>8k9>Komx-4+;?Cu5stFbf;eUvx;uwx87DO*sE~a-ba}qnetxn+|q@6!V5LIv?N^i?uzc>$Hq`QSXEl{ z@N$3u^Jw!%7YC2cLn#N_I^G!^Ea9v#>fy3Rg0|uvna^5x1xU0nWW!62>EdYp-X~** zD+-gz{c3mh`5LCsNG=Hbk=Z4H4o-Cwrl>gA*$WE(_VXvC#ea#!DYlW;#jD#>m`B33 zxBnEk?k8=C^yq70J)icuwrFhrxHxH66Yb{ynnm6}OoZ}H?fJ5JH2jVvXq1Ck!X*mw z&jQs`aii&}yr1dtYf*e;BArw@&GnAFx2a<1!$a>rfa&ub?|c4{!e z=5NoGp9N!`sZ4IvrHGI=F;o7s5F>mq+1PNsKOVC901A@*tW#N4)FfN7@iY}9rz%FJ zhGR*&F&P{R1gL-RWPs)ES_}91CNPMZ^OD0a9I9fLFnaj{%{m z9(&KFx+R9oEU_<&EGc8z}rG5*D<{WIJ{DPK|1gQ1!Q{3799{4YlBpW)tCk@iIe za3}>0u!>3Mf?`De%U%21Kb|w>iK3>=w#s9`=~FC$e`P=geg4_4f6RjacgOeJrze0^ zse*zhPe3-2b02E@EjCaK!wvTC&32$=ESGX(YT|FsxtiOuPAS`uUteqEvLy7aA36gB zwd57Ty1H;?{Pzz^UuFsW;nLuuw#dnJ)tnX7eE%d_Z8TIqd`Vp9Yxf5drNPR~x|M=r zLTKp<@_W_?Iw(=u-3l}=k_VT=tw-&xXBdHl)p z!{JdySve{zVPj&(>M%z}KpQ0`*ZhK)o*Ey2mWkB;Z002Z{6N=M0a2RlRz(Ti{;xS=>@-Pg<*XwuY;> zBRh8^ER-BTadjEP85$ZYON)1j^`byFdy9!ZR&X*NAU(0VnrpV;aCn6BA>UTR5Fx}h zN~{TDYqmv1?cjwO;s5#}@MmUWEDs9@XOxXusCAS6 z3QLW|K?zVp%-79&bwsw&xNBFnqbq;B`S%|bTs2biWHREin21&OAgJsnc*JWo`V4V1=F+YEDRmrjvQc=B$B#s2P7Dw@+5*QjoDJ~Yau#PU-y}iFN!KGf!BJDW= z>Q;QOORUC}lq$B9sXmdH-j;B<;0!7b=sXJWQ69Z=G~e9kN2I>N#>YW8zCQ3+;uRZboFuzzSrokISogkkK~=uPn; zsAZinyeZ_oan-=QrDJ7QN4xnQYeGRj+GpwEgx*lH(pZJ(XmWDr7Qy?4`=2tH1!CYX z4FQu{{$cmSLs?zJ7->SDC`t38d3u0N8K9HzqC9H3&!x>uK*DKU=hQ>mUl8s=2SD0h z6zfN(k$rwFMK8ei2R&}kf<5`sja>;tCwJ~ywK_<|`n%?z};i;k9Q zL#!@KC*<|u%o((IIq)gSV-O9ZeO^6XCZq~% zHLb1rRqJ8d`5O`UyFYI&?r5O_vpB$<=NR-ttSBTBlcyQ$29?o#0wM4+cZ{C$1}8;T z)z#oMqXa$B``FH06?J!0r)YJC*ESQsc4td=g8fU;hc%2xI)Q2e0vx8m7a%S21F*n@ z#l9eTf4UKV*(!q6ZL5%DjJ@2MZf=1mntPpD%k`X-yfM1!KcNOwYhZ?jhMEQJB@uyt zu>!YA^>4Ah_#;#waUAL+hwr!1@3f5o+w`cQa*RIySu= zc;F0VWTY3p=|3SOBQp=&((^kIIcYq~0`iL^CaI?@#2ylfp!{v{PE+6t30)~ei#o+1eoL5DTb&W5R6f9@fq~>b`M%tx2+uB?rvzDpL;f(%o=K&Tx@Q{_I7tCCMTOb z6l64}Nm56PzvlDXAkhAHpV@D?g~&4MM%)D%yc~*oR5}H@dLg0o46~S#@5(I zB6c`7-wVgk2z>1GHj5v&89=yj;%g)nBX-z0{oo9IeEee{yDMFqx@u%OQX1;(_eF%~ z7v^WoK0<7LTl5{PX65A-F z$;|5sf)bk~n{t7#5GfL3p8F86+c5|IJY%LhSU!stU%u`7xB13~>6hbLdREp_Fqnx} zbkU}6TqoQr)D~NxIbqk*VIkvs?~$M&_qEh|`u-Dq$Q#i&t;Nl-R?KPp1>owPR=f+HwO zb=80=dC*)icoG(&p%}1-kdTv;Gq1j94eQEtc2ytk&%=rh+6hrnCkhXCYIk@>>FFZ7 z81w*IEZhY!q{udq`my_fZ9iE2@X~4y2z0NKCEGAft=|5EBf)R{=76JJkv8sQvPxmR zhJY*LB&Z{QL&Ms7iW0bF$$M4tu6=0F%_RV+u_!8aiZeF(lt1OvN0C}3p#%MvQ9Sn%2iXGL@p{WhFX$iK0p8B zl76cmD^AUemEsTey8OCI}1G4;x-?0 zU9(q0BWLKgw{_s?SX$9^4-W1p$iPG$vv4YqFQN;uuy9V6YCPTCG?@wISJ7&J32iT? z`Y{4VSyHq6h$A64lD_i#AL3bcw|(-ko2HA?<9$`Es$UHi5WcImA6ttn9nEZcMn>wZ z&BlTRfxPSVf)%CQ2Le#mlGJMoPZw1&bNmkU@kjKF(NqU@!*OxHzgqE;@Zcv*UBr1G zsbvT151|v5!0tkC_xeu8J05%wrq_gLH^XIh^#c*}j#NR1 z$-WIpi)B-|)3rl@X-25Ya^S|+I3f}wQW*iIj9G&#_~!I(g)YCpcq>55Gl75PV{DQs z%^U>_l;R+Y^#|TpE!j9bi_30k!cymazo6ypZcJ=D?AjTyCZLYSMzkqvV^5T$gWI;u zgW9(WB9L3Pp%Z-j?v5WlmJfo6Q+Ce3IVer~v9e}2TtbIcQYtg^OY;{lPIWT+l1+ln z<;al7AoTghr);pI_Q)%jn~8)6k>-o09Mh`Omb*DhqH168D6&3mVyf}l;UF1dt@84j z*yV?_+tJ#0-tsKCi0?$_9xH1f5-z$5-BxImklL{tWbvm~#ml=FQd zAr^^6ye=7;z;v_i$Fyp$@*$jM{|xT`;c#gydi zEwd_)`yXrHdt8ld15X0{TG#4tL5W<`Cl0*LRI=a^cKygEMe9<>`vLkK26K za+9X`f7b#wFoN6*Phl3`x`jqt;;aHG)OKqgf$o!O@+qdiN0LDg@3+%q&B>QH*R}Y?ivc)OltY}%S7z&@WZ2e90~K1S zSM}Qk<~)afM>(#|9X{Ta_NJ+&E55tH3{@4EdAGGjOwsvzr;q1+Gc(0)*~>B8mD$Z7 zGC)Uq5=KN~_rpM!hxizFB_$fM$Zt6@&SlvyF4x9$d2mV!FVr5Gpl46xzy1t#+D#ZO zwNk7!8WVy4!He+|e^BdQt)6WMvX`x+Ig3?!#jr}r@%Ukbta7I~eI^Iw;(9Tdv)sTB zFZs5%jkJ0IZU;5!wVz+$0NrPnyjD1?L0&0`i}x!{s09x?mV4CE$LiLXNFL^>;Nl4{lFd&$E~O2gL|X=8mpr%!3Z%y$upa zjCFNIABJj1{WsyUhB*MEL=;{KsmN8VN^%E zyRS$|^MJ*}rTO{s{4pVzP<5CPOW!B5t}kC=j$J^?5up+cziqk6!kJgvzKEy4dsECk z$TH)`yn((%M!xTH9*Od96Z9Yn6zXgO;vF^qlcg&S-UlQ(u1Jdm$!Q@^rnF?}^;DSo z;m%vkkh(@M-pbWButclVyx(NZY3no+3a>tBbC38}~WM?o%sz8`d$ z1)W#*<>U+C_@6aNY;6;bu(Ky9S`)N!+B;Xw*k|mpi?S;diVYY8eaHfv)}~S5r-4b} z-e}PM+la#H>G~D%i~g%45zcnEBQf}*b)AuG+fmGtf4gDOUbAD+$0S)+q}RD^(A`E3 zT}a%hC~|XM4pVSji1G@#WvnxmhGKyeGWnJ0@FvdxTig@OE zi?k!6s($_?pu#FBpwlgASfH$IJq8^aJC1=7T!^XgZKV8g9>x}AvQWPB05^F zt}YCA4s&T;o3G`aQOZoIXel}VlLuTzjJAVrq1!2bHkj7L2i>I5tkD5xDr&i;5_Ln#~zoz?rU4w#Kp+y z6EDp9QPr{RNu4F+O)z%!y9I2*UwNZP>PO@-s+HJCfv5N9ed`!t0_igBM~n+V-h^qwnSNL z#A`oC!O<-yekau4T)$mn=kAmV+rU{F9vr#tiyX}kTDvLSBy1b0JJZzRkSi*V(Ut)p z4AFi->BIfZ`hLw=7reqo_&MM0s#0&G#4`OEKQ#(&{gTYF|HW7KLDebKgvyck>eTiR zbK;BIfwt3z2X8f5YGN1ujCdX00Oa$aKFKr}m+z^msVInT>yFgDz-n)@p^pV$ZY-V! zQ-vYka}=>WU2d*b;|5J0<>BFhIrmkhR3;E-qRb+!E7g}9h1gU6&O0ut@+`IND>`pEY!kSH$MNM&jxt2tO>Q8#&VuU$&CDm4vGJiNY+ZXi54?7*@#U?*`{LdHAaK+>1Ryg0yJ0=6+k(HqaN@l~|PuM>||- zX=&}sVKmxrJ&W~g6bQPSvuYur_6{An%FFB21$w_%uJvU%uD_^;PK!PKhHkAK7I2R6 zWkr8WxA$#onvC3Y6GT|>K7(HqXz1WPW*@VgthM^HM?r;={`s8|jovMgOt`L8cV>=Q z@YfBbzPK7(MyJiJ%SwRq>T-ZbX{&KLhSB`5BAC?t7_o+Swp(06260@pt2c=PMA~JX zIc{L0St7B*v>iv0Z;Tr!Z+AG1JR)qBm2POD8bV^@!wCx4n9l~eH=m7KRJESn80%23 zEy4NY`dAsXg?7+u=I&1EVjpZbS|*LX?h%e9bddUbpNV1|s_S7H5D2-_0aJEpIb(o; z!^ysjEzz+ivC=NqeK*6%rz0xIy{}Ma)gucl_q8Cdfdu6ml#7c#`=QH7NH`+a{E8YQ zfIgaH4a6b@TojG#79c{$!N6|cHTxb5xUOslQ?M&nJC{*(P@SJm! z?9wt;Oco-mG9q;Q1985+?YdXPY6hzDU$Mv+~h#Qmlsk zmg&*Qjg!*JIY^IbQjGi;JJh>h5P`5~_3=9GZP)(sE$pF`QXjZ$JYBoEMd}wnc`Z8b3Vz@wNMF`u==G#FKzWS?UeW3)rR9L?k3Hmd*pw@Qj3NBT)2tLe9|e@D;+n z)_K?;?zD0?MLZh6#-(LHr~QDE6S0PaVRvS3FPpTC4S*Z5L05meIvU>nXtzz;TsS52 z2aa=>S>5xxY(pmY1FP1iOTSD_YSeBodfFA{BnModF->ZVxDGerEIv4vCP*NM9@=5C zGbQmp$3i>Z-TnNoi2U;Maxa%N9S!m9^!<~ero)oOmMrW8)H_+y^^sl+K@xc}X|wOF zjpXZPgD0e9X1I5HcXEP!cb4Gks>M@f8gO?JfcU;0psuEXU91Hq&fPprPnto8hGtt_ zCG-+madgLil5J7zB_?y2dfb+i$H&K)@F%9E%q%z-&X!g{<{Bz3-oMX%E%AWT1V)Aj zuzO97a7pcVbMx$cYiFmhvo0Z4HHPWr2WWB9V{zvz@p|b|LyKR#txPxw24k26#>aVm zn}qbxagJBa*9uS?lqm{hy>L~Ui?E#S`=M_vCufa1>FLEV7b>gl5EE_t15GDHfL>nu zy86P0F~5HYA09g8alNx8$({0kf8tY0JDkd z)Y09AhMhf5C4ZcAQZduSbSzJW2-8ojuluY&|D&l)Tg9{Op!dipgGYRTKxwWigyI+4 z<0SHSx&fD(8((tc)gD&-XE%O*Ha%7l%4U9^@)nXkL`zD7Kr^t&!cPhPfawbyXdn={MdK$WsY9F z*QJUrJ^|`WQ}_^3HZTC4Mo|6H0M<$zebNAgRGQpTP(A0etN-*Tq(gw8Bik}#*kR1x zIPokT!&`#D2(7aD^DNmsQA zTd@1-)MtG^w?Yw;KFt*idR|pE$d1A{ykqEx(QIv zm)z?8nzG~<_^j_&TR6h}m(~6bSR;Axo8$g5sRqM6$tnHVz#Zr&N{sS`KhV#ftYZgY zV|I|EzpL(vPn3Mj+kP`}0zLm}qi&<=Cs_a^sS-_nalYLG`Dfkw!;=7`d= zEA0$!y{tSXxMQ>TCj$1@P23-L5-MtdCjRsR+LWfCwTU%b-+Kie(_FnW0!2%71qE8P z;LSwdj43B>9939kR_;olj4PLVS(T6I`PeIAFFhiEAD4x3$PCqeO-NX!;F1412?u-= zZ~H*XSAOtz2*oP~Y{BV^*k9`_#h`To5kcX68R}N<>_U^6!6u!_Fo*19e8}xZ265FlD?2!sC z55YsqX8nRHCL?+dR+bHtZIYyCOI8_xzBkWl?fos=rkpUyF;&#mV%=7F%WFmzmz1;( zRcluozFFkdw>1^*p(JNHkd-~@3idKx?CV0bK4-iIwTjR645fr!c7onab>tUiFqjqW z9fzHL-Ei+QM=3G8nU8P>y*Q0hd1t@aSK(>8@SAW9?Fk2OKjorFVZ(NO>6z+r?*?%i zhWmXb`|RERH2LqOOmZ?N4NScC6oVoX$^p_82TKc9zie8zs5xtWyeriWM8d+NyDS?_ zYSjsHeg#qqWyN5F{5z`pY-;?9?_W_;V&n9QwLA*I3|6Ea$;(^9x-@Fj=NA4r2(gvN z3>n(S(+d`@SwESrXP^}gc90poF5FuldHI_6DJr$Jjy74a#x}pC42|(Ao8l1_O-#}$ z`F&>#=wnSa!j0e84O9@EU$0Q&$aUd zQb+`%EHYkQjU>MP%#r?~9;csxMF}eL;pV>-fBdo@)h{L%;oYN;yeA}c z%x1RTW?B5Y$Kuz=yT%Hf9gmN=zcFYxiK?f4r^kK&>E$Z@i*-d|BV$LpDVJ+AH?k{O zX2|T~JC(66&RE>%$>oAy%$Jv^WyL1)OPIB4W$|)eh3(_~Qq9nd+vFA%J@lGqi0>j$ zS#Z(^cTthD5XxdG2{Il%eLr;=MGttcoiLozt%m-W&t7nMmZC73IS~i|y!iCj_X1Ex z-m73UqJPCj{dfIzM7$;y%7#W6dw}a;l!f=NIQIYap8w$t{~r_oFFfME?v(%k*u?)o z(+?o?|2>8zINZ$6Zf|q;dqUjMBS|}4RhW^PKC8e4Hs78g;5q4kg%@rLC zlSaJQQE{-_v)ZgS9zp})3jY(UVd9FX`gpyEPURc=1aL}@vf}^eRfT4hfQ4_`ntkS2yiGP)r`86o_;80CX|K=h3FT#mB>Tl2w7aHh) zeW-sid!Rz8G&2|Z7xz=s{_pnv5sBaoa&wvm+Ik(N;RXN@Jh!5!aRICf=Kbf1sDCE) zyHFIJC5`c)r-8l?xS+e0DD^!8WXdMU;nR^Mx&d%`{yEHl1H1k~8f^-oXd_(h|LNy{ z5I_G81oV$f-#%ex-ancM6`?Lf|M1}heEI%?=PmC=V^z~-Uf%;RKfioz4g!hf_8>(E zaDA4P$KQ#dJl<0{iHwf(Pqwx#?l?{9yS-H-Klb~M!wC2kj>Z#@`r5+o%D2-~`!*B- zh1VbT9cm49x(-)m%$imS{C&rR3VNRA-*!%!Kk+D14NWjU3iPD!6s6a5evh-Nf9KkC z+dy3AZN&Kq@Da_U+u7>l*LzBzhl2+d0D$U|80P@rpHb(=^UiSfq{1x+~wzpVy8`ghj-f4Ii}2c`B`rj}Iv#peP5Ooaa5 uFvR``bO7Mt!L50*2lWZ3XMfdVPXP3^Xsu|Hx2&iu03|tf*$V0R!T%TCEn@5d literal 19252 zcmeFZWmH_xyXQ+12nhrT5?q42gy0(7-GYqT>%G+919H%4M#~)RvQiNNf;X1 z)0G!bQ7N?Zw2f$JuhEobrF4C>4`7&q6!3e=lX^}_U8?i2PHKlwwve0m(-3qP(U&1S zSjk$~DNPsV+5*;ZMp)Xr;%J-~ik$3TWQDw`Q&bJx@CB&UF)r0raY7#r+GJl0%BUxA zCS3Xro~nq5r&dkxdO%wO)Oq;$hMu7PqRoDWX7QZvB^v2B#iwXtFNDz2&E)0fnOYz} zfBoV)c9tdhebHTl2Nd-35uwx%haI^uWN0u%F>tXdQ*tuZ@dV2+<7yaQc_2QX*t7yl zpYeR|D`kDPe z(=1ho5J$rR@4vnL)4i=_swP*W#hp5&o2i;1rp`j-)?Kg)=gU&t@CNLQwB3*_%-a8H zwpHi{n-X=xOvAdrKH&OK)Z)(fD=X5^o?d^S-C$wG<9IS%%FC|I0ak@rhZ zFctC`C$J7wc7@~P%SWyg>Tb*>UXv`$El$i$y^W7lnYHTgV{7>_zk2NCTzYB;@w$ZJJ-WBUm-PD|9-RxA_3t2XFZt#_+vFXa} z!aJWytVO0)3juG2Q|`KAhnm(JpR45}{HL!*WAo-`W|vJi8kP$B6E9-KLV0OpGAtZ+ z_X0P&jxQFSFv|8S`xpj4jX67b1g(CgA*21sAN*j*SVW1=d0VPY^YoJW1zo6IJ+Z5l z%W`GjynVeP{2kWjr0?f9r}Z1j&%ak!wSwE+@%RDn>(3aqg?;jM${H)q=b`r-3y(K? zMu9zrp#VQPM!4hOZy4CZFQfwxzshG*;%13x^<#9^wm09*?|S~RM^E_vG9;9du|j1# zH#;-cy76LC=!5fR5P5KpHq^($D|2jK9N8Ry+y>1Rqtm!MSTx1)IzC+Pe$sm#zxfL< z=;h;&$)80OZdeFWm->a8wyoQ5`2@%F_O{ube&@toiZNQx*3C;G@WSJS1Zi9*8S&-J z2B$r({5-S*sd<>QX4w?~t|31D(QH7rqpAk*`eC)Y{ZoemMOG%Ded98S{1q;dxWu-%dh1>Su9$Dvd_xm(8if%ZRk!o0vx$*O zUCHusMWIN0I0{}8B}*5*Q0E6pZ9ndOyx9hgjSa7*lU#c&i-@)#wIaHwZdA^rw>X4% z8@A1T`*%IvY2Rv-rlc6_YPT^kF{zh0vn{o@n$GjOzwj~nm@{6*uXj_fyBgZO>^)!1 z4PFN{H#OzB-A2Jgo9`~BqG_%-=uJ&c@7#-2!S#7@&#q3EprvATo@vC|Uc(c4`^P?I zGYjDO0al{s<&LATlFP`8>3H*C7&LZZyu6&8i}ZR{qoLhlULk8$(3_$|g&;*~8&p@` z;QCOSsB7PGjzBoWnhHvPy%l5*Sc*Fa`R+k+!#4)ZAHQMa%*>kd6wEBoPwSe`{?UY0 z17-`i2bdX7nWi@t^MECUC%<>H*rQQmb~Jpo6|F{`#zV!H^;6G_PuZiki}uPw5Y+@@@E#j=KGr=|AeDsvwQ)uZ2yUpf0^ zk8+i_n;A8xYUbM%k&CLy&-Rs{FEqnu9hOeI748mabZ3ExI|&w%#Fj5m^dF!E`K*V1 zKnJip6Ox~wUsuQbuzl+Y729Zt&=jZU785hcNli)4pKIbH6=^7Tv5aC^{ zM#(6c%h)ita9}Hw+Ig;lS>XsiiPKX`C0)6;ZA5c!@X7*(Kw!r?@|c1m3jl_z_i_rF zdR(TMyeRkz8O`n?()0Ite|gnFigT;0CSNX+6wLRFrrvnm9C$h%AMCd(v%;t$cEuAc z!$MRo_Lv=T{J3GMA(oV!?2E{0E}K1yJ6SRVo@NUdVeLi=|Lqm+sG4u|@*;N@F#h-l zFR!nLqV}Woy&EeV|KXDKNr;S0HD ziam;&)`v9n;ClFaqhnw@r*x7c2Nz4GjRAekOS$mxXuved(kRTA{jEBe7lL=XuBnxBD7i0tG-NHp%GY!3F}DRvEC{ zYAx5#zCE_Bj{fjE3q(CVpdmOxuBfP*s%#xjvVVA3RwnU5%4?Bqn{9)zN4#a?H-OYJ z4H}HBFa?(-ld|VjB$UpOO>_Lbk76J<7wy=NAf`5Rx3Kt|<1-IU*AjBHc?W~X-^E3| zQ1Ducey(6MW;ipuu(GIYprgl?(x?d)@sd%dx7QlSOZuzDSWkZmlw(aD z9qc}1x%Y6fajDy^!?MoIwK_jv|By33E@s;(O-7^b#W=Fs|GtidxUFJ#rB`{+VI&7P z;I2XF^U^%&cFQUEpS54dtV@Y0I?3tTdyi6R?IYgikI@um(0pI{Px>}nO;oF1-rleP z1XY@8Tk3b30uB00HHRhg@bECAk-k@ZH*v%ocS=iU#^+fi4`kd)tDZ+UCB$!{L!^~c z@2kdwAMXf9idE7;flDLUfe=B@27}$k93Zf|ds4@Q)w*tZb2G!944=8Qt$Q2aneFj< ziBJ&cuHoC#)fuZH?)`Y#bw?GOJ%PzDt3ABbbh*-GG?s#d@YZ1^>Dn(}@VYSYH|{MO z8_$9RJ&(ceX^|&Y8Bj zBQ0ofHe?#p$<@^rvc)~KckwNsRh3`Mz<74~W3C<#vuVy|@cCg#*Gd6Ljq`E-*@{zn z*`i~;kqy*b;GX_(XsP-DLY!IaWAPq;ZKHA43Bw!fy;0<{U09lL8@^}QLbH()HecCjqxgg)%U zfjQr^--_5@aRb^tx8g-<+eL6F=DPfL;gaz&F|SfhsX}Z1{+gcO6^EIFOPY!kUAC#3 z;>idf7Yb>Xn{C^f=rz3TLaao(<&x4rs5Ra7B$6>=@5no<+#i{)dXryfLtTZO+mjVk z)Q$3DGzz)fq%OUd_uzryV%ExZ^w@YshVACc!EoNYV}@!Q~4=YE|8p z_`r~(rrU*HY2ug4{iV72dCfa^tiYl8?F*a^77v$b#5VXcG!?CH1CXWF7rA&3#wZ`Q^OhQ%_-4yP6N)Do z9Eea*QGkhfrh#K89%UZIu!B73S68K#dgc=sfw%{oayC`|o2)KBq082;^Q%co1bF6J zhqnUZFnJAmz4!=M#HS}Q8hJ@+1{Vv=`xJW>s4bcMIaWcwY(u<~3N+so+x)i&eMjKx zQNK!BEP^2rz1;*?r&+2<*jF2I@m=vVR54wV6m~3Z;5TLTdtzv4;_u(r&N(0@54*@Q z_*+n}*!8i3pm6R6K9HKUfJ2ZsaI_lv+b81HE8I8_@@Thv%Xpv@!BF(vvYXU;y2da2 zjqrqI1yFBt3!3nZxr9h`tJW|iRw6KoV#9ZQ(vOOjNIPb3UVm<0FH&|gxxa$@uIw04 z0vyFW?&6nXa0`4>)))yAF-#d((}`dR`^W(t!(7(C{Y5_r zET$vhNB)ugEeTJz-dX+qWg0=^p`!quT|Lex2G+50{;{pkL8FU&InxPS)VAF_^8`5Z zCh1Jk1qG%&*+T{*Ve@th>PYSJB!wzY91>91z!I&981LD@9H(}|j?gClb_QLPC>A|P zBu8aaIm2S*Tl%^-e{+3VUJkqB%_;qb&P)muL1gI~G11_YR~HDK-dLlwm9V8a=PbO{ z!Og8Vv4~fJMf&q|vJ`Uedkme60$KO>I5c~HkXUn?=E zBi^Dd0+<-eXEG}lH3na=Mx0O=%TB(3OrAEs|J#`?E^66NuviJb9Hu|JOQUdHx@@j# z`+?2Yup~&n2vk@$S`FIi=AG3Otj^fIb^tPxblQ1RdwG*n;u^bBCCm0Y7z*E9RQqkq z76L~VXgVNn=Ex(<9dW8-GM0Edop>eDPT;Rx@ui zxsR7P`>EZ1>lOBmHSzg#OtU+LO6W}}Gq-S1J$B&5!r;aI@_Xpo(^ztf!?y3?$^!$e z(^DRu;mZD!0B~*_S;m5GrO4#bSFA|&AH?VaiGpO}>Bu;-Y{@$ujh@2mv2=uEt)IW*3>uF-z!T&&hX$gZf@ z)q124wQpXOmr-z0R+id}S8vB54t&riF#WZ4W?=4rbvH}GzCpL@R?a<9Wh$H%r=iG#SLxcrag;!=%b-O^!A7gF9h z3;8>9AHwir8`_Vqvj00;M##ARAZ&HX}S&(P5PieEudV`$h1 z5^fXEfwO5&eMoc-I6E_c(cF+0KN@;7EW89>s-A5u)7GwOU1u}04nQ0u|8C%gg@qT$ zv{pTnB34wl#>h-L(5%dF+%5qwt}dt8z<=*%>zT#elJ{I_ zfVYvlOwLXYF$D5ehJI3?q-Z(soTdjVYOW0%SZ?FhYO^ML-@70C}=N#_2OjOA87 zlJY)22*Ti?y7oa`6QS=9&x3-^R+nGb#N-`Vst z>%IY2bwjr~W}2#!R445^WwsO=Fs_q;ib6Zc+nYJZUEU;{u8=PBe1=8z!r3iKR;!__ z-D+=b<+`7win9tQR?WEC#Y4nY@NT_rYHH!2hOdh*GE88uvV%u}DRt_$-@v@QvW)!w z($Aw+W}&96?e67#$($QpOzjxiz{_@cO(`L7u zzCTGYBFk&0?|L2rHJI0(h7_u8e$a!j`(g8EHmI2gD+SAx+ddCEu2`yd930>lGz)<9 zzmJY)XT!59E}7Re;NzPa6H9&QFouADoyJ;F-wHOg(&r8dc2+uKET*SAci0 z1;%>2iP)=K%JaJ@hFd-`qWiX~Nuce}RY>_R9nxb{PZkq)LF5+D40BiQ^=yL=Z| zU0Hoa7B6rz5&pTh59(52A78`b2J&_cLH6j7mFtcT#WSZ~RMWBjCfQJ%PucPmiJ|hZ zm{on#+tPAB96YX)*JednRGu}rNrl?&p(xUmyAO&+a(wA~pqWKjb z4xVqd+3GAzoabS;KsPNx+1kHTf=eK-Ekp9reFKBtAR5o~aj-dZ+qx;!-Zi-`wiFP} z27Eb!Uf+M<+6L`>Ef-nZJakTuWF;iw{Mh2#O?Mlj+2}I5sxB$Vyc*;+ z;}wa=gY(h=%lFqagR|{za0`D3V?wRFW@CwMfUgM z$LFx6K&5i5oZxZ4na=yI0KjxW$6iOuiWoT_#*RX6@WJ6qF%#*mQ#2M9&Et6zZyAT= zJkSmIwzJ{7tV>J=VC&hivyRP8eO|@_+!5uMzGmuBR#?`7(|3i8Yw$gUTD zq4Zp`TPkN%CL3`XUxN%BRe&W@8aOe9412xm+ZwX@QGXycj{)$7ZTp$biHsNT(6s;! zV-AfAqcSqRWqaRC`n9bWW?*u9w6_Fm*5?~g)s^M|(b-qFxctJPtd7ovk_ys4k z+Vz?I9_iaJfQ35e;Qp_F{-oQ2`~%l|Fk%2-H{_WlzraO%v14p~?G#I#9p)P+1PV2o z?5FDU^Ks28WA|cez?VkyMogE%^fAR5L1`+!rEXTu#)xF+G|fg?Is=wqyB-zmcXy0( zB_TD$6W0aEOhpWxeL171gH1{Cvq1%#fNyn?;OkJnY9O+zD@(LF`99Wl*@&Pq01?!s z#uPt1oZG`tNINM0p~BhGo|KaECnvW!y}`Of<(2}b_?h@qu*zHC$@Ya!P*wNmM#KDq z{(2QWYDSfS0yOH-A%lbw#KF(w_bO@B-8LKUtyfb##(w=1C$94Ti{CKL-$eawkw%a< z7VJQbt(c$y43#gz{jC%*g&S z8a+Dotq6U-(P=v>AK5o@&9Ik2Ec?JCqiAs+adCMy;cKFQ6hpt{UJA%sv_pNsvuY_^ z?wHva`3su20~VP#NkHk*dpyxilUoB0(XE0gDH)WJmR^d5JazlCG8Td7gPYJNRv!Py zIrW8<)kR)j;^H1wd8J~JyLDi`PY|6yP3OJrU>x!lfhl3Ag>+&j_PdQM#>w(Dh^3D9 zbXjzvd$3_S6FXDtr9DpEvoy%UoXP6!g8Gv}ac3{^t zg0b!(`ze57EL}L%t@^inJoke0^Z{>E5P5T|EPJSg4N8=clr5ic($x#3*#cF*XH#au z!p4KeXS70rPO^d0%H>R`G$P_f=1_E!z1X88fwsX7=cCi>iZnDvgq5ValZN_)5 z)H}#T3@$GDwKyZs8-Apm4)aCHavF#CDhCjxO&(h}3-aZ!>h<23%QC8Io`@HkU=GgT1kb=e1OqhEU^WGy<=4BtIT8tdek+RP z`1MA&iHZElQdEakGtt8uj$==ft_?>z!duf0?@|Z)S^Sqva5jnE@*MgwMV0!FFM(Na z2iKGMfpn28ts34g6#&OKgKTA)70Jpfe7m32*|j63M4UYU@sW$4i9ST9a&6lt{^VSN zdq(7cx7h04u;@c%_Pz2QmU4hTCG76RLyH-7XlgGucsr6$m)4W2=8sM>~x6dj^zzc2}~MXFoy zoT|Ofn$6B$2c;!uzP;m6fAaV-cIi{`{?1nMdPtgimq!T{9uEsskiTkfY9YB5l*AW& z!y<{L=W2IhTI1~IA6L8v*}tqJt`}BjP}Z|m1a8?E)%u0W1 zOw$|=fKJNNe~LX~rX%g6enUHLdf>>6KRkQajVKx!m8SC{CD$8rsl&bTsg~KC`6Ax8QeH&kFF-iOzid zLjPV!=r7f0!3IZTV{*F}G5O(bxt$5RLYCgXilF<~bUiJ9oQ<#e<=AzU!# z{rCp*LxJ$;(RTGXZg{WyuFSn?`ONoemkDweMb+Xe&P5NUH-H;ECJoH*8()kl8nS9C zV7W=}zstwPP#Nv5ND!<2u=gt63~z_5EVmU+{=t94IuxOEx79K0!CU`BA-m*s*4_yf zu2wHGF~OiT5e@qtoOgHCQs1+p4V=h>p-h$H*7GfWt;@;r*)zP(o5+vA&13&Qk{ zX_GDf#1Jd}J?$)gcdl1lNazpD+O;+N$P!!bvzk9~*Yb?>0iU<#x+e9o)w>3mE83HF zYX9?5fqsor*MD9PW*`&sjJ=#?EhD!MOmy0tj}oeVev1?pX}Is%Q6m5ES^$wa7c)*L z2{dz%yM$Aw#5pdCU@|>Tm6|?bNa%~@u@IjWsw6iMhzN^mZ;&&iKEj+=OpH%V%srp0 zUJ#}8Zjc;BXHtsz|5!8}Q}cRu&t=f1LYF{1p5Dt{@`U>q5=tblMOThgER3K|-;$Ak@6mJhLv%eXyd#|c{M_ah z4z4CPyEe-OZoZE7HZsT9&X$mjwXo;C%2q&H7ScbYhXrAf%tF^^bNKD2OskaW<j)f2jpXlGc!#Z~Q z)DVcM<|J~yh}SOmT+u#uH#OD?d7Jo6jUyEmX}N)e2;0Lu=Gie{Xg6RMKOrP%izHTw zJF@_SCqDp*8j^}*uy+;DXyjf7udVWo110c~B>I>_xExy&(V=1S>m8=AS1LlN;=BjM zzSaoJ^!Rlg5hQ7yD{+S_<=SN?F;R+Etnx$!7n-YIb2Hx z#a;a6Q$e$M@joz(xBujcVEErlx=GZehHFuuGd@9!!N3vx-+TEV_~Sp+`d{V!|7JXd zy#K@4{xjSb|2_o=1C`LwETwvJ{u=`H|B&-Pm-N3`-cww(zv$%DNBjG=A34?A^@u6L z6`y|D7(^WiGuuM6^7{&jf`+SeR<984J@n`a-xM>%h!p_Oe%GSh8Yy+quN55woliRx zld&(pen?6!nzM?#p+_y57YtdmQ&YOOjfT%}O-HT^Q4~o~YikWNFE5)B2k3fm()HPw zf+_2RUkg>cWWskaYMr!-)iPbC0oL_5Bj|v4eyZ7$&@dwcG|HDKT~f{KX5z2K$B(|* z(uTSwBN$H|l+bvD9lXhK4h1S&3qA3>7&yeT+d@zgs!(ZK5}|bNm#8HOXJ+m4I^Tu| z_*|unFlx6p2byFjww2zG(vPO0qbcOq)PxW8-}Z&4uvfFdd58JDDsUteA-`s+-T7&c&;(`0RYR-1GR15TrnfvUJ+aT*D`zT|i7stj1!in{lufU{|iG@-)4vl(q=~ zjl++Njw24hv)+sYtWM~ns^I~8`kn{#ehhAj!&Y>Q@PA-+6Sv^+PvcGP3u8N&87;}#v)j-*XP8eNOQ{C2c z^efcO#OM|eCX?B~;bMgHi@(NQnj`xO9=y$SS&cY`$2nsn^<1a5{!+Nm)6)|!TfgS- zVs629%_m*-dj36CehfXK2O~PwZ8}qs)nIJc%yE$J=vvbg6V}rEFlJlm3wN zco5vQ3vF7eyz_q@*+*u0gJmt9j*01NqKTKFzVx}-PpD_cBeHL~fpk}&jh;@CZNjLFnEY1&0iRe5YU>#Od!4NGJy_l~8axGXEHJ)0Lf z3GcvYEZS&VpCrIbjb9uf@4YrEtde}n+MS{xOUf9fp_!7*r3T^PA@>Z!<$P=#V2{7? z)O?75I2va;?C*8>c%0j0Hyn(%o@->jjW1rkU6VAYI&>~@r$=jK@JDHWCSY5IY`S;N zt^y=?d*z6$%r=`;jvphNr7TiEHO~w4z)^b}_)n#@CJ)koij<1{9e~6ZbS`qzc+ITj z9cQ$@Z?UI+Z#!^u^22PN*G=xFh4zfvZl@ZRyt4KBw|)o-w>XX;pP38#&&L?hj7_5^ zz2@79&0-9HT5ZG1*+m6zoJIzrr+j8}d4Pgf1Xu-IoMNHo=?NK-ZrTm_fYxl{btwfo zxx0|lJGawiK7tBG*FVlERaHU%YqrfcxDIM%_jDGQCS^TKSM{)TUhv7n z#KiN4MD-^!GUvW?y!sZ>N5mC!lS^^2do|F%i6eNs=KSUo0BqG+o!vJrjf(H|?f)hx zXL#|;Y)Kl&lDzj^f-OZ^pF>iQK}2XLHJ2ptQmUdZEa;cB-(SM^OPH!a5BOxnkO?c28)_3z-W z)s!6bb);Rkv>Rk>pMa3C#iDr5O7CdZnrA0bXPMc!K<8yd|FWPM?}7LlXiv&xD%JJG zQfDkSdd@`Ad}iQwHnuBAY`mZdEeS_G;V5L!QIkQ>N~!C6HRk7 zy>YQ`zxMn0?`ZDMx{E@%?kCa1n0m>FO&ze={KCRz`zlPp(q^{~ZX0-iX>%m|qo0MP zr!2ouYN#OYPZZF42@GpN`3V$bdF<|G#Dr-nyuO^?k;>OyV46dvUP3Qk;W~3howOUt zoV!Kgvb(!$U=n2SXuQPPypoXq<79_ExUvS|dy`_YTXx)XKj*V*`nahRY}R_#k(A1{ zfY|qDzi$CI=QgP3rfO4OQZ6TPdo2v@u*~xfGX~JVBUTrODh^I|g{Kj#u)ubo zNE0rAz5Qf!b8}-;m5En^k6)I)W8-3YSh(EeXJTAWWi?23&5G^bk?sJqdnpT*%1Z4g zWx88=OMWn(o5C2Pvp_5=BH}0GozJn%jDec?96tWHKSFZyHWHYFzkOS~v&0g2V9Htp z4L*oiqv{`6a+3sdfdt#(ci%dO;^`kB=55s_ZWzX=LuHpjAn>VD9l4%T67y??z>a{4 zoK#skIWI3SxsdQBXMr~MgzUc=-`|Wd_-8CO$p`tP;l|+v{b(t7?~-N1#>1{D=a59) z@h>)Qfmf)(zhg2y!or?cup>M`mWJop(WYFsK<78xpe|Yw(dOR{@0$4pT|vVIx29&o;AlpPFDuwUBp{$M)x7Rw+vu#`7 zW(gwu+D+(E0>N(6f~$cl$lSBDsHNkMC96HNHmh35uF>N|*hsm9a;eV>@E8wI!sJJ8 zT2|K5@^am7bj_84E@`YOcgIPhdQAzuBp3Wm^hiu}%GEMlpUYsP7@C*w`AU=2kYh_j z^0vloo{d-qyae`}4NcP)8M6FLn52EpJ+9NZ2GA*XJ-U8<;`2rs@t{ijrhhf|Qni^S zDsJm|Wb;T(moDMVryn%N+hsAZA}(4*tdiSWQ}~cDLmw|eOh(q~d)hNwp|73e zH{7gs~ zY)qV5n%2=VrHvpWf@b#`Bn!O=p&ySO-d0(c{MzvBCF19jzq5{2ArGzLSZGY26@RCd zsc}Jb?wj)`80BJJYe5gY$cy_fK$nP+5YKM>E7C6Hwx!_X*4*mD*0^Mck#lZoX~Ev< zpbH4N53pVJL$m=D<{RyY;5mZkTp91XewBtu!q#3q1SQi2&@AD$g0*nWBq5yLFvc0xdZSUv3!)44T_4g1w8m~D z1Y=@u2(4H7E4R{W4lr{GN6OdrKoEy!9i9v58pssnAAd-53(KXbr)Z?H zhtn{C{`S^)1!@@ir`q+DL;K%CYId z&U3v0kH@K?zUdk^D!uCpnb0(%iUPV>HmF%^nN#rlwDYCjIKH;)x(cL!fWo)6Ju~KI ze5YB39~77sWj7ddUX3Y)Bwh-pj)XwmB)we|MP>D|pJ^KGpQgr|o3=g#N!1<`1kbs$ zs3?2K_cS!3sM{Z(1|1*$Am@#}zNKEDssj;uk}f>l9iL$3TFkx;E=Gts$DiWT3hR@fvY z$m@7yxtA_1>mJf6nfVAadpMewTBtKGH#GB1{=4xZ;cK z;YUReLFT$*+ZXSlz)g*KidXVnq=hZ!LFv_1e(OQu9d~4W4F~O3>->}>*Xk1^&ye;q z1uh|9A0p+K4`v@*j}x}{rfS-5%5{>SkJ_B~^?F=Yaf_eS*9{p1TGq}&y1D8X&NkK? z)JCk_OQ!SYn?GT`FDrAI)fvIb8hcpW2XL9Y+4KQi?q*kD?MBAr>JspVQxPcaKJMAG zOD{|U0=?6hgnDjBB&Jyqd=~TJT2aQ*GB~)4j%(b4Pk(;KW%)wRMqXZDp9!ZX@akqj zQsTlcAOJZr@ml=7vp(`FWqRNpz--q|f(iaY)3T*&bzz}QLuv9PTsFoqMxG883ol4x zB969?ai999g;LF7x0%a#`D=M=R(gBr?bBzMP{1)|(=NhkjTP3*}T-O;1!SL0-Q=PX{_b)q$1YLvhI6 zf%mb^)B$&_{%5~Pa*>WMW^0v++L5{fE9Z%+#UeRP_E#M``LdIQVqOa2YsFs82QY)6 zT}G1oEy1p0MF)F(;7OiBlM5`UyFA5b6<9K6(+;0o@;SN zgQJP$CC)FBu(zP_^lf*Mt5j!oJ=$`)@;q7};a!MxvAXYe!m>azQW6MunquR21>x0ITHv^?l_*M{w8mfnUWO6JyDa*HLVEraKCZ_ev%g zSymu^Ss=?NFJC(~YyY!-ax(EcM7Cmf%5raA?ERCzepckI|0wXt5XbWK#^%|_q_Swq zZ}NGnKh=&xgCA-F=x%?xM)zlD=dbT8_|iPHE^n3k{P{EL5=6$iZC`9+k<2P_i6W7l z3KJ(sisHD$K692}u*z7ES{8U{Xs9qKzwOq){B_mG+L)$}@o{y3=`vY|W>JZ|9m0ai z7XMl0TA@sVi0UJk5{O@qE&VBG6_ycggOP3-nN&hvrM*k#E+2U*JS#mJ1-T{pgwfS^;^>XC- zZ%a#AF6c0plij~7h%ZxgU4PDxC#3NTi);G&^+8v|%R;-skJGGUXFYme?6C)8y?UI- zJuaRT8T*;howowF$Gx-&#;0> zSMvl&fPTm0nQBxCk{5ER=osJ2~3h|(ezQ`k~qg_MR+Db*VMH0Q?CNzaV-x7TFEu@CToOU zC(caE;tW-fqX?fj_(x`nco>tQDE$|~dWFwG?dTu*$A<)Y^ls* z@GvA2!*EZq5=tGGxZ3bT1K^Y7bL{0aGA1`J_^H7aief^Li?wfxVk~7 za07?f+uA|ueBFFT+Z?}v?1g$j>tP&1FtFin##&qBu>%6n6mQ>*j?HxiVXaUAz~ToP z3#s?0$m^+r-rml{dbx6VfmFldp8MWd-q)(lWE%Ac&|in>PvM#$v;VXn|KoR*9$W| zB}P9o9b8;NKvHY6(B3~dj0k(yL(>_<9nZyCd*STW&z7IZ~bd zD4iXrk4%l5f$hWXRy?Yx%Va~3Vm*N})2PTJtI)UQ((1C-2a>L!oi$0rs-{}ol+C3R zq&JGho*Na`X-O|puRtEQ8MEQ)q5vyl+gv&N`-aU84atJk#n$a?;3KfBMC4`~dUwqJ z7!=E9Z=36-uNvdDQCwqWY^-AQM)GoVqpa*CG8tofk zo93-!B%s74Z;KNW1mB(QiypNNItkQ#blW3Md-k^=?zyJuqVaS+cK_$HM-8>Z#?jFj zSiqIGTv=c+22O?6gO;5AWPWHftd5J7HPS)G&(9AAV~VO8DK8ffxVpT&e6zW|>H#%v z6<+ARP9T<*CttK@F{0-5Zk8zlvMn6W6^D^a6>56!TsK>#Fwwskq;e= zQ!{f>N6AD1h$D)E)IJ5%8xYk6&}Mgv{yJvC=?aJ(VXr7JbC;#n)8?%x7bEnckH3>)S6DHQixl^wR}%d`vVesr%{#-I>P!}{Y9Mi2Y1T@#l(@#nOAwS{FLF)+fS$v_VN-H1qrlv%%y&> zVQl1DHk3EPoY!M6P?ZDoE5VOt(oV}w%$S)>4u>#ZDi)7cN=nrsxTh}7HRuYjEK}7c ze97*Y(^4~qPnz@LKL3q6@mH~LUtUG}$tM{qz~cgn+X^I-+V z)jqc6`tio2Zh=RPkAS-SgK3C}d49ik?MHGF67CaK72h7b6wg10YB;mNEoui9RM^;V zs)WZF=lA3bU6&XtC9?wt{MDl4!tr_@m#OG6YK>3fYjNDGpW;y3I|?@V@grXyvWXT~ z@Bu#1QUdc2MhP#CkvEdRosQro8mS`@0eeS;AmuxnziRm#H;9Y$w#CaT)D=&@An229 zyC=i3wF=7}G@Pr@KQCV~-Og@-sl&(CK|Ki@B8$eoEj)gskz%_3Gc^(t!I909n%sGe>x!h4{#}q{6y% zyDo10ehFt|9S_SYO;S0al|7l{s}=hAw}t3~*7!@KM_-#l%ZYkjg}j&>$u1!0XI5t) zx$Nv4)r!^W^`eP6BvQ-?a(@)gS!_~EQ|%|n-pgW?+0TlI7is5CyVPZja-}J9z{8i+ zhul6Wly$8(dLZX#xS3Lhr{8;YLTt2bSpbKI+2a$YHu3=7BX?R6V-`BIA^#ehHm`}g>*NR?Ry>5=5_=5#@* zw9R=0o79ToX|wYJ5k9q4BdvAdlrFdJ^VgZ*=o5XxgC_4%6y#;v|8OkTprK*4{`0?p zyv{M3dR$ywD>zX(&m-CI1gv6WbozIjo+9mSiv=Nc$Bl*d*YOV`DI((Ul;EAouhA&K zT9h;1lvghsX!13($I*16jtb=EB_rfs>b6!}p9MaC`GP&CSYA=aa49uq|=f&{gcv)$5Ygd_oiy zIp&Zj#Z@c2zWt@GwML=F(MyDiNPOK+$!)lMXR=Aa%ZCgVN;07?QB@>(!Sms{Yc{$Y z166fn(N_n-KyD$N0Ka%?Cx_DFowYQD7wFMxpaE`xqjMq1UQ0Lm-!6W}$XsInmCe#m z*yI-y+Hn_VNc>KyvUDhrHjPilN+gS=B*b)xVKI9VgNh4k6ON_zsbP#1h>aB7O{OVu z{`1cRvp4@0CjT$Y;D1V5{1*oC{~HYA-~Vy)A83aE&&M~yq62d;OH@c;(~7|Z?KL?% zp;&5K%1Dk~W2^`+n)GvW7qs86WeL!D&=b(nUP&=LL6do{^uI`YnW@-w$IB9bmZurC ze3OVOo>cgd`27>K5L^^S@bB!<(7TvIBu&umg92y`4ejn9rr|$U@q|PuUgFYcwVi5U zyq9FB}J&z7U%MBFxD0Po%uhV^sn&Li%kqThsA>f%cpI2N`V|#Kjz;lA$V66n+hIgmYHE? n.version)).toEqual(['0.23.0', '0.22.0', '0.21.4', '0.21.3', '0.21.2', '0.21.1', '0.21.0', '0.20.0', '0.19.0','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']) + expect(RELEASE_NOTES.map((n) => n.version)).toEqual(['0.24.0', '0.23.0', '0.22.0', '0.21.4', '0.21.3', '0.21.2', '0.21.1', '0.21.0', '0.20.0', '0.19.0','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 98b2c439..e81b563b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "loop-studio", - "version": "0.23.0", + "version": "0.24.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "loop-studio", - "version": "0.23.0", + "version": "0.24.0", "dependencies": { "@fontsource/ibm-plex-mono": "^5.3.0", "@fontsource/ibm-plex-sans": "^5.3.0", diff --git a/package.json b/package.json index 869084b1..a649a522 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "loop-studio", "private": true, - "version": "0.23.0", + "version": "0.24.0", "type": "module", "engines": { "node": ">=22.12.0" diff --git a/scripts/arrow-units.json b/scripts/arrow-units.json index 23454437..ae808269 100644 --- a/scripts/arrow-units.json +++ b/scripts/arrow-units.json @@ -6,7 +6,7 @@ "mirroring": 4, "jsxSites": 1, "keepLiterals": 1, - "iconSites": 12, + "iconSites": 13, "catalogKeysMirror": 9, "catalogKeysKeep": 1, "catalogPairsPerLocale": 10, @@ -67,7 +67,7 @@ { "file": "src/components/dataImport/DataImportWizard.tsx", "unit": "external-link", "count": 1 }, { "file": "src/components/dataImport/DataImportWizard.tsx", "unit": "submenu", "count": 1 }, { "file": "src/components/mobile/MobileMoreMenu.tsx", "unit": "external-link", "count": 1 }, - { "file": "src/components/mobile/MobileMoreMenu.tsx", "unit": "submenu", "count": 4 }, + { "file": "src/components/mobile/MobileMoreMenu.tsx", "unit": "submenu", "count": 5 }, { "file": "src/components/ModelPanels.tsx", "unit": "submenu", "count": 1 }, { "file": "src/components/Toolbar.tsx", "unit": "undo", "count": 1 }, { "file": "src/components/Toolbar.tsx", "unit": "redo", "count": 1 } diff --git a/src/components/edges/LoopEdge.tsx b/src/components/edges/LoopEdge.tsx index 153e5f68..df57b6c3 100644 --- a/src/components/edges/LoopEdge.tsx +++ b/src/components/edges/LoopEdge.tsx @@ -299,10 +299,19 @@ function LoopEdge({ // issue #330 PR 1 — within the 24 token-and-badge pairs of the step const pbInCap = travelBudget.has(id) const pbCueOn = pbMoves && pbInCap - // the travelling dot and its `+N` badge — elided at L0 (§PB4.4). - const pbToken = pbCueOn && !atL0 - const pbFrac = travelFraction(pbLocalTau) + // issue #330 PR 3 (§PB6.1) — the step's tier, read once when it started: + // `full` the token travels with its `+N`; `fast` the token travels and `+N` + // shows on arrival; `veryFast` the path flashes, then the token appears at + // the end, with `+N`, at the arrive beat + const pbTier = transition?.tier ?? 'full' + const pbArriving = pbLocalPhase === 'arrive' + // the round dot and its `+N` badge — elided at L0 (§PB4.4). + const pbToken = pbCueOn && !atL0 && (pbTier !== 'veryFast' || pbArriving) + const pbFrac = pbTier === 'veryFast' ? 1 : travelFraction(pbLocalTau) const pbPt = pbToken ? pointOnPath(path, pbFrac) : null + const pbBadgeOn = pbPt != null && (pbTier === 'full' || pbArriving) + // the very fast tier's path flash, from the edge's onset to its arrive beat + const pbFlash = pbCueOn && pbTier === 'veryFast' && !pbArriving // issue #330 PR 1 — past the cap a moved edge keeps its arrival cue const pbArriveOn = pbMoves && pbLocalPhase === 'arrive' const pbEndPt = pbArriveOn ? pointOnPath(path, 1) : null @@ -310,7 +319,7 @@ function LoopEdge({ // step (whichever branch the Gate took, deterministic or probabilistic; a // branch with no move gets no cue), and every moved edge past the cap const sourceIsGate = gNodes.some((n) => n.id === source && n.data.kind === 'gate') - const pbPathOn = pbMoves && (sourceIsGate || !pbInCap) + const pbPathOn = pbMoves && (sourceIsGate || !pbInCap || pbFlash) const pbBadge = badgeText(pbFlow) const pbAll = selected && transition && !isState ? transition.events.filter((e) => e.edgeId === id) : [] const pbBreakdown = pbAll.slice(0, MAX_PLAYBACK_TOKENS) @@ -395,7 +404,7 @@ function LoopEdge({ }, [labelKey]) const labelBox = showLabel && labelSize ? { x: labelX, y: labelY, ...labelSize } : null const labelDimmed = - (pbPt != null && labelUnderToken(labelBox, pbPt.x, pbPt.y, pbBadge)) || + (pbPt != null && labelUnderToken(labelBox, pbPt.x, pbPt.y, pbBadgeOn ? pbBadge : null)) || (rmBadgePt != null && labelUnderToken(labelBox, rmBadgePt.x, rmBadgePt.y, badgeText(rmBadgeAmount), 'left')) // docs/flow-colour-and-compact-nodes.md FC-4.2 — the edge's flow colour is @@ -533,7 +542,7 @@ function LoopEdge({ that moved this step, or any moved edge past the 24 pairs */} {pbPathOn ? ( - {/* emit: an OUTWARD burst at the source handle (§PBO3) */} - {pbLocalPhase === 'depart' ? ( + {/* emit: an OUTWARD burst at the source handle (§PBO3); not drawn on + the phone profile (issue #330 PR 3, docs/mobile.md §MV4) */} + {pbLocalPhase === 'depart' && transition?.profile !== 'phone' ? ( a.x0 < b.x1 && b.x0 < a.x1 && a.y0 < b.y1 && b.y0 < a.y1 /** whether a label box (centred on the label point, `w` × `h`) meets the token - * or its badge */ + * or its badge; `text` null ⇒ a token without its badge (issue #330 PR 3: the + * fast tier shows `+N` only on arrival) */ export function labelUnderToken( label: { x: number; y: number; w: number; h: number } | null, x: number, y: number, - text: string, + text: string | null, side: BadgeSide = 'right', ): boolean { if (!label || label.w <= 0) return false const L: Box = { x0: label.x - label.w / 2, y0: label.y - label.h / 2, x1: label.x + label.w / 2, y1: label.y + label.h / 2 } - return tokenAndBadgeBoxes(x, y, text, side).some((b) => overlaps(L, b)) + const [token, badge] = tokenAndBadgeBoxes(x, y, text ?? '', side) + return overlaps(L, token) || (text != null && overlaps(L, badge)) } diff --git a/src/components/edges/playbackBudget.ts b/src/components/edges/playbackBudget.ts index e909d1e0..f2dcf100 100644 --- a/src/components/edges/playbackBudget.ts +++ b/src/components/edges/playbackBudget.ts @@ -1,5 +1,5 @@ import { useSimStore } from '../../store/simStore' -import { MAX_PLAYBACK_TOKENS_TOTAL } from './playback-caps' +import { MAX_PLAYBACK_TOKENS_PHONE, MAX_PLAYBACK_TOKENS_TOTAL } from './playback-caps' /** the in-flight transition, non-null. `flowByEdge` / `events` / `stateEvents` * are set once per transition and kept by reference across every τ tick, so @@ -53,17 +53,19 @@ function budgetSet(t: Transition): ReadonlySet { t.events.forEach((e, i) => { if (!(e.edgeId in firstEv)) firstEv[e.edgeId] = i }) - const set = pick(t.flowByEdge, t.stateEvents, firstEv) + // issue #330 PR 3 — the phone profile (fixed when the step started) takes the + // first 12 of the same order + const set = pick(t.flowByEdge, t.stateEvents, firstEv, t.profile === 'phone' ? MAX_PLAYBACK_TOKENS_PHONE : MAX_PLAYBACK_TOKENS_TOTAL) cache = { key: t.flowByEdge, set } return set } -/** the stable choice itself: the first `MAX_PLAYBACK_TOKENS_TOTAL` candidates - * in the fixed key order */ +/** the stable choice itself: the first `cap` candidates in the fixed key order */ function pick( flowByEdge: Record, stateEvents: Transition['stateEvents'], firstEv: Record, + cap: number = MAX_PLAYBACK_TOKENS_TOTAL, ): ReadonlySet { const cand: { edgeId: string; rank: number; ord: number }[] = [] for (const edgeId of Object.keys(flowByEdge)) { @@ -84,7 +86,7 @@ function pick( const set = new Set() for (const c of cand) { - if (set.size >= MAX_PLAYBACK_TOKENS_TOTAL) break + if (set.size >= cap) break set.add(c.edgeId) } return set diff --git a/src/components/mobile/MobileMoreMenu.tsx b/src/components/mobile/MobileMoreMenu.tsx index bf1c5b99..be67aafa 100644 --- a/src/components/mobile/MobileMoreMenu.tsx +++ b/src/components/mobile/MobileMoreMenu.tsx @@ -11,6 +11,7 @@ import { useGraphStore } from '../../store/graphStore' import { useMcStore } from '../../store/mcStore' import { useProjectStore } from '../../store/projectStore' import { useSimStore } from '../../store/simStore' +import { PHONE_SPEEDS } from '../../store/playbackTier' import { selectOverlay, useUiStore } from '../../store/uiStore' import { decideWorkspaceExport, @@ -24,7 +25,7 @@ import { type ProtectedShareLinkResult, copyShareLink, prepareProtectedShareLink import { useTourStore } from '../../store/tourStore' import { useWhatsNewStore } from '../../store/whatsNewStore' import { useHintStore, useTier3Ready, useLargeGraphInteractionGate } from '../../store/hintStore' -import { useT } from '../../i18n' +import { type MessageKey, useT } from '../../i18n' import { AboutDialog } from '../AboutDialog' import { AuthorDialog } from '../AuthorDialog' import { ConfirmDialog } from '../ConfirmDialog' @@ -39,7 +40,7 @@ import { WhatsNewPanel } from '../WhatsNewPanel' import { StoragePrivacyDialog } from '../StoragePrivacyDialog' import { ShareCreateDialog } from '../toolbar/ShareCreateDialog' import { selectTemporary, useSessionStore } from '../../store/sessionStore' -import { ArrowIcon } from '../../ui/icons' +import { ArrowIcon, Icon } from '../../ui/icons' // docs/localization.md Slice 2b — Templates replace, the Project-revision // disclosure, and the Workspace-JSON summary are in-app ConfirmDialogs now; @@ -53,6 +54,13 @@ type PendingConfirm = { title: string; body: string; confirmLabel: string; run: // the desktop menus. const MiB = (n: number) => `${(n / (1024 * 1024)).toFixed(1)} MiB` +// issue #330 PR 3 — each phone speed's name and per-step time, as literal keys +const SPEED_KEYS = { + slow: ['mobile.speed.slow', 'mobile.speed.slow.time'], + normal: ['mobile.speed.normal', 'mobile.speed.normal.time'], + fast: ['mobile.speed.fast', 'mobile.speed.fast.time'], + veryFast: ['mobile.speed.veryFast', 'mobile.speed.veryFast.time'], +} as const satisfies Record<(typeof PHONE_SPEEDS)[number]['key'], readonly [MessageKey, MessageKey]> // a sub-sheet returns focus to the top bar's More button when it closes const backToMore = () => document.querySelector('.mob-more') @@ -99,6 +107,9 @@ export function MobileMoreMenu({ }).length, ) >= WORTH_IT_FLOOR const t = useT() + // issue #330 PR 3 — the current speed's step, if it is one of the four + const speedMs = useSimStore((s) => s.speedMs) + const speedChoice = PHONE_SPEEDS.find((s) => s.ms === speedMs) ?? null // §L9.3 — a direction-aware CHARACTER from the shared table, never a transform const { fitView } = useReactFlow() @@ -410,6 +421,15 @@ export function MobileMoreMenu({ + {/* docs/mobile.md §MV4 (issue #330 PR 3) — the playback speed: four + steps in a sub-sheet, never a slider in the run bar */} + {/* docs/…-auto-frames.md §AF-INV-7 — on mobile, "Suggest frames" is a More-sheet action (no canvas control); auto frames still render. */} {(suggestEligible || autoFramesExist) && ( @@ -591,6 +611,40 @@ export function MobileMoreMenu({ ) } + // docs/mobile.md §MV4 (issue #330 PR 3) — the mobile Playback speed + // sub-sheet: Slow 1000, Normal 600, Fast 300, Very fast 120 ms a step, each + // in exactly one tier (§PB6.1). The same session-only `speedMs` as the + // desktop slider; a choice closes the sheets so the run shows at once. + if (overlay === 'speed') { + return ( + closeOverlay('speed')} + onEscape={backToMoreFrom('speed')} + returnFocusTo={backToMore} + > + {PHONE_SPEEDS.map((s) => ( + + ))} + + ) + } + // docs/large-graph-readability.md §LGR3.2 / §LGR9 — the mobile Filters // sub-sheet. Same ephemeral `filterStore` as desktop. if (overlay === 'filter') { diff --git a/src/i18n/es419Copy.test.ts b/src/i18n/es419Copy.test.ts index 06e92509..efd185d8 100644 --- a/src/i18n/es419Copy.test.ts +++ b/src/i18n/es419Copy.test.ts @@ -289,7 +289,7 @@ describe('es-419 copy — mechanical review', () => { Ctrl: ['toolbar.undo.title', 'toolbar.redo.title'], Cmd: ['toolbar.undo.title', 'toolbar.redo.title'], Z: ['toolbar.undo.title', 'toolbar.redo.title'], - N: ['import.ignoreLastRows', 'import.issue.invalid-ignore-rows', 'import.issue.ragged-row', 'whatsNew.v0220.badge'], + N: ['import.ignoreLastRows', 'import.issue.invalid-ignore-rows', 'import.issue.ragged-row', 'whatsNew.v0220.badge', 'whatsNew.v0240.tiers'], // the sample CSV's own column headers, shown as data item: ['import.qs.mapping'], name: ['import.qs.mapping'], @@ -337,6 +337,10 @@ describe('es-419 copy — mechanical review', () => { x: ['canvas.frame.a11y.moved', 'rf.node.moveCancelled', 'rf.node.moved'], y: ['canvas.frame.a11y.moved', 'rf.node.moveCancelled', 'rf.node.moved'], rev: ['revChip.rev'], + // issue #330 PR 3 — the Spanish word IS `Normal` (the phone's Normal + // speed), and `s` is the seconds symbol of the four per-step times + Normal: ['mobile.speed.normal', 'whatsNew.v0240.phone'], + s: ['mobile.speed.slow.time', 'mobile.speed.normal.time', 'mobile.speed.fast.time', 'mobile.speed.veryFast.time', 'whatsNew.v0240.tiers'], } const WORD = /[A-Za-zÀ-ÖØ-öø-ÿ]+/gu diff --git a/src/i18n/itCopy.test.ts b/src/i18n/itCopy.test.ts index f1bc71ef..144373f9 100644 --- a/src/i18n/itCopy.test.ts +++ b/src/i18n/itCopy.test.ts @@ -205,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(1052) // issue #330 PR 2: +3 (three 0.23.0 release-note lines); issue #330 PR 1: +5 (five 0.22.0 release-note lines); 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); the flow colour: +20 (the sixteen strings of the Colour section and the four 0.19.0 release-note lines); the flow colour views: +4 (the four 0.20.0 release-note lines); compact nodes: +3 (the three 0.21.0 release-note lines); Focus dimming: +3 (the three 0.21.1 release-note lines); value rows: +3 (the three 0.21.2 release-note lines); the edit lock: +4 (canvas.lock.name, its fixed accessible name, and the three 0.21.3 release-note lines); the locked rail and the phone search: +4 (the four 0.21.4 release-note lines) + expect(KEYS).toHaveLength(1064) // issue #330 PR 3: +3 (three 0.24.0 release-note lines); issue #330 PR 3: +9 (the phone Playback speed sheet); issue #330 PR 2: +3 (three 0.23.0 release-note lines); issue #330 PR 1: +5 (five 0.22.0 release-note lines); 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); the flow colour: +20 (the sixteen strings of the Colour section and the four 0.19.0 release-note lines); the flow colour views: +4 (the four 0.20.0 release-note lines); compact nodes: +3 (the three 0.21.0 release-note lines); Focus dimming: +3 (the three 0.21.1 release-note lines); value rows: +3 (the three 0.21.2 release-note lines); the edit lock: +4 (canvas.lock.name, its fixed accessible name, and the three 0.21.3 release-note lines); the locked rail and the phone search: +4 (the four 0.21.4 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: 1052, template: 196, frame: 7, module: 19 }) - expect(RUNTIME).toHaveLength(1274) + expect(per).toEqual({ catalog: 1064, template: 196, frame: 7, module: 19 }) + expect(RUNTIME).toHaveLength(1286) }) it('EVERY row has a non-empty ENGLISH side — the vacuity guard', () => { diff --git a/src/i18n/locales/ar/ui.ts b/src/i18n/locales/ar/ui.ts index 5751400f..38740118 100644 --- a/src/i18n/locales/ar/ui.ts +++ b/src/i18n/locales/ar/ui.ts @@ -190,6 +190,15 @@ const ui = { 'import.modelLayerUnreadable': 'محتوى طبقة النموذج في هذا الملف غير قابل للقراءة ({detail})؛ وتُجوهلت بيانات مشروعه.', 'mobile.more.import': 'استيراد ملف', 'mobile.more.importSub': 'JSON المخطط أو مساحة العمل', + 'mobile.speed.rowLabel': 'سرعة التشغيل', + 'mobile.speed.slow': 'بطيئة', + 'mobile.speed.normal': 'عادية', + 'mobile.speed.fast': 'سريعة', + 'mobile.speed.veryFast': 'سريعة جدًا', + 'mobile.speed.slow.time': 'ثانية واحدة لكل خطوة', + 'mobile.speed.normal.time': '0.6 ثانية لكل خطوة', + 'mobile.speed.fast.time': '0.3 ثانية لكل خطوة', + 'mobile.speed.veryFast.time': '0.12 ثانية لكل خطوة', 'export.button': 'تصدير', 'export.menuLabel': 'تصدير', 'export.graphJson.name': 'JSON المخطط', @@ -613,6 +622,9 @@ const ui = { 'whatsNew.v0230.pulse': 'عندما تصل علامة متحركة إلى مَجمَع يضيء داخل المَجمَع الآن لحظة قصيرة، ويبقى الرقم يتغير في نهاية الخطوة كما كان.', 'whatsNew.v0230.mark': 'أثناء تحرك علامات المُحوِّل تظهر داخله إشارة صغيرة بسهمين، ثم تختفي في نهاية الخطوة.', 'whatsNew.v0230.focus': 'عند تشغيل التركيز تحتفظ هاتان الإشارتان بكامل قوتهما، حتى في العُقد خارج التركيز.', + 'whatsNew.v0240.tiers': 'أصبحت سرعة التشغيل تغيّر أيضًا ما تعرضه الخطوة: من 0.4 ثانية للخطوة فأكثر تتحرك العلامة ومعها +N، وأسرع من ذلك يظهر +N عند الوصول، وتحت 0.2 ثانية يومض المسار ثم تظهر العلامة في نهايته.', + 'whatsNew.v0240.step': 'التقدّم خطوة واحدة يعرض الحركة كاملة دائمًا، مهما كانت السرعة المختارة.', + 'whatsNew.v0240.phone': 'على الهاتف أصبحت قائمة المزيد تضم سرعة التشغيل (بطيئة أو عادية أو سريعة أو سريعة جدًا)، وتتحرك 12 علامة على الأكثر في الوقت نفسه.', } as const export default ui diff --git a/src/i18n/locales/de/ui.ts b/src/i18n/locales/de/ui.ts index 42ecae12..27ffdfe7 100644 --- a/src/i18n/locales/de/ui.ts +++ b/src/i18n/locales/de/ui.ts @@ -191,6 +191,15 @@ const ui = { 'Der Inhalt der Modellebene dieser Datei ist nicht lesbar ({detail}); ihre Projektdaten wurden ignoriert.', 'mobile.more.import': 'Datei importieren', 'mobile.more.importSub': 'Graph oder Workspace JSON', + 'mobile.speed.rowLabel': 'Wiedergabetempo', + 'mobile.speed.slow': 'Langsam', + 'mobile.speed.normal': 'Normal', + 'mobile.speed.fast': 'Schnell', + 'mobile.speed.veryFast': 'Sehr schnell', + 'mobile.speed.slow.time': '1 s pro Schritt', + 'mobile.speed.normal.time': '0,6 s pro Schritt', + 'mobile.speed.fast.time': '0,3 s pro Schritt', + 'mobile.speed.veryFast.time': '0,12 s pro Schritt', 'export.button': 'Exportieren', 'export.menuLabel': 'Exportieren', 'export.graphJson.name': 'Graph JSON', @@ -648,6 +657,9 @@ const ui = { 'whatsNew.v0230.pulse': 'Wenn eine bewegliche Markierung einen Speicher erreicht, leuchtet sein Inneres jetzt kurz auf; die Zahl ändert sich weiterhin erst am Ende des Schritts.', 'whatsNew.v0230.mark': 'Solange sich die Markierungen eines Konverters bewegen, erscheint in ihm ein kleines Zeichen mit zwei Pfeilen, das am Ende des Schritts verblasst.', 'whatsNew.v0230.focus': 'Bei eingeschaltetem Fokus behalten diese beiden Zeichen ihre volle Stärke, auch in Knoten außerhalb des Fokus.', + 'whatsNew.v0240.tiers': 'Das Wiedergabetempo bestimmt jetzt auch, was ein Schritt zeigt: ab 0,4 s pro Schritt wandert die Markierung mit ihrem +N; schneller erscheint +N erst bei der Ankunft; unter 0,2 s leuchtet der Weg kurz auf und die Markierung erscheint an seinem Ende.', + 'whatsNew.v0240.step': 'Einen Schritt weiter zeigt immer die ganze Bewegung, egal welches Tempo eingestellt ist.', + 'whatsNew.v0240.phone': 'Auf dem Smartphone bietet „Mehr“ jetzt das Wiedergabetempo (Langsam, Normal, Schnell oder Sehr schnell), und höchstens 12 Markierungen bewegen sich gleichzeitig.', } satisfies Record export default ui diff --git a/src/i18n/locales/en/ui.ts b/src/i18n/locales/en/ui.ts index 3fd14727..6f9ca510 100644 --- a/src/i18n/locales/en/ui.ts +++ b/src/i18n/locales/en/ui.ts @@ -157,6 +157,15 @@ const ui = { 'import.modelLayerUnreadable': "This file's model-layer content is not readable ({detail}); its project data was ignored.", 'mobile.more.import': 'Import file', 'mobile.more.importSub': 'Graph or Workspace JSON', + 'mobile.speed.rowLabel': 'Playback speed', + 'mobile.speed.slow': 'Slow', + 'mobile.speed.normal': 'Normal', + 'mobile.speed.fast': 'Fast', + 'mobile.speed.veryFast': 'Very fast', + 'mobile.speed.slow.time': '1 s a step', + 'mobile.speed.normal.time': '0.6 s a step', + 'mobile.speed.fast.time': '0.3 s a step', + 'mobile.speed.veryFast.time': '0.12 s a step', 'export.button': 'Export', 'export.menuLabel': 'Export', 'export.graphJson.name': 'Graph JSON', @@ -557,6 +566,9 @@ const ui = { 'whatsNew.v0230.pulse': 'When a moving marker reaches a Pool, the inside of the Pool now briefly lights up; its number still changes when the step ends.', 'whatsNew.v0230.mark': 'While a Converter’s markers are moving, a small two-arrow sign appears inside it and fades when the step ends.', 'whatsNew.v0230.focus': 'With Focus on, these two signs keep their full strength, even in nodes outside the focus.', + 'whatsNew.v0240.tiers': 'Playback speed now also changes what a step shows: at 0.4 s a step or slower, the moving marker carries its +N; faster, +N appears when it arrives; under 0.2 s, the path flashes and the marker appears at its end.', + 'whatsNew.v0240.step': 'Advancing one step always shows the full movement, whatever the speed is set to.', + 'whatsNew.v0240.phone': 'On a phone, More now has Playback speed (Slow, Normal, Fast or Very fast), and at most 12 markers move at once.', } 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 4b9d9b97..71252d84 100644 --- a/src/i18n/locales/es-419/ui.ts +++ b/src/i18n/locales/es-419/ui.ts @@ -161,6 +161,15 @@ const ui = { 'import.modelLayerUnreadable': 'El contenido de la capa de modelo de este archivo no se puede leer ({detail}); sus datos de proyecto se ignoraron.', 'mobile.more.import': 'Importar un archivo', 'mobile.more.importSub': 'Graph o Workspace JSON', + 'mobile.speed.rowLabel': 'Velocidad de reproducción', + 'mobile.speed.slow': 'Lenta', + 'mobile.speed.normal': 'Normal', + 'mobile.speed.fast': 'Rápida', + 'mobile.speed.veryFast': 'Muy rápida', + 'mobile.speed.slow.time': '1 s por paso', + 'mobile.speed.normal.time': '0,6 s por paso', + 'mobile.speed.fast.time': '0,3 s por paso', + 'mobile.speed.veryFast.time': '0,12 s por paso', 'export.button': 'Exportar', 'export.menuLabel': 'Exportar', 'export.graphJson.name': 'Graph JSON', @@ -571,6 +580,9 @@ const ui = { 'whatsNew.v0230.pulse': 'Cuando una marca en movimiento llega a un Depósito, el interior del Depósito ahora se ilumina un momento; su valor sigue cambiando al final del paso.', 'whatsNew.v0230.mark': 'Mientras se mueven las marcas de un Convertidor, aparece dentro de él un pequeño signo de dos flechas, que se desvanece al final del paso.', 'whatsNew.v0230.focus': 'Con el Enfoque activado, estos dos signos mantienen toda su intensidad, incluso en los nodos fuera del enfoque.', + 'whatsNew.v0240.tiers': 'Ahora la velocidad de reproducción también cambia lo que muestra un paso: desde 0,4 s por paso, la marca en movimiento lleva su +N; más rápido, el +N aparece al llegar; por debajo de 0,2 s, el camino se ilumina un momento y la marca aparece al final.', + 'whatsNew.v0240.step': 'Avanzar un paso siempre muestra todo el movimiento, sea cual sea la velocidad elegida.', + 'whatsNew.v0240.phone': 'En un teléfono, el menú Más ofrece ahora la velocidad de reproducción (Lenta, Normal, Rápida o Muy rápida), y se mueven como máximo 12 marcas a la vez.', } 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 48763862..dd20e523 100644 --- a/src/i18n/locales/es-ES/ui.ts +++ b/src/i18n/locales/es-ES/ui.ts @@ -175,6 +175,15 @@ const ui = { 'import.modelLayerUnreadable': 'El contenido de la capa de modelo de este archivo no se puede leer ({detail}); sus datos de proyecto se ignoraron.', 'mobile.more.import': 'Importar un archivo', 'mobile.more.importSub': 'Graph o Workspace JSON', + 'mobile.speed.rowLabel': 'Velocidad de reproducción', + 'mobile.speed.slow': 'Lenta', + 'mobile.speed.normal': 'Normal', + 'mobile.speed.fast': 'Rápida', + 'mobile.speed.veryFast': 'Muy rápida', + 'mobile.speed.slow.time': '1 s por paso', + 'mobile.speed.normal.time': '0,6 s por paso', + 'mobile.speed.fast.time': '0,3 s por paso', + 'mobile.speed.veryFast.time': '0,12 s por paso', 'export.button': 'Exportar', 'export.menuLabel': 'Exportar', 'export.graphJson.name': 'Graph JSON', @@ -585,6 +594,9 @@ const ui = { 'whatsNew.v0230.pulse': 'Cuando una marca en movimiento llega a un Depósito, el interior del Depósito ahora se ilumina un momento; su valor sigue cambiando al final del paso.', 'whatsNew.v0230.mark': 'Mientras se mueven las marcas de un Convertidor, aparece dentro de él un pequeño signo de dos flechas, que se desvanece al final del paso.', 'whatsNew.v0230.focus': 'Con el Enfoque activado, estos dos signos mantienen toda su intensidad, incluso en los nodos fuera del enfoque.', + 'whatsNew.v0240.tiers': 'Ahora la velocidad de reproducción también cambia lo que muestra un paso: desde 0,4 s por paso, la marca en movimiento lleva su +N; más rápido, el +N aparece al llegar; por debajo de 0,2 s, el camino se ilumina un momento y la marca aparece al final.', + 'whatsNew.v0240.step': 'Avanzar un paso siempre muestra todo el movimiento, sea cual sea la velocidad elegida.', + 'whatsNew.v0240.phone': 'En un teléfono, el menú Más ofrece ahora la velocidad de reproducción (Lenta, Normal, Rápida o Muy rápida), y se mueven como máximo 12 marcas a la vez.', } 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 cef3e7fa..ea4d88af 100644 --- a/src/i18n/locales/fr/ui.ts +++ b/src/i18n/locales/fr/ui.ts @@ -183,6 +183,15 @@ const ui = { 'Le contenu de la couche modèle de ce fichier est illisible ({detail}) ; ses données de projet ont été ignorées.', 'mobile.more.import': 'Importer un fichier', 'mobile.more.importSub': 'Graph ou Workspace JSON', + 'mobile.speed.rowLabel': 'Vitesse de lecture', + 'mobile.speed.slow': 'Lente', + 'mobile.speed.normal': 'Normale', + 'mobile.speed.fast': 'Rapide', + 'mobile.speed.veryFast': 'Très rapide', + 'mobile.speed.slow.time': '1 s par étape', + 'mobile.speed.normal.time': '0,6 s par étape', + 'mobile.speed.fast.time': '0,3 s par étape', + 'mobile.speed.veryFast.time': '0,12 s par étape', 'export.button': 'Exporter', 'export.menuLabel': 'Exporter', 'export.graphJson.name': 'Graph JSON', @@ -642,6 +651,9 @@ const ui = { 'whatsNew.v0230.pulse': 'Quand un repère mobile atteint un Réservoir, l’intérieur du Réservoir s’éclaire désormais brièvement. Sa valeur change toujours à la fin de l’étape.', 'whatsNew.v0230.mark': 'Pendant que les repères d’un Convertisseur se déplacent, un petit signe à deux flèches apparaît à l’intérieur, puis s’estompe à la fin de l’étape.', 'whatsNew.v0230.focus': 'Avec le Focus activé, ces deux signes gardent toute leur intensité, même dans les nœuds hors du focus.', + 'whatsNew.v0240.tiers': 'La vitesse de lecture change désormais aussi ce qu’une étape montre. À partir de 0,4 s par étape, le repère mobile porte son +N. Plus vite, le +N apparaît à l’arrivée. Sous 0,2 s, le chemin s’allume brièvement et le repère apparaît à son extrémité.', + 'whatsNew.v0240.step': 'Avancer d’un pas montre toujours tout le mouvement, quelle que soit la vitesse choisie.', + 'whatsNew.v0240.phone': 'Sur un téléphone, le menu Plus propose désormais la vitesse de lecture (Lente, Normale, Rapide ou Très rapide), et 12 repères au plus se déplacent à la fois.', } satisfies Record export default ui diff --git a/src/i18n/locales/it/ui.ts b/src/i18n/locales/it/ui.ts index 75e891cd..c44ab45a 100644 --- a/src/i18n/locales/it/ui.ts +++ b/src/i18n/locales/it/ui.ts @@ -183,6 +183,15 @@ const ui = { 'import.modelLayerUnreadable': 'Il contenuto del livello di modello di questo file non è leggibile ({detail}); i suoi dati di progetto sono stati ignorati.', 'mobile.more.import': 'Importa un file', 'mobile.more.importSub': 'Grafo o Spazio di lavoro JSON', + 'mobile.speed.rowLabel': 'Velocità di riproduzione', + 'mobile.speed.slow': 'Lenta', + 'mobile.speed.normal': 'Normale', + 'mobile.speed.fast': 'Veloce', + 'mobile.speed.veryFast': 'Molto veloce', + 'mobile.speed.slow.time': '1 s per passo', + 'mobile.speed.normal.time': '0,6 s per passo', + 'mobile.speed.fast.time': '0,3 s per passo', + 'mobile.speed.veryFast.time': '0,12 s per passo', 'export.button': 'Esporta', 'export.menuLabel': 'Esporta', 'export.graphJson.name': 'Grafo JSON', @@ -587,6 +596,9 @@ const ui = { 'whatsNew.v0230.pulse': 'Quando un segno in movimento raggiunge un Serbatoio, l’interno del Serbatoio ora si illumina per un attimo; il valore cambia sempre alla fine del passo.', 'whatsNew.v0230.mark': 'Mentre i segni di un Convertitore si spostano, al suo interno compare un piccolo segno a due frecce, che svanisce alla fine del passo.', 'whatsNew.v0230.focus': 'Con la messa a fuoco attiva, questi due segni restano a piena intensità, anche nei nodi fuori fuoco.', + 'whatsNew.v0240.tiers': 'Ora la velocità di riproduzione cambia anche ciò che un passo mostra: da 0,4 s per passo in su, il segno in movimento porta il suo +N; più veloce, il +N compare all’arrivo; sotto 0,2 s, il percorso si illumina per un attimo e il segno compare alla fine.', + 'whatsNew.v0240.step': 'Avanzare di un passo mostra sempre tutto il movimento, qualunque sia la velocità scelta.', + 'whatsNew.v0240.phone': 'Sul telefono, il menu Altro offre ora la velocità di riproduzione (Lenta, Normale, Veloce o Molto veloce), e al massimo 12 segni si spostano alla volta.', } satisfies Record export default ui diff --git a/src/i18n/locales/ja/ui.ts b/src/i18n/locales/ja/ui.ts index 3afd4039..a44fcf46 100644 --- a/src/i18n/locales/ja/ui.ts +++ b/src/i18n/locales/ja/ui.ts @@ -167,6 +167,15 @@ const ui = { 'import.modelLayerUnreadable': 'このファイルのモデル層の内容を読み取れないため({detail})、プロジェクトデータは無視しました。', 'mobile.more.import': 'ファイルをインポート', 'mobile.more.importSub': 'グラフまたはワークスペースの JSON', + 'mobile.speed.rowLabel': '再生速度', + 'mobile.speed.slow': '遅い', + 'mobile.speed.normal': '標準', + 'mobile.speed.fast': '速い', + 'mobile.speed.veryFast': 'とても速い', + 'mobile.speed.slow.time': '1ステップあたり1秒', + 'mobile.speed.normal.time': '1ステップあたり0.6秒', + 'mobile.speed.fast.time': '1ステップあたり0.3秒', + 'mobile.speed.veryFast.time': '1ステップあたり0.12秒', 'export.button': 'エクスポート', 'export.menuLabel': 'エクスポート', 'export.graphJson.name': 'グラフ JSON', @@ -592,6 +601,9 @@ const ui = { 'whatsNew.v0230.pulse': '動く印がプールに届くと、プールの内側が一瞬明るくなるようになりました。数値はこれまでどおりステップの終わりに変わります。', 'whatsNew.v0230.mark': 'コンバーターの印が動いている間、その内側に小さな双方向の矢印が表示され、ステップの終わりに消えます。', 'whatsNew.v0230.focus': 'フォーカスがオンでも、この2つの表示はフォーカス外のノードでも淡くなりません。', + 'whatsNew.v0240.tiers': '再生速度によって、ステップの見え方も変わるようになりました。1ステップあたり0.4秒以上では動く印が+Nと一緒に進み、それより速いと+Nは到着時に表示され、0.2秒未満では経路が一瞬光ったあと印が終点に表示されます。', + 'whatsNew.v0240.step': '1 ステップ進めると、速度の設定にかかわらず常に動き全体が表示されます。', + 'whatsNew.v0240.phone': 'スマートフォンの「その他」メニューに再生速度(遅い・標準・速い・とても速い)が加わり、同時に動く印は最大12個になりました。', } satisfies Record export default ui diff --git a/src/i18n/locales/ko/ui.ts b/src/i18n/locales/ko/ui.ts index ef6f78c5..d666dc8e 100644 --- a/src/i18n/locales/ko/ui.ts +++ b/src/i18n/locales/ko/ui.ts @@ -160,6 +160,15 @@ const ui = { 'import.modelLayerUnreadable': '이 파일의 모델 계층 내용을 읽을 수 없어 ({detail}) 프로젝트 데이터는 무시했습니다.', 'mobile.more.import': '파일 가져오기', 'mobile.more.importSub': 'Graph 또는 Workspace JSON', + 'mobile.speed.rowLabel': '재생 속도', + 'mobile.speed.slow': '느리게', + 'mobile.speed.normal': '보통', + 'mobile.speed.fast': '빠르게', + 'mobile.speed.veryFast': '매우 빠르게', + 'mobile.speed.slow.time': '단계당 1초', + 'mobile.speed.normal.time': '단계당 0.6초', + 'mobile.speed.fast.time': '단계당 0.3초', + 'mobile.speed.veryFast.time': '단계당 0.12초', 'export.button': '내보내기', 'export.menuLabel': '내보내기', 'export.graphJson.name': '그래프 JSON', @@ -560,6 +569,9 @@ const ui = { 'whatsNew.v0230.pulse': '움직이는 표시가 풀에 닿으면 이제 풀 안쪽이 잠깐 밝아지며, 숫자는 그대로 단계가 끝날 때 바뀝니다.', 'whatsNew.v0230.mark': '컨버터의 표시가 움직이는 동안 그 안에 작은 양방향 화살표 표시가 나타나고, 단계가 끝나면 사라집니다.', 'whatsNew.v0230.focus': 'Focus를 켜도 이 두 표시는 집중 범위 밖의 노드에서도 흐려지지 않습니다.', + 'whatsNew.v0240.tiers': '이제 재생 속도에 따라 단계의 모습도 바뀝니다. 단계당 0.4초 이상이면 움직이는 표시가 +N과 함께 이동하고, 더 빠르면 +N이 도착할 때 나타나며, 0.2초보다 빠르면 경로가 잠깐 밝아진 뒤 표시가 끝에 나타납니다.', + 'whatsNew.v0240.step': '한 단계 진행은 속도 설정과 관계없이 항상 전체 움직임을 보여 줍니다.', + 'whatsNew.v0240.phone': '휴대폰의 더 보기 메뉴에 재생 속도(느리게, 보통, 빠르게, 매우 빠르게)가 생겼고, 한 번에 움직이는 표시는 최대 12개입니다.', } satisfies Record export default ui diff --git a/src/i18n/locales/nl/ui.ts b/src/i18n/locales/nl/ui.ts index 4022db96..1e28c801 100644 --- a/src/i18n/locales/nl/ui.ts +++ b/src/i18n/locales/nl/ui.ts @@ -167,6 +167,15 @@ const ui = { 'import.modelLayerUnreadable': 'De inhoud van de modellaag van dit bestand is niet leesbaar ({detail}); de projectgegevens zijn genegeerd.', 'mobile.more.import': 'Bestand importeren', 'mobile.more.importSub': 'Diagram- of Werkruimte-JSON', + 'mobile.speed.rowLabel': 'Afspeelsnelheid', + 'mobile.speed.slow': 'Langzaam', + 'mobile.speed.normal': 'Normaal', + 'mobile.speed.fast': 'Snel', + 'mobile.speed.veryFast': 'Heel snel', + 'mobile.speed.slow.time': '1 s per stap', + 'mobile.speed.normal.time': '0,6 s per stap', + 'mobile.speed.fast.time': '0,3 s per stap', + 'mobile.speed.veryFast.time': '0,12 s per stap', 'export.button': 'Exporteren', 'export.menuLabel': 'Exporteren', 'export.graphJson.name': 'Diagram-JSON', @@ -568,6 +577,9 @@ const ui = { 'whatsNew.v0230.pulse': 'Als een bewegende markering een Voorraad bereikt, licht de binnenkant van die Voorraad nu even op; het getal verandert nog steeds pas aan het eind van de stap.', 'whatsNew.v0230.mark': 'Zolang de markeringen van een Omzetter bewegen, verschijnt erin een klein teken met twee pijlen, dat aan het eind van de stap vervaagt.', 'whatsNew.v0230.focus': 'Met Focus aan houden deze twee tekens hun volle sterkte, ook in knooppunten buiten de focus.', + 'whatsNew.v0240.tiers': 'De afspeelsnelheid bepaalt nu ook wat een stap laat zien: vanaf 0,4 s per stap reist de markering met haar +N mee; sneller verschijnt +N bij aankomst; onder 0,2 s licht het pad even op en verschijnt de markering aan het eind.', + 'whatsNew.v0240.step': 'Eén stap vooruit laat altijd de hele beweging zien, welke snelheid er ook is ingesteld.', + 'whatsNew.v0240.phone': 'Op een telefoon biedt Meer nu een afspeelsnelheid (Langzaam, Normaal, Snel of Heel snel), en bewegen hooguit 12 markeringen tegelijk.', } as const export default ui diff --git a/src/i18n/locales/pt-BR/ui.ts b/src/i18n/locales/pt-BR/ui.ts index 59af1380..fd3d4bb0 100644 --- a/src/i18n/locales/pt-BR/ui.ts +++ b/src/i18n/locales/pt-BR/ui.ts @@ -170,6 +170,15 @@ const ui = { 'import.modelLayerUnreadable': 'O conteúdo da camada de modelo deste arquivo não é legível ({detail}); os dados de projeto dele foram ignorados.', 'mobile.more.import': 'Importar arquivo', 'mobile.more.importSub': 'Grafo ou Espaço de trabalho JSON', + 'mobile.speed.rowLabel': 'Velocidade de reprodução', + 'mobile.speed.slow': 'Lenta', + 'mobile.speed.normal': 'Normal', + 'mobile.speed.fast': 'Rápida', + 'mobile.speed.veryFast': 'Muito rápida', + 'mobile.speed.slow.time': '1 s por passo', + 'mobile.speed.normal.time': '0,6 s por passo', + 'mobile.speed.fast.time': '0,3 s por passo', + 'mobile.speed.veryFast.time': '0,12 s por passo', 'export.button': 'Exportar', 'export.menuLabel': 'Exportar', 'export.graphJson.name': 'Grafo JSON', @@ -573,6 +582,9 @@ const ui = { 'whatsNew.v0230.pulse': 'Quando uma marca em movimento chega a um Reservatório, o interior do Reservatório agora acende por um instante; o valor só muda no fim do passo, como antes.', 'whatsNew.v0230.mark': 'Enquanto as marcas de um Conversor estão em movimento, aparece dentro dele um pequeno sinal com duas setas, que desaparece no fim do passo.', 'whatsNew.v0230.focus': 'Com o Foco ativado, os dois sinais mantêm a intensidade total, mesmo nos nós fora do foco.', + 'whatsNew.v0240.tiers': 'A velocidade de reprodução agora também muda o que um passo mostra: a partir de 0,4 s por passo, a marca em movimento leva o seu +N; em velocidades maiores, o +N aparece na chegada; abaixo de 0,2 s, o caminho acende por um instante e a marca aparece no fim.', + 'whatsNew.v0240.step': 'Avançar um passo mostra sempre todo o movimento, seja qual for a velocidade escolhida.', + 'whatsNew.v0240.phone': 'Em telefones, o menu Mais oferece agora a velocidade de reprodução (Lenta, Normal, Rápida ou Muito rápida), e no máximo 12 marcas estão em movimento ao mesmo tempo.', } 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 8688fe84..bb47945b 100644 --- a/src/i18n/locales/pt-PT/ui.ts +++ b/src/i18n/locales/pt-PT/ui.ts @@ -184,6 +184,15 @@ const ui = { 'import.modelLayerUnreadable': 'O conteúdo da camada de modelo deste ficheiro não é legível ({detail}); os dados de projeto dele foram ignorados.', 'mobile.more.import': 'Importar ficheiro', 'mobile.more.importSub': 'Grafo ou Espaço de trabalho JSON', + 'mobile.speed.rowLabel': 'Velocidade de reprodução', + 'mobile.speed.slow': 'Lenta', + 'mobile.speed.normal': 'Normal', + 'mobile.speed.fast': 'Rápida', + 'mobile.speed.veryFast': 'Muito rápida', + 'mobile.speed.slow.time': '1 s por passo', + 'mobile.speed.normal.time': '0,6 s por passo', + 'mobile.speed.fast.time': '0,3 s por passo', + 'mobile.speed.veryFast.time': '0,12 s por passo', 'export.button': 'Exportar', 'export.menuLabel': 'Exportar', 'export.graphJson.name': 'Grafo JSON', @@ -587,6 +596,9 @@ const ui = { 'whatsNew.v0230.pulse': 'Quando uma marca em movimento chega a um Reservatório, o interior do Reservatório agora acende por um instante; o valor só muda no fim do passo, como antes.', 'whatsNew.v0230.mark': 'Enquanto as marcas de um Conversor estão em movimento, aparece dentro dele um pequeno sinal com duas setas, que desaparece no fim do passo.', 'whatsNew.v0230.focus': 'Com o Foco ativado, os dois sinais mantêm a intensidade total, mesmo nos nós fora do foco.', + 'whatsNew.v0240.tiers': 'A velocidade de reprodução agora também muda o que um passo mostra: a partir de 0,4 s por passo, a marca em movimento leva o seu +N; em velocidades maiores, o +N aparece na chegada; abaixo de 0,2 s, o caminho acende por um instante e a marca aparece no fim.', + 'whatsNew.v0240.step': 'Avançar um passo mostra sempre todo o movimento, seja qual for a velocidade escolhida.', + 'whatsNew.v0240.phone': 'Em telefones, o menu Mais oferece agora a velocidade de reprodução (Lenta, Normal, Rápida ou Muito rápida), e no máximo 12 marcas estão em movimento ao mesmo tempo.', } 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 97495ff1..7bffe3c5 100644 --- a/src/i18n/locales/ru/ui.ts +++ b/src/i18n/locales/ru/ui.ts @@ -197,6 +197,15 @@ const ui = { 'Содержимое слоя модели в этом файле не читается ({detail}); его проектные данные проигнорированы.', 'mobile.more.import': 'Импортировать файл', 'mobile.more.importSub': 'Graph или Workspace JSON', + 'mobile.speed.rowLabel': 'Скорость воспроизведения', + 'mobile.speed.slow': 'Медленно', + 'mobile.speed.normal': 'Обычно', + 'mobile.speed.fast': 'Быстро', + 'mobile.speed.veryFast': 'Очень быстро', + 'mobile.speed.slow.time': '1 с на шаг', + 'mobile.speed.normal.time': '0,6 с на шаг', + 'mobile.speed.fast.time': '0,3 с на шаг', + 'mobile.speed.veryFast.time': '0,12 с на шаг', 'export.button': 'Экспорт', 'export.menuLabel': 'Экспорт', 'export.graphJson.name': 'Graph JSON', @@ -641,6 +650,9 @@ const ui = { 'whatsNew.v0230.pulse': 'Когда движущаяся отметка достигает Накопителя, его внутренняя часть теперь ненадолго подсвечивается; число по-прежнему меняется в конце шага.', 'whatsNew.v0230.mark': 'Пока движутся отметки Преобразователя, внутри него появляется маленький знак из двух стрелок, который исчезает в конце шага.', 'whatsNew.v0230.focus': 'При включенном фокусе эти два знака сохраняют полную яркость даже в узлах вне фокуса.', + 'whatsNew.v0240.tiers': 'Скорость воспроизведения теперь меняет и то, что показывает шаг: от 0,4 с на шаг отметка движется со своим +N; быстрее +N появляется при прибытии; меньше 0,2 с путь ненадолго подсвечивается, а отметка появляется в его конце.', + 'whatsNew.v0240.step': 'Один шаг всегда показывает движение целиком, какая бы скорость ни была выбрана.', + 'whatsNew.v0240.phone': 'На телефоне в меню появилась скорость воспроизведения (Медленно, Обычно, Быстро или Очень быстро), и одновременно движутся не больше 12 отметок.', } 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 cd6d6617..bb7057a3 100644 --- a/src/i18n/locales/th/ui.ts +++ b/src/i18n/locales/th/ui.ts @@ -198,6 +198,15 @@ const ui = { 'เนื้อหาชั้นแบบจำลองของไฟล์นี้อ่านไม่ได้ ({detail}) ข้อมูลโปรเจกต์จึงถูกข้ามไป', 'mobile.more.import': 'นำเข้าไฟล์', 'mobile.more.importSub': 'Graph หรือ Workspace JSON', + 'mobile.speed.rowLabel': 'ความเร็วในการเล่น', + 'mobile.speed.slow': 'ช้า', + 'mobile.speed.normal': 'ปกติ', + 'mobile.speed.fast': 'เร็ว', + 'mobile.speed.veryFast': 'เร็วมาก', + 'mobile.speed.slow.time': '1 วินาทีต่อขั้น', + 'mobile.speed.normal.time': '0.6 วินาทีต่อขั้น', + 'mobile.speed.fast.time': '0.3 วินาทีต่อขั้น', + 'mobile.speed.veryFast.time': '0.12 วินาทีต่อขั้น', 'export.button': 'ส่งออก', 'export.menuLabel': 'ส่งออก', 'export.graphJson.name': 'Graph JSON', @@ -633,6 +642,9 @@ const ui = { 'whatsNew.v0230.pulse': 'เมื่อเครื่องหมายเคลื่อนไหวไปถึงถังพัก ด้านในของถังพักจะสว่างขึ้นครู่หนึ่ง ส่วนตัวเลขยังคงเปลี่ยนเมื่อจบขั้น', 'whatsNew.v0230.mark': 'ขณะที่เครื่องหมายของตัวแปลงกำลังเคลื่อนที่ จะมีสัญลักษณ์ลูกศรสองทางขนาดเล็กปรากฏด้านใน แล้วจางหายไปเมื่อจบขั้น', 'whatsNew.v0230.focus': 'เมื่อเปิดโฟกัส สัญลักษณ์ทั้งสองนี้ยังคงชัดเต็มที่ แม้ในโหนดที่อยู่นอกโฟกัส', + 'whatsNew.v0240.tiers': 'ตอนนี้ความเร็วในการเล่นยังเปลี่ยนสิ่งที่แต่ละขั้นแสดงด้วย ตั้งแต่ 0.4 วินาทีต่อขั้นขึ้นไป เครื่องหมายเคลื่อนไหวจะพา +N ไปด้วย ถ้าเร็วกว่านั้น +N จะปรากฏเมื่อไปถึง และถ้าต่ำกว่า 0.2 วินาที เส้นทางจะสว่างวาบแล้วเครื่องหมายจะปรากฏที่ปลายทาง', + 'whatsNew.v0240.step': 'การเดินหน้าหนึ่งขั้นจะแสดงการเคลื่อนไหวทั้งหมดเสมอ ไม่ว่าจะตั้งความเร็วไว้เท่าใด', + 'whatsNew.v0240.phone': 'บนโทรศัพท์ เมนูเพิ่มเติมมีความเร็วในการเล่นแล้ว (ช้า ปกติ เร็ว หรือเร็วมาก) และเครื่องหมายจะเคลื่อนที่พร้อมกันได้ไม่เกิน 12 อัน', } 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 fb244e33..89b71305 100644 --- a/src/i18n/locales/tr/ui.ts +++ b/src/i18n/locales/tr/ui.ts @@ -178,6 +178,15 @@ const ui = { 'Bu dosyanın model katmanı içeriği okunamıyor ({detail}); proje verisi yok sayıldı.', 'mobile.more.import': 'Dosya içe aktar', 'mobile.more.importSub': 'Graph ya da Workspace JSON', + 'mobile.speed.rowLabel': 'Oynatma hızı', + 'mobile.speed.slow': 'Yavaş', + 'mobile.speed.normal': 'Normal', + 'mobile.speed.fast': 'Hızlı', + 'mobile.speed.veryFast': 'Çok hızlı', + 'mobile.speed.slow.time': 'Adım başına 1 sn', + 'mobile.speed.normal.time': 'Adım başına 0,6 sn', + 'mobile.speed.fast.time': 'Adım başına 0,3 sn', + 'mobile.speed.veryFast.time': 'Adım başına 0,12 sn', 'export.button': 'Dışa aktar', 'export.menuLabel': 'Dışa aktar', 'export.graphJson.name': 'Graph JSON', @@ -627,6 +636,9 @@ const ui = { 'whatsNew.v0230.pulse': 'Hareket eden bir işaret bir Havuza ulaştığında, Havuzun içi artık kısa bir süre parlar; sayı yine adımın sonunda değişir.', 'whatsNew.v0230.mark': 'Bir Dönüştürücünün işaretleri hareket ederken içinde iki oklu küçük bir işaret görünür ve adımın sonunda kaybolur.', 'whatsNew.v0230.focus': 'Odak açıkken bu iki işaret, odak dışındaki düğümlerde bile tam gücünü korur.', + 'whatsNew.v0240.tiers': 'Oynatma hızı artık bir adımın gösterdiğini de değiştirir: adım başına 0,4 sn ve üzerinde işaret +N ile birlikte hareket eder; daha hızlıda +N varışta görünür; 0,2 sn altında yol kısa bir süre parlar ve işaret sonunda belirir.', + 'whatsNew.v0240.step': 'Bir adım ilerletmek, hız ayarı ne olursa olsun her zaman hareketin tamamını gösterir.', + 'whatsNew.v0240.phone': 'Telefonda Diğer menüsünde artık oynatma hızı var (Yavaş, Normal, Hızlı veya Çok hızlı) ve aynı anda en fazla 12 işaret hareket eder.', } 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 d15f3ac1..2a151275 100644 --- a/src/i18n/locales/vi/ui.ts +++ b/src/i18n/locales/vi/ui.ts @@ -173,6 +173,15 @@ const ui = { 'import.modelLayerUnreadable': 'Không đọc được phần nội dung tầng mô hình của tệp này ({detail}); dữ liệu dự án của nó đã bị bỏ qua.', 'mobile.more.import': 'Nhập tệp', 'mobile.more.importSub': 'Graph hoặc Workspace JSON', + 'mobile.speed.rowLabel': 'Tốc độ phát', + 'mobile.speed.slow': 'Chậm', + 'mobile.speed.normal': 'Bình thường', + 'mobile.speed.fast': 'Nhanh', + 'mobile.speed.veryFast': 'Rất nhanh', + 'mobile.speed.slow.time': '1 giây mỗi bước', + 'mobile.speed.normal.time': '0,6 giây mỗi bước', + 'mobile.speed.fast.time': '0,3 giây mỗi bước', + 'mobile.speed.veryFast.time': '0,12 giây mỗi bước', 'export.button': 'Xuất', 'export.menuLabel': 'Xuất', 'export.graphJson.name': 'Graph JSON', @@ -573,6 +582,9 @@ const ui = { 'whatsNew.v0230.pulse': 'Khi một dấu chuyển động tới Bể chứa, bên trong Bể chứa giờ sẽ sáng lên trong chốc lát; con số vẫn chỉ thay đổi khi bước kết thúc.', 'whatsNew.v0230.mark': 'Trong lúc các dấu của Bộ chuyển đổi di chuyển, bên trong nó hiện một ký hiệu nhỏ hai mũi tên, rồi mờ đi khi bước kết thúc.', 'whatsNew.v0230.focus': 'Khi bật Tiêu điểm, hai ký hiệu này vẫn giữ nguyên độ đậm, kể cả trong các nút nằm ngoài tiêu điểm.', + 'whatsNew.v0240.tiers': 'Tốc độ phát giờ cũng thay đổi những gì một bước hiển thị: từ 0,4 giây mỗi bước trở lên, dấu chuyển động mang theo +N; nhanh hơn thì +N hiện khi tới nơi; dưới 0,2 giây thì đường đi lóe sáng rồi dấu hiện ở cuối.', + 'whatsNew.v0240.step': 'Tiến một bước luôn hiển thị toàn bộ chuyển động, dù tốc độ được đặt thế nào.', + 'whatsNew.v0240.phone': 'Trên điện thoại, mục Thêm giờ có tốc độ phát (Chậm, Bình thường, Nhanh hoặc Rất nhanh), và cùng lúc chỉ có tối đa 12 dấu di chuyển.', } satisfies Record export default ui diff --git a/src/i18n/locales/zh-Hans/ui.ts b/src/i18n/locales/zh-Hans/ui.ts index 08c75c0d..effbada9 100644 --- a/src/i18n/locales/zh-Hans/ui.ts +++ b/src/i18n/locales/zh-Hans/ui.ts @@ -164,6 +164,15 @@ const ui = { 'import.modelLayerUnreadable': '这个文件的模型层内容无法读取({detail}),它的项目数据已被忽略。', 'mobile.more.import': '导入文件', 'mobile.more.importSub': 'Graph 或 Workspace JSON', + 'mobile.speed.rowLabel': '播放速度', + 'mobile.speed.slow': '慢', + 'mobile.speed.normal': '正常', + 'mobile.speed.fast': '快', + 'mobile.speed.veryFast': '很快', + 'mobile.speed.slow.time': '每步 1 秒', + 'mobile.speed.normal.time': '每步 0.6 秒', + 'mobile.speed.fast.time': '每步 0.3 秒', + 'mobile.speed.veryFast.time': '每步 0.12 秒', 'export.button': '导出', 'export.menuLabel': '导出', 'export.graphJson.name': 'Graph JSON', @@ -565,6 +574,9 @@ const ui = { 'whatsNew.v0230.pulse': '移动标记到达资源池时,资源池内部现在会短暂亮起;数值仍在这一步结束时才变化。', 'whatsNew.v0230.mark': '转换器的标记移动期间,其内部会显示一个小的双向箭头,并在这一步结束时淡出。', 'whatsNew.v0230.focus': '开启聚焦时,这两种提示即使在聚焦范围外的节点中也保持原有强度。', + 'whatsNew.v0240.tiers': '播放速度现在也会改变一步的显示方式:每步 0.4 秒及以上时,移动标记带着 +N 一起移动;更快时,+N 在到达时出现;快于 0.2 秒时,路径先短暂亮起,标记随后出现在终点。', + 'whatsNew.v0240.step': '前进一步时,无论速度如何设置,始终显示完整的移动过程。', + 'whatsNew.v0240.phone': '在手机上,“更多”菜单新增了播放速度(慢、正常、快、很快),同时移动的标记最多 12 个。', } satisfies Record export default ui diff --git a/src/i18n/locales/zh-Hant/ui.ts b/src/i18n/locales/zh-Hant/ui.ts index 707f1c21..0b2df990 100644 --- a/src/i18n/locales/zh-Hant/ui.ts +++ b/src/i18n/locales/zh-Hant/ui.ts @@ -172,6 +172,15 @@ const ui = { 'import.modelLayerUnreadable': '這個檔案的模型層內容無法讀取({detail}),它的專案資料已被忽略。', 'mobile.more.import': '匯入檔案', 'mobile.more.importSub': 'Graph 或 Workspace JSON', + 'mobile.speed.rowLabel': '播放速度', + 'mobile.speed.slow': '慢', + 'mobile.speed.normal': '正常', + 'mobile.speed.fast': '快', + 'mobile.speed.veryFast': '很快', + 'mobile.speed.slow.time': '每步 1 秒', + 'mobile.speed.normal.time': '每步 0.6 秒', + 'mobile.speed.fast.time': '每步 0.3 秒', + 'mobile.speed.veryFast.time': '每步 0.12 秒', 'export.button': '匯出', 'export.menuLabel': '匯出', 'export.graphJson.name': 'Graph JSON', @@ -591,6 +600,9 @@ const ui = { 'whatsNew.v0230.pulse': '移動標記抵達資源池時,資源池內部現在會短暫亮起;數值仍在這一步結束時才變化。', 'whatsNew.v0230.mark': '轉換器的標記移動期間,其內部會顯示一個小的雙向箭頭,並在這一步結束時淡出。', 'whatsNew.v0230.focus': '開啟聚焦時,這兩種提示即使在聚焦範圍外的節點中也維持原有強度。', + 'whatsNew.v0240.tiers': '播放速度現在也會改變一步的顯示方式:每步 0.4 秒以上時,移動標記帶著 +N 一起移動;更快時,+N 在抵達時出現;快於 0.2 秒時,路徑先短暫亮起,標記隨後出現在終點。', + 'whatsNew.v0240.step': '前進一步時,無論速度如何設定,始終顯示完整的移動過程。', + 'whatsNew.v0240.phone': '在手機上,「更多」選單新增了播放速度(慢、正常、快、很快),同時移動的標記最多 12 個。', } satisfies Record export default ui diff --git a/src/i18n/nlCopy.test.ts b/src/i18n/nlCopy.test.ts index 1a5e0810..198ef79f 100644 --- a/src/i18n/nlCopy.test.ts +++ b/src/i18n/nlCopy.test.ts @@ -261,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(1052) // issue #330 PR 2: +3 (three 0.23.0 release-note lines); issue #330 PR 1: +5 (five 0.22.0 release-note lines); 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); the flow colour: +20 (the sixteen strings of the Colour section and the four 0.19.0 release-note lines); the flow colour views: +4 (the four 0.20.0 release-note lines); compact nodes: +3 (the three 0.21.0 release-note lines); Focus dimming: +3 (the three 0.21.1 release-note lines); value rows: +3 (the three 0.21.2 release-note lines); the edit lock: +4 (canvas.lock.name, its fixed accessible name, and the three 0.21.3 release-note lines); the locked rail and the phone search: +4 (the four 0.21.4 release-note lines) + expect(KEYS).toHaveLength(1064) // issue #330 PR 3: +3 (three 0.24.0 release-note lines); issue #330 PR 3: +9 (the phone Playback speed sheet); issue #330 PR 2: +3 (three 0.23.0 release-note lines); issue #330 PR 1: +5 (five 0.22.0 release-note lines); 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); the flow colour: +20 (the sixteen strings of the Colour section and the four 0.19.0 release-note lines); the flow colour views: +4 (the four 0.20.0 release-note lines); compact nodes: +3 (the three 0.21.0 release-note lines); Focus dimming: +3 (the three 0.21.1 release-note lines); value rows: +3 (the three 0.21.2 release-note lines); the edit lock: +4 (canvas.lock.name, its fixed accessible name, and the three 0.21.3 release-note lines); the locked rail and the phone search: +4 (the four 0.21.4 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: 1052, template: 196, frame: 7, module: 19 }) - expect(RUNTIME).toHaveLength(1274) + expect(per).toEqual({ catalog: 1064, template: 196, frame: 7, module: 19 }) + expect(RUNTIME).toHaveLength(1286) }) 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 7ffc3c25..2fb464c8 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(1052) // issue #330 PR 2: +3 (three 0.23.0 release-note lines); issue #330 PR 1: +5 (five 0.22.0 release-note lines); 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); the flow colour: +20 (the sixteen strings of the Colour section and the four 0.19.0 release-note lines); the flow colour views: +4 (the four 0.20.0 release-note lines); compact nodes: +3 (the three 0.21.0 release-note lines); Focus dimming: +3 (the three 0.21.1 release-note lines); value rows: +3 (the three 0.21.2 release-note lines); the edit lock: +4 (canvas.lock.name, its fixed accessible name, and the three 0.21.3 release-note lines); the locked rail and the phone search: +4 (the four 0.21.4 release-note lines) + expect(KEYS).toHaveLength(1064) // issue #330 PR 3: +3 (three 0.24.0 release-note lines); issue #330 PR 3: +9 (the phone Playback speed sheet); issue #330 PR 2: +3 (three 0.23.0 release-note lines); issue #330 PR 1: +5 (five 0.22.0 release-note lines); 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); the flow colour: +20 (the sixteen strings of the Colour section and the four 0.19.0 release-note lines); the flow colour views: +4 (the four 0.20.0 release-note lines); compact nodes: +3 (the three 0.21.0 release-note lines); Focus dimming: +3 (the three 0.21.1 release-note lines); value rows: +3 (the three 0.21.2 release-note lines); the edit lock: +4 (canvas.lock.name, its fixed accessible name, and the three 0.21.3 release-note lines); the locked rail and the phone search: +4 (the four 0.21.4 release-note lines) }) it('differs from pt-BR on exactly the audited keys', () => { diff --git a/src/i18n/ruCopy.test.ts b/src/i18n/ruCopy.test.ts index 935204d9..161c6983 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(1052) // issue #330 PR 2: +3 (three 0.23.0 release-note lines); issue #330 PR 1: +5 (five 0.22.0 release-note lines); 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); the flow colour: +20 (the sixteen strings of the Colour section and the four 0.19.0 release-note lines); the flow colour views: +4 (the four 0.20.0 release-note lines); compact nodes: +3 (the three 0.21.0 release-note lines); Focus dimming: +3 (the three 0.21.1 release-note lines); value rows: +3 (the three 0.21.2 release-note lines); the edit lock: +4 (canvas.lock.name, its fixed accessible name, and the three 0.21.3 release-note lines); the locked rail and the phone search: +4 (the four 0.21.4 release-note lines) + expect(KEYS).toHaveLength(1064) // issue #330 PR 3: +3 (three 0.24.0 release-note lines); issue #330 PR 3: +9 (the phone Playback speed sheet); issue #330 PR 2: +3 (three 0.23.0 release-note lines); issue #330 PR 1: +5 (five 0.22.0 release-note lines); 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); the flow colour: +20 (the sixteen strings of the Colour section and the four 0.19.0 release-note lines); the flow colour views: +4 (the four 0.20.0 release-note lines); compact nodes: +3 (the three 0.21.0 release-note lines); Focus dimming: +3 (the three 0.21.1 release-note lines); value rows: +3 (the three 0.21.2 release-note lines); the edit lock: +4 (canvas.lock.name, its fixed accessible name, and the three 0.21.3 release-note lines); the locked rail and the phone search: +4 (the four 0.21.4 release-note lines) }) // ----------------------------------------------------------------- script diff --git a/src/i18n/thCopy.test.ts b/src/i18n/thCopy.test.ts index 50e7e6d3..285ed7b0 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(1052) // issue #330 PR 2: +3 (three 0.23.0 release-note lines); issue #330 PR 1: +5 (five 0.22.0 release-note lines); 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); the flow colour: +20 (the sixteen strings of the Colour section and the four 0.19.0 release-note lines); the flow colour views: +4 (the four 0.20.0 release-note lines); compact nodes: +3 (the three 0.21.0 release-note lines); Focus dimming: +3 (the three 0.21.1 release-note lines); value rows: +3 (the three 0.21.2 release-note lines); the edit lock: +4 (canvas.lock.name, its fixed accessible name, and the three 0.21.3 release-note lines); the locked rail and the phone search: +4 (the four 0.21.4 release-note lines) + expect(KEYS).toHaveLength(1064) // issue #330 PR 3: +3 (three 0.24.0 release-note lines); issue #330 PR 3: +9 (the phone Playback speed sheet); issue #330 PR 2: +3 (three 0.23.0 release-note lines); issue #330 PR 1: +5 (five 0.22.0 release-note lines); 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); the flow colour: +20 (the sixteen strings of the Colour section and the four 0.19.0 release-note lines); the flow colour views: +4 (the four 0.20.0 release-note lines); compact nodes: +3 (the three 0.21.0 release-note lines); Focus dimming: +3 (the three 0.21.1 release-note lines); value rows: +3 (the three 0.21.2 release-note lines); the edit lock: +4 (canvas.lock.name, its fixed accessible name, and the three 0.21.3 release-note lines); the locked rail and the phone search: +4 (the four 0.21.4 release-note lines) }) it('covers all three runtime surfaces', () => { - expect(RUNTIME.filter(([s]) => s === 'catalog')).toHaveLength(1052) + expect(RUNTIME.filter(([s]) => s === 'catalog')).toHaveLength(1064) 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) @@ -277,6 +277,7 @@ const LATIN_KEYS = [ 'catalog:import.issue.invalid-ignore-rows', 'catalog:import.issue.ragged-row', 'catalog:whatsNew.v0220.badge', + 'catalog:whatsNew.v0240.tiers', 'catalog:import.parseError', 'catalog:import.parseErrorsBlockValidation', 'catalog:import.pasteAria', @@ -432,7 +433,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(171) // issue #330 PR 1: +1 (the `+N` of one 0.22.0 release-note line); issue #307: +2 (two 0.18.1 release-note lines); issue #308: +1 (one 0.18.2 release-note line); issue #325: +1 (one 0.19.0 release-note line) + expect(LATIN_KEYS).toHaveLength(172) // issue #330 PR 3: +1 (the `+N` of one 0.24.0 release-note line); issue #330 PR 1: +1 (the `+N` of one 0.22.0 release-note line); issue #307: +2 (two 0.18.1 release-note lines); issue #308: +1 (one 0.18.2 release-note line); issue #325: +1 (one 0.19.0 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 e5d7bf03..bce2841b 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(1052) // issue #330 PR 2: +3 (three 0.23.0 release-note lines); issue #330 PR 1: +5 (five 0.22.0 release-note lines); 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); the flow colour: +20 (the sixteen strings of the Colour section and the four 0.19.0 release-note lines); the flow colour views: +4 (the four 0.20.0 release-note lines); compact nodes: +3 (the three 0.21.0 release-note lines); Focus dimming: +3 (the three 0.21.1 release-note lines); value rows: +3 (the three 0.21.2 release-note lines); the edit lock: +4 (canvas.lock.name, its fixed accessible name, and the three 0.21.3 release-note lines); the locked rail and the phone search: +4 (the four 0.21.4 release-note lines) + expect(KEYS).toHaveLength(1064) // issue #330 PR 3: +3 (three 0.24.0 release-note lines); issue #330 PR 3: +9 (the phone Playback speed sheet); issue #330 PR 2: +3 (three 0.23.0 release-note lines); issue #330 PR 1: +5 (five 0.22.0 release-note lines); 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); the flow colour: +20 (the sixteen strings of the Colour section and the four 0.19.0 release-note lines); the flow colour views: +4 (the four 0.20.0 release-note lines); compact nodes: +3 (the three 0.21.0 release-note lines); Focus dimming: +3 (the three 0.21.1 release-note lines); value rows: +3 (the three 0.21.2 release-note lines); the edit lock: +4 (canvas.lock.name, its fixed accessible name, and the three 0.21.3 release-note lines); the locked rail and the phone search: +4 (the four 0.21.4 release-note lines) }) }) @@ -172,6 +172,7 @@ describe('a value left in English is declared, not accidental', () => { 'inspector.expr.activatorPlaceholder', // >= 5 'inspector.expr.labelPlaceholder', // +1 · -2 · =S 'mc.title', // Monte Carlo + 'mobile.speed.normal', // Normal — the Turkish word (issue #330 PR 3) 'playbar.mc', // Monte Carlo 'playbar.mc.withNote', // Monte Carlo · {note} 'regExpr.row.generic', // — {code} diff --git a/src/i18n/viCopy.test.ts b/src/i18n/viCopy.test.ts index ccf5b371..ebd70cae 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(1052) // issue #330 PR 2: +3 (three 0.23.0 release-note lines); issue #330 PR 1: +5 (five 0.22.0 release-note lines); 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); the flow colour: +20 (the sixteen strings of the Colour section and the four 0.19.0 release-note lines); the flow colour views: +4 (the four 0.20.0 release-note lines); compact nodes: +3 (the three 0.21.0 release-note lines); Focus dimming: +3 (the three 0.21.1 release-note lines); value rows: +3 (the three 0.21.2 release-note lines); the edit lock: +4 (canvas.lock.name, its fixed accessible name, and the three 0.21.3 release-note lines); the locked rail and the phone search: +4 (the four 0.21.4 release-note lines) + expect(KEYS).toHaveLength(1064) // issue #330 PR 3: +3 (three 0.24.0 release-note lines); issue #330 PR 3: +9 (the phone Playback speed sheet); issue #330 PR 2: +3 (three 0.23.0 release-note lines); issue #330 PR 1: +5 (five 0.22.0 release-note lines); 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); the flow colour: +20 (the sixteen strings of the Colour section and the four 0.19.0 release-note lines); the flow colour views: +4 (the four 0.20.0 release-note lines); compact nodes: +3 (the three 0.21.0 release-note lines); Focus dimming: +3 (the three 0.21.1 release-note lines); value rows: +3 (the three 0.21.2 release-note lines); the edit lock: +4 (canvas.lock.name, its fixed accessible name, and the three 0.21.3 release-note lines); the locked rail and the phone search: +4 (the four 0.21.4 release-note lines) }) it('covers all four runtime surfaces', () => { - expect(RUNTIME.filter(([s]) => s === 'catalog')).toHaveLength(1052) + expect(RUNTIME.filter(([s]) => s === 'catalog')).toHaveLength(1064) 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) @@ -400,6 +400,7 @@ const ASCII_KEYS = [ 'catalog:import.issue.missing-source-column-id', 'catalog:import.issue.ragged-row', 'catalog:whatsNew.v0220.badge', + 'catalog:whatsNew.v0240.tiers', 'catalog:import.parseError', 'catalog:import.parseErrorsBlockValidation', 'catalog:import.pasteAria', @@ -579,7 +580,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(195) // issue #330 PR 1: +1 (the `+N` of one 0.22.0 release-note line); issue #307: +3 (the three 0.18.1 release-note lines); issue #308: +1 (one 0.18.2 release-note line); issue #325: +1 (one 0.19.0 release-note line) + expect(ASCII_KEYS).toHaveLength(196) // issue #330 PR 3: +1 (the `+N` of one 0.24.0 release-note line); issue #330 PR 1: +1 (the `+N` of one 0.22.0 release-note line); issue #307: +3 (the three 0.18.1 release-note lines); issue #308: +1 (one 0.18.2 release-note line); issue #325: +1 (one 0.19.0 release-note line) }) it('the kept vocabulary is exactly the declared one', () => { diff --git a/src/index.css b/src/index.css index 6c7462f5..94fac3f3 100644 --- a/src/index.css +++ b/src/index.css @@ -3438,6 +3438,13 @@ a.sheet__row { vector-effect: non-scaling-stroke; pointer-events: none; } +/* issue #330 PR 3 — the very fast tier (§PB6.1): a moved path flashes, a + little stronger than the highlight, from its onset to its arrive beat, where + the token appears at the end instead of travelling. Forced colours: the same + 4 px CanvasText line as every path highlight. */ +.pb-path--flash { + opacity: 0.8; +} /* the `+N` badge beside the round token, from `+1`; it moves with the token. It lives in the label layer (HTML, above every connection label) so the pair is drawn on top. Its box matches ./components/edges/playbackBadge.ts (height @@ -6111,6 +6118,21 @@ input[type='number']::-webkit-inner-spin-button { .sheet__row:focus-visible { background: var(--surface-hover); } + /* issue #330 PR 3 — the Playback speed choices: a 44 px touch target + (§MV7), and the chosen one marked by a check and a soft tint, never by + colour alone (forced colours keep the check and the pressed state) */ + .sheet__row--speed { + min-height: 44px; + } + .sheet__row--speed[aria-pressed='true'] { + background: var(--signal-primary-soft); + font-weight: 600; + } + /* on the tint the tertiary per-step time would fall under 4.5:1; lift it, + as the hover / focus rule above does */ + .sheet__row--speed[aria-pressed='true'] .sheet__row-sub { + color: var(--text-secondary); + } .sheet__row-sub { margin-inline-start: auto; font-size: 12px; diff --git a/src/releaseNotes/releaseNotes.ts b/src/releaseNotes/releaseNotes.ts index fbedeb7c..c6f1a2de 100644 --- a/src/releaseNotes/releaseNotes.ts +++ b/src/releaseNotes/releaseNotes.ts @@ -38,6 +38,15 @@ export const releaseNoteIdFor = (version: string): ReleaseNoteId => `release:${v * mechanics, no test counts, no pull-request numbers. */ export const RELEASE_NOTES: readonly ReleaseNote[] = [ + { + // issue #330 PR 3 - the speed tiers, Step always full, the phone's four + // speeds and its fewer moving markers; the date is the day it is deployed, + // set right before the merge + id: 'release:0.24.0', + version: '0.24.0', + date: '2026-10-09', + items: ['whatsNew.v0240.tiers', 'whatsNew.v0240.step', 'whatsNew.v0240.phone'], + }, { // issue #330 PR 2 - the cues inside a node: the Pool arrival pulse and the // Converter's conversion mark, at full strength under Focus mode; the diff --git a/src/store/playbackTier.test.ts b/src/store/playbackTier.test.ts new file mode 100644 index 00000000..b6cdc901 --- /dev/null +++ b/src/store/playbackTier.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest' +import { PHONE_SPEEDS, TIER_FAST_MIN_MS, TIER_FULL_MIN_MS, tierOf } from './playbackTier' +import { MAX_PLAYBACK_TOKENS_PHONE, MAX_PLAYBACK_TOKENS_TOTAL } from '../components/edges/playback-caps' + +// issue #330 PR 3 (v0.24.0), docs/simulation-playback.md §PB6.1 + +describe('the speed tiers', () => { + it('have exact boundaries: ≥ 400 full, 200 … 399 fast, < 200 very fast', () => { + expect(TIER_FULL_MIN_MS).toBe(400) + expect(TIER_FAST_MIN_MS).toBe(200) + expect(tierOf(2400)).toBe('full') + expect(tierOf(400)).toBe('full') + expect(tierOf(399)).toBe('fast') + expect(tierOf(200)).toBe('fast') + expect(tierOf(199)).toBe('veryFast') + expect(tierOf(120)).toBe('veryFast') + }) + + it('put each of the phone’s four speeds in exactly one tier', () => { + expect(PHONE_SPEEDS.map((s) => [s.key, s.ms, tierOf(s.ms)])).toEqual([ + ['slow', 1000, 'full'], + ['normal', 600, 'full'], + ['fast', 300, 'fast'], + ['veryFast', 120, 'veryFast'], + ]) + }) +}) + +describe('the phone profile', () => { + it('draws at most 12 token-and-badge pairs, half the desktop 24', () => { + expect(MAX_PLAYBACK_TOKENS_PHONE).toBe(12) + expect(MAX_PLAYBACK_TOKENS_TOTAL).toBe(24) + }) +}) diff --git a/src/store/playbackTier.ts b/src/store/playbackTier.ts new file mode 100644 index 00000000..ea19901e --- /dev/null +++ b/src/store/playbackTier.ts @@ -0,0 +1,39 @@ +// issue #330 PR 3 (v0.24.0), docs/simulation-playback.md §PB6.1 — the three +// speed tiers: what a step draws at the per-step beat the user chose. Exact +// boundaries on the continuous desktop slider (120 … 2400 ms): +// +// ≥ 400 ms full the token travels its path with `+N` beside it +// 200 … 399 ms fast the token travels; `+N` shows when it arrives +// < 200 ms very fast the path flashes, then the token appears at the +// end and leads into the arrival with `+N` +// +// The tier is read ONCE, when a step starts (the transition carries it); a +// speed change during a step re-rates that step's clock (§PB6.2) but changes its +// tier only from the next step. Step (the button) always draws the full tier, +// whatever the slider says. Reduced motion has its own static form (§PB9) and +// ignores the tier. Presentation only: no engine, RNG or result depends on it. + +export type PlaybackTier = 'full' | 'fast' | 'veryFast' + +/** the slowest beat that is still `fast`, + 1 ms: from here up it is `full` */ +export const TIER_FULL_MIN_MS = 400 +/** from here up to `TIER_FULL_MIN_MS` it is `fast`; below it `veryFast` */ +export const TIER_FAST_MIN_MS = 200 + +export const tierOf = (ms: number): PlaybackTier => + ms >= TIER_FULL_MIN_MS ? 'full' : ms >= TIER_FAST_MIN_MS ? 'fast' : 'veryFast' + +/** issue #330 PR 3 — the display profile a step is drawn with, fixed when it + * starts (like the tier): `phone` in the mobile view/run layout (at most 12 + * token-and-badge pairs, no departure ring), `desktop` otherwise. Reduced + * motion keeps its own static form within the 24 on either. */ +export type PlaybackProfile = 'desktop' | 'phone' + +/** docs/mobile.md §MV4 — the phone's four speeds under `⋯ → Playback speed`; + * each falls in exactly one tier */ +export const PHONE_SPEEDS = [ + { key: 'slow', ms: 1000 }, + { key: 'normal', ms: 600 }, + { key: 'fast', ms: 300 }, + { key: 'veryFast', ms: 120 }, +] as const diff --git a/src/store/simStore.ts b/src/store/simStore.ts index f0c27ff5..b39f1b67 100644 --- a/src/store/simStore.ts +++ b/src/store/simStore.ts @@ -10,6 +10,8 @@ import { useGraphStore, } from './graphStore' import { computeStagger } from './playbackRank' +import { type PlaybackProfile, type PlaybackTier, tierOf } from './playbackTier' +import { MOBILE_MEDIA_QUERY } from '../ui/media' import { flowTotals, isSteady, STEADY_N, type SteadySample } from './steadyState' // docs/large-graph-readability.md §LGR6-cues — the trailing Activity-overlay @@ -168,6 +170,12 @@ type SimStore = { onsetByEdge: Record /** count of non-empty onset buckets this step (observable for tests) */ bucketCount: number + /** issue #330 PR 3 — what this step draws (./playbackTier, §PB6.1): + * read once when the step starts; Step is always `full` */ + tier: PlaybackTier + /** issue #330 PR 3 — the display profile (./playbackTier), fixed when + * the step starts, so a rotation or resize mid-step changes nothing */ + profile: PlaybackProfile } | null /** id of the current preparedTransition, or null (§PB7.3) */ @@ -254,6 +262,9 @@ const isHidden = (): boolean => const reducedMotion = (): boolean => typeof matchMedia !== 'undefined' && matchMedia('(prefers-reduced-motion: reduce)').matches === true const clamp01 = (n: number): number => (n < 0 ? 0 : n > 1 ? 1 : n) +/** the mobile view/run layout right now (the one query, src/ui/media.ts) */ +const isPhoneLayout = (): boolean => + typeof matchMedia !== 'undefined' && matchMedia(MOBILE_MEDIA_QUERY).matches === true /** recursively `Object.freeze` a plain-data value (dev only) so a bug that * mutates `prepared.toState` / `prepared.derived` throws instead of silently @@ -441,7 +452,7 @@ export const useSimStore = create((set, get) => { /** prepare + arm + start the τ clock for ONE step's choreography. Used by both * UI Step (one-shot) and Play (the loop re-calls it after each settle). It * does not decide whether to continue — the loop does, gated on `status`. */ - const beginTransition = () => { + const beginTransition = (tier: PlaybackTier) => { if (get().status === 'ended' || isHidden() || get().activeTransitionId != null) return const p = armPrepared(prepareTransition()) prepared = p @@ -462,6 +473,10 @@ export const useSimStore = create((set, get) => { stateEvents: p.derived.stateEvents, onsetByEdge, bucketCount, + tier, + // the phone layout's fewer moving elements (docs/mobile.md §MV4); + // reduced motion keeps its own static form on either layout (§PB9) + profile: isPhoneLayout() && !reducedMotion() ? 'phone' : 'desktop', }, }) } @@ -520,7 +535,8 @@ export const useSimStore = create((set, get) => { set((s) => ({ transition: s.transition ? { ...s.transition, tau, phase: phaseOf(tau) } : null })) } } else if (s.status === 'running') { - beginTransition() + // issue #330 PR 3 — Play reads the tier from the speed, once per step + beginTransition(tierOf(s.speedMs)) } if ((get().status === 'running' || get().activeTransitionId != null) && typeof requestAnimationFrame !== 'undefined') { @@ -603,7 +619,8 @@ export const useSimStore = create((set, get) => { if (get().status === 'running') return // Step is disabled while Play runs head() if (get().initError) return // the graph cannot be initialised — see `initError` - beginTransition() + // issue #330 PR 3 — Step always draws the full tier (§PB6.1) + beginTransition('full') if (typeof requestAnimationFrame === 'undefined' || reducedMotion()) { // no animation clock (SSR / vitest), or reduced motion ⇒ a Step's // one-step choreography settles instantly (§PB9 — no artificial wait). diff --git a/src/store/uiStore.ts b/src/store/uiStore.ts index aca417bc..7bb2c077 100644 --- a/src/store/uiStore.ts +++ b/src/store/uiStore.ts @@ -20,6 +20,7 @@ export type Overlay = | 'export' | 'help' // docs/guided-tour.md §GT7 — the mobile Help sub-sheet | 'filter' // docs/large-graph-readability.md §LGR9 — the mobile Filters sub-sheet + | 'speed' // docs/mobile.md §MV4 — the mobile Playback speed sub-sheet (issue #330 PR 3) | 'inspector' // Slice 3 type UiState = {