diff --git a/.changes/playback-speed-tiers.json b/.changes/playback-speed-tiers.json new file mode 100644 index 0000000..ae2e3bc --- /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 a330da8..c08c89f 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 844ba81..de60419 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 c36f7d7..d23450c 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 cc3e09a..270cf20 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 23dc2e4..6e49a01 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 5aaefa6..5d6b6e4 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 0000000..39780a5 --- /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 0092039..cce7ad7 100644 Binary files a/e2e/playback-visual.spec.ts-snapshots/play-depart-dark-L2-mobile-win32.png and b/e2e/playback-visual.spec.ts-snapshots/play-depart-dark-L2-mobile-win32.png differ 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 3b38ff2..238cf93 100644 Binary files a/e2e/playback-visual.spec.ts-snapshots/play-depart-forced-colors-L2-mobile-win32.png and b/e2e/playback-visual.spec.ts-snapshots/play-depart-forced-colors-L2-mobile-win32.png differ 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 72681ae..00f49a6 100644 Binary files a/e2e/playback-visual.spec.ts-snapshots/play-depart-light-L2-mobile-win32.png and b/e2e/playback-visual.spec.ts-snapshots/play-depart-light-L2-mobile-win32.png differ diff --git a/e2e/whats-new.spec.ts b/e2e/whats-new.spec.ts index 8351164..5592205 100644 --- a/e2e/whats-new.spec.ts +++ b/e2e/whats-new.spec.ts @@ -238,7 +238,7 @@ test.describe('closing the notice and opening the panel are different things', ( expect(text).not.toContain('text unavailable') } } - expect(RELEASE_NOTES.map((n) => n.version)).toEqual(['0.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 98b2c43..e81b563 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 869084b..a649a52 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 2345443..ae80826 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 153e5f6..df57b6c 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 e909d1e..f2dcf10 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 bf1c5b9..be67aaf 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 06e9250..efd185d 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 f1bc71e..144373f 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 5751400..3874011 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 42ecae1..27ffdfe 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 3fd1472..6f9ca51 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 4b9d9b9..71252d8 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 4876386..dd20e52 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 cef3e7f..ea4d88a 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 75e891c..c44ab45 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 3afd403..a44fcf4 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 ef6f78c..d666dc8 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 4022db9..1e28c80 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 59af138..fd3d4bb 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 8688fe8..bb47945 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 97495ff..7bffe3c 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 cd6d661..bb7057a 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 fb244e3..89b7130 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 d15f3ac..2a15127 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 08c75c0..effbada 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 707f1c2..0b2df99 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 1a5e081..198ef79 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 7ffc3c2..2fb464c 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 935204d..161c698 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 50e7e6d..285ed7b 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 e5d7bf0..bce2841 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 ccf5b37..ebd70ca 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 6c7462f..94fac3f 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 fbedeb7..c6f1a2d 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 0000000..b6cdc90 --- /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 0000000..ea19901 --- /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 f0c27ff..b39f1b6 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 aca417b..7bb2c07 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 = {