diff --git a/.changes/playback-node-cues.json b/.changes/playback-node-cues.json new file mode 100644 index 0000000..3c11aa8 --- /dev/null +++ b/.changes/playback-node-cues.json @@ -0,0 +1 @@ +{ "type": "user-facing", "releaseNoteId": "release:0.23.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 8252df7..a330da8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ All notable Loop Studio releases, newest first. Behavioral changes are pinned in versioned spec documents (see the [README](README.md#technical-reference)); this file is the narrative history, not the contract. +## v0.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. + +- **The Pool arrival pulse.** When the first moving marker of a step reaches a Pool, a soft tint fills the Pool from 7 px inside its outline, under its title and value, for half a second; it carries on across the end of the step. The value still changes when the step ends. Past the 24 marker pairs and when zoomed far out, where no marker is drawn, the pulse plays at the same moment. It replaces the disc that used to play after the marker had gone. +- **The conversion mark (option A).** While a Converter's markers move, a small two-arrow mark shows inside it and fades after the step; its inputs and output still move together. Each Converter has its own spot for the mark, at least 6 px inside the outline and clear of its title and mode text, worked out once per text, language or size change. When zoomed far out it takes the place of the type dot. +- **Focus mode, reduced motion and forced colours.** Both are cues inside a node, so Focus mode never fades them. Under reduced motion they stay still for the step; in forced colours the pulse is a 2 px system-colour line under the text and the mark takes the same colour. + +**No migration.** Engine, RNG, files, share links, digests and simulation results are unchanged; node sizes and text placement are unchanged. Three release-note lines in 18 languages, 16 of them without native review. The informational `meta.tool` string is now `loop-studio/0.23.0`. + ## v0.22.0 — 2026-10-09 Playback shows what moved where (issue #330, first of three): the path a Gate took, every amount as a `+N` badge beside its round token, and Focus mode winning on connections, as [`docs/simulation-playback.md`](docs/simulation-playback.md) §PB4.6 and [`docs/large-graph-readability.md`](docs/large-graph-readability.md) §LGR2.3 describe. diff --git a/README.md b/README.md index 5c6b87a..844ba81 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.22.0 +## Latest — 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. + +- **The Pool lights up on arrival**: when a moving marker reaches a Pool, the inside of the + Pool briefly glows, under its title and value; the number still changes when the step ends +- **A conversion mark inside the Converter**: while a Converter's markers move, a small + two-arrow mark shows inside it, at a spot clear of its text, and fades when the step ends +- **Never faded by Focus mode**: both stay at full strength in nodes outside the focus; with + reduced motion they stay still for the step, and in forced colours the glow is a thin line + +## v0.22.0 Playback shows what moved where: the path a Gate took, and every amount beside its moving marker. @@ -180,17 +192,8 @@ A fix release: the edit lock is exact, and its button shows the state. - **The lock button reads at a glance**: an open padlock when you can edit, a closed, highlighted one when editing is locked -## v0.21.2 - -A fix release: values and detail rows sit inside their node. - -- **Pool, Parameter and Register rows start with the title**, at least 8 px inside the - drawn outline; before, a Pool's value sat on its slanted side -- **Wider only where needed**: a node grows only when a row would not fit, up to the - usual maximum, and a row too long even then ends in "…"; positions, heights, files and - results are unchanged - -See [`CHANGELOG.md`](CHANGELOG.md) for the full notes of these releases, v0.21.1 (Focus +See [`CHANGELOG.md`](CHANGELOG.md) for the full notes of these releases, 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 colours in the minimap and the timeline), v0.19.0 (flow diff --git a/docs/large-graph-readability.md b/docs/large-graph-readability.md index 05fe7e5..b961039 100644 --- a/docs/large-graph-readability.md +++ b/docs/large-graph-readability.md @@ -232,8 +232,9 @@ the L0 pulse and the reduced-motion forms are drawn at the same 0.26 colours they are not faded either, the highlight takes the `1 5` dash, the token turns hollow and the badge's border is dashed (`docs/simulation-playback.md` §PB4.6). The run-in-progress cue and every cue -INSIDE a node (the fired wave, the evaluated mark) keep full strength, as -above. +INSIDE a node (the fired wave, the evaluated mark, and since issue #330 PR 2, +v0.23.0, the Pool arrival pulse and the conversion mark, §PB4.7) keep full +strength, as above. **A selected node is never de-emphasised.** Selection outranks focus: whatever the focus set says, a node the user has selected renders at full strength, with diff --git a/docs/simulation-playback-ordering.md b/docs/simulation-playback-ordering.md index 0338f32..50e283c 100644 --- a/docs/simulation-playback-ordering.md +++ b/docs/simulation-playback-ordering.md @@ -180,6 +180,12 @@ holds (`fill: CanvasText` on `converge`, `stroke-dasharray` + vanish on 1.2×); `absorb` overrides the end with the dissolve. - No new colour — all three read on `--flow-strength` / `currentColor`. - The direction marker, trail, count label, and §PB4.5 breakdown are unchanged. +- **The conversion mark (issue #330 PR 2, v0.23.0, option A).** A Converter's + inputs and output keep this order: they share the Converter's one onset and + their tokens move together — there is no consume-then-produce sequence. Only + a 10 px ⇄ mark inside the Converter says "converted here", shown from that + onset while the tokens move and faded after the `settle` + (`docs/simulation-playback.md` §PB4.7). Onsets are unchanged. ## PBO4. Reduced-motion & forced-colors — the role difference must survive diff --git a/docs/simulation-playback.md b/docs/simulation-playback.md index 79fdee1..cc3e09a 100644 --- a/docs/simulation-playback.md +++ b/docs/simulation-playback.md @@ -143,6 +143,12 @@ wall-clock by the current speed, §PB6): The exact fractions are a single constants block (`PLAYBACK_BEATS`), tunable in one place. +The target's inflow cue on a **Pool** is the **arrival pulse** (issue #330 PR 2, +v0.23.0, §PB4.7): it starts the moment that Pool's first token reaches it (the +connection's own arrive beat, on its local τ), plays inside the Pool, and runs a +fixed time across the `settle`; the Pool's value and delta still change only at +`settle`. + **PB2.2 — `committedStep === revealedStep`, always.** There is one clock: the store's `stepIndex`. It moves only at `settle`, so what the canvas shows and what the store holds are the same thing at every instant. There is no separate @@ -387,6 +393,42 @@ files, digests and every PB-INV / PBO-INV invariant are unchanged. - **Monte Carlo** draws no tokens, as before. The speed tiers (§PB6.1) are issue #330 PR 3; until then every speed draws this same form. +**PB4.7 — the cues inside a node: the Pool arrival pulse and the conversion +mark (issue #330 PR 2, v0.23.0).** A presentation layer over §PB2.1 and +§PBO3; the engine, the RNG, files, digests and every PB-INV / PBO-INV invariant +are unchanged. Both are drawn **inside the node only**, never across the +selection, keyboard-focus or invalid rings, and neither moves, resizes or +re-lays out the node. + +- **Pool arrival pulse.** A soft tint of the Pool's silhouette from 7 px inside + (clear of the flow-colour band and the focus ring), under the title and value, + which stay on top. It starts when the Pool's first token of the step reaches + it — the earliest arrive beat among its moved incoming connections — and runs + a fixed 0.5 s, carrying on across the `settle` (it is keyed on the step, so the + commit does not restart it); the next arrival at that Pool starts a new one. + Past the 24 pairs and at L0, where no token is drawn, it plays at the same + moment. The value and delta still change at `settle` (§PB2.4). It replaces the + disc that used to play at `settle`, after the token had gone. +- **Conversion mark (option A).** A 10 px ⇄ drawing inside the Converter, + shown from the Converter's own onset (its pull-in and push-out share one + onset, §PBO3) while its tokens move, then faded after the `settle`. Its spot + is the Converter's own: the free square nearest the end of the mode text, at + least 6 px inside the drawn outline and 1 px clear of the title, mode text + and type chip. The spot is computed from the silhouette and the measured text + once per change of the text, language, fonts or node size — never per frame, + never while a step plays — and is the same at L1 (where the mode text is + hidden but keeps its place). At L0 the mark takes the type dot's place and + size. No one fixed spot fits every Converter: the waisted shape and its text + leave a different pocket per node and language (a fixed spot after the mode + text missed 20 of the 450 bundled instances). +- **Focus mode:** both are cues inside a node and keep full strength on a node + outside the focus set (`docs/large-graph-readability.md` §LGR2.3). +- **Reduced motion:** both are held static for the committed step — the tint + and the mark at full strength, no fade — and clear on the next step or Reset + (§PB9). +- **Forced colours:** no tint; the pulse is a 2 px `Highlight` line 7–9 px + inside the outline, drawn under the text, and the mark takes the same colour. + ## PB5. Pause / Resume **PB5.1 — Pause keeps the prepared transition.** Pause stops the `τ` clock at its @@ -598,7 +640,10 @@ same RNG result). The choreography then: (target handle emphasis) cues **briefly** or all at once — the ordering is the information, so it is not removed, but it is **not padded to a long duration**; - runs `settle` (value delta chip, state effects) — a very short count-up or an - immediate snap. + immediate snap; +- holds the cues inside the nodes static for the committed step (issue #330 + PR 2, §PB4.7): the Pool arrival tint and the conversion mark, at full + strength, cleared on the next step or Reset. **PB9.2 — timing.** `beatDuration` may collapse toward its floor so the sequence is quick; it must not stretch playback out. Step still fast-forwards; Play still diff --git a/docs/visual-language.md b/docs/visual-language.md index 0c26b86..23dc2e4 100644 --- a/docs/visual-language.md +++ b/docs/visual-language.md @@ -536,6 +536,12 @@ byte-identical across L2/L1/L0 (§VL12.5). the moved path's highlight, the arrival tell, and the `+N` badge at the target end. A connection's own label never moves; it dims only while the token or its badge covers it. +- **Playback, issue #330 PR 2 (v0.23.0)** (`docs/simulation-playback.md` + §PB4.7): when a token reaches a Pool, a soft tint pulses inside the Pool + (0.5 s, under the text); a Converter whose tokens move shows a small ⇄ mark + 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. - Motion never conveys information that isn't also in a static frame. --- diff --git a/e2e/canvas-refresh-edges.spec.ts b/e2e/canvas-refresh-edges.spec.ts index 183ee3f..68a2973 100644 --- a/e2e/canvas-refresh-edges.spec.ts +++ b/e2e/canvas-refresh-edges.spec.ts @@ -146,7 +146,8 @@ test.describe('Canvas Refresh PR 2 — reduced motion: the flow bead contract', // the substitute: a persistent highlight on the edge that carried flow… await expect(page.locator('.react-flow__edge[data-id="r1"] .flow-edge-pulse')).toHaveCount(1) // …and the arrival cue on the pool it landed in, HELD (no fade-to-0 keyframe) - const arrival = page.locator('.react-flow__node[data-id="a"] .nodef__arrival') + // (issue #330 PR 2 — the arrival tint inside the Pool) + const arrival = page.locator('.react-flow__node[data-id="a"] .nodef__pulse') await expect(arrival).toHaveCount(1) const held = await arrival.evaluate((el) => { const cs = getComputedStyle(el) @@ -168,7 +169,7 @@ test.describe('Canvas Refresh PR 2 — reduced motion: the flow bead contract', await load(page) await step(page) // status → paused const pulse = page.locator('.react-flow__edge[data-id="r1"] .flow-edge-pulse') - const arrival = page.locator('.react-flow__node[data-id="a"] .nodef__arrival') + const arrival = page.locator('.react-flow__node[data-id="a"] .nodef__pulse') await expect(pulse).toHaveCount(1) await expect(arrival).toHaveCount(1) @@ -179,7 +180,7 @@ test.describe('Canvas Refresh PR 2 — reduced motion: the flow bead contract', await expect(page.locator(MOTION)).toHaveCount(0) // still nothing moving await reset(page) - await expect(page.locator('.flow-edge-pulse, .state-edge-pulse, .nodef__wave, .nodef__arrival')).toHaveCount(0) + await expect(page.locator('.flow-edge-pulse, .state-edge-pulse, .nodef__wave, .nodef__pulse, .nodef__conv')).toHaveCount(0) }) test('a state-edge effect under reduce is also static — no pulse travels', async ({ page }) => { diff --git a/e2e/large-graph-readability.spec.ts-snapshots/run-distinction-states-chromium-win32.png b/e2e/large-graph-readability.spec.ts-snapshots/run-distinction-states-chromium-win32.png index 30ba071..306ae8d 100644 Binary files a/e2e/large-graph-readability.spec.ts-snapshots/run-distinction-states-chromium-win32.png and b/e2e/large-graph-readability.spec.ts-snapshots/run-distinction-states-chromium-win32.png differ diff --git a/e2e/playback-node-cues.spec.ts b/e2e/playback-node-cues.spec.ts new file mode 100644 index 0000000..d7c4c7b --- /dev/null +++ b/e2e/playback-node-cues.spec.ts @@ -0,0 +1,329 @@ +import { readFileSync } from 'node:fs' +import type { Page } from '@playwright/test' +import { expect, importGraph, openApp, resetAll, test } from './support/loop' + +// issue #330 PR 2 (v0.23.0) — the cues INSIDE a node (docs/simulation-playback.md +// §PB2.1 / §PB9, docs/simulation-playback-ordering.md §PBO3): the Pool arrival +// pulse, a soft tint of the silhouette from 7 px inside that starts when the +// first round token arrives and carries on across the settle (the value still +// changes at settle); and the Converter's conversion mark (option A), a 10 px +// ⇄ drawing at the node's own spot — inside the outline by 6 px and clear of +// its text — shown from the Converter's onset, faded after the settle, taking +// the type dot's place at L0. Focus mode never dims them; reduced motion holds +// them static for the step; forced colours draw the pulse as a 2 px system- +// colour line under the text. 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() + return { step: s.stepIndex as number, tau: (s.transition?.tau ?? null) as number | null, flow: { ...(s.transition?.flowByEdge ?? {}) } as Record } + }) +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, six + * past the 24 token-and-badge pairs */ +const CAP_GRAPH = JSON.stringify({ + schema: 'loop-studio/graph', + version: 1, + nodes: Array.from({ length: 30 }, (_, i) => [ + { id: `s${i}`, type: 'source', position: { x: 0, y: i * 80 }, data: { kind: 'source', label: `S${i}`, activation: 'automatic', mode: 'pushAny' } }, + { id: `p${i}`, type: 'pool', position: { x: 260, y: i * 80 }, data: { kind: 'pool', label: `P${i}`, activation: 'passive', initial: 0, capacity: null, mode: 'pullAny' } }, + ]).flat(), + edges: Array.from({ length: 30 }, (_, i) => ({ id: `e${i}`, type: 'loop', source: `s${i}`, target: `p${i}`, 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) +} + +/** Play slowly and pause once `pred` holds (τ-based) */ +async function holdWhen(page: Page, pred: (s: Awaited>) => boolean) { + await call(page, 'setSpeed', 4000) + await call(page, 'play') + await expect.poll(async () => (pred(await sim(page)) ? 1 : -1), { timeout: 30000, intervals: [16] }).toBe(1) + await call(page, 'pause') +} + +const pulseOf = (page: Page, id: string) => page.locator(`.react-flow__node[data-id="${id}"] .nodef__pulse`) +const markOf = (page: Page, id: string) => page.locator(`.react-flow__node[data-id="${id}"] .nodef__conv`) +const valueOf = (page: Page, id: string) => page.locator(`.react-flow__node[data-id="${id}"] .nodef__value`).innerText() + +test.afterEach(async ({ page }) => { + await page.emulateMedia({ reducedMotion: null, forcedColors: null }).catch(() => {}) +}) + +test('the Pool pulse starts when the token arrives, the value changes at settle, and the settle does not restart it', async ({ page }) => { + await load(page, EQUILIBRIUM) + // mid-travel on the first step: the token is still on its way, no pulse yet + await holdWhen(page, (s) => s.step === 0 && s.tau != null && s.tau > 0.4 && s.tau < 0.7) + await expect(pulseOf(page, 'tpl-vault')).toHaveCount(0) + // the arrive beat (local τ 0.8) before the settle (0.95): the pulse plays, + // keyed on the step it belongs to, and the value has not changed yet + await call(page, 'play') + await expect.poll(async () => { const s = await sim(page); return s.step === 0 && s.tau != null && s.tau >= 0.81 && s.tau < 0.93 ? 1 : -1 }, { timeout: 20000, intervals: [16] }).toBe(1) + await call(page, 'pause') + await expect(pulseOf(page, 'tpl-vault')).toHaveCount(1) + await expect(pulseOf(page, 'tpl-vault')).toHaveAttribute('data-pulse-step', '1') + expect(await valueOf(page, 'tpl-vault')).toBe('0') + await pulseOf(page, 'tpl-vault').evaluate((el) => el.setAttribute('data-probe', 'same')) + // the settle commits the value; the same pulse element carries on + await call(page, 'play') + await expect.poll(async () => (await sim(page)).step, { timeout: 20000, intervals: [16] }).toBe(1) + await call(page, 'pause') + expect(await valueOf(page, 'tpl-vault')).toBe('3') + await expect(pulseOf(page, 'tpl-vault')).toHaveAttribute('data-probe', 'same') + // it is inside the silhouette only: a tint, no line outside forced colours + const look = await pulseOf(page, 'tpl-vault').evaluate((g) => ({ + tint: getComputedStyle(g.querySelector('.nodef__pulse-tint')!).display, + line: getComputedStyle(g.querySelector('.nodef__pulse-line')!).display, + mask: g.querySelector('.nodef__pulse-tint')!.getAttribute('mask'), + })) + expect(look.tint).not.toBe('none') + expect(look.line).toBe('none') + expect(look.mask).toMatch(/-pul\)$/) +}) + +test('past the 24 pairs and at L0, every Pool that received still pulses at the arrival', async ({ page }) => { + await load(page, CAP_GRAPH, 0.6) + await holdWhen(page, (s) => s.step === 0 && s.tau != null && s.tau >= 0.81 && s.tau < 0.93) + await expect(page.locator('.nodef__pulse')).toHaveCount(30) + await expect(page.locator('.pb-path--over-cap')).toHaveCount(6) + // L0: no token, the pulse is the same + await setViewport(page, 0.3) + await expect(page.locator('g.pb-move')).toHaveCount(0) + await expect(page.locator('.nodef__pulse')).toHaveCount(30) +}) + +test('the conversion mark shows from the Converter onset, at its own spot inside the outline and clear of its text, and fades after the settle', async ({ page }) => { + await load(page, EQUILIBRIUM, 1.5) + await holdWhen(page, (s) => s.tau != null && s.tau > 0.4 && s.tau < 0.7 && ((s.flow['tpl-e3'] ?? 0) > 0 || (s.flow['tpl-e5'] ?? 0) > 0)) + await expect(markOf(page, 'tpl-conv')).toHaveAttribute('data-conv-mark', 'live') + const geo = await page.locator('.react-flow__node[data-id="tpl-conv"] .nodef').evaluate((f) => { + const m = f.querySelector('.nodef__conv')!.getBoundingClientRect() + const fill = f.querySelector('path.nodef__fill') as SVGPathElement + const inv = fill.ownerSVGElement!.getScreenCTM()!.inverse() + const scale = f.getBoundingClientRect().width / parseFloat(getComputedStyle(f).width) + const inFill = (x: number, y: number) => fill.isPointInFill(new DOMPoint(x, y).matrixTransform(inv)) + let out = 0 + for (let i = 0; i <= 40; i++) { + const t = i / 40 + for (const [x, y] of [[m.left + t * m.width, m.top], [m.left + t * m.width, m.bottom], [m.left, m.top + t * m.height], [m.right, m.top + t * m.height]]) { + for (let a = 0; a < 16; a++) { + const r = (6 - 0.25) * scale + if (!inFill(x + Math.cos((a / 8) * Math.PI) * r, y + Math.sin((a / 8) * Math.PI) * r)) out++ + } + } + } + const range = document.createRange() + const boxes: DOMRect[] = [] + for (const sel of ['.nodef__title', '.nodef__sub']) { + range.selectNodeContents(f.querySelector(sel)!) + boxes.push(...[...range.getClientRects()].filter((r) => r.width > 0.5)) + } + const hits = boxes.filter((b) => !(m.right <= b.left || m.left >= b.right || m.bottom <= b.top || m.top >= b.bottom)).length + return { out, hits, size: m.width / scale } + }) + expect(geo).toEqual({ out: 0, hits: 0, size: 10 }) + // after the settle it fades + await call(page, 'play') + await expect.poll(async () => markOf(page, 'tpl-conv').getAttribute('data-conv-mark'), { timeout: 20000, intervals: [16] }).toBe('done') + await call(page, 'pause') + expect(await markOf(page, 'tpl-conv').evaluate((el) => getComputedStyle(el).animationName)).toBe('convOut') +}) + +test('at L0 the conversion mark takes the type dot’s place', async ({ page }) => { + await load(page, EQUILIBRIUM, 0.35) + await holdWhen(page, (s) => s.tau != null && s.tau > 0.4 && s.tau < 0.7 && (s.flow['tpl-e5'] ?? 0) > 0) + await expect(markOf(page, 'tpl-conv')).toHaveClass(/nodef__conv--map/) + await expect(page.locator('.react-flow__node[data-id="tpl-conv"] .nodef__cdot')).toHaveCount(0) + await expect(page.locator('.react-flow__node[data-id="tpl-vault"] .nodef__cdot')).toHaveCount(1) + expect(await markOf(page, 'tpl-conv').evaluate((el) => parseFloat(getComputedStyle(el).width))).toBe(18) +}) + +test('Focus mode never dims the pulse or the mark of a node outside the focus set', async ({ page }) => { + await load(page, EQUILIBRIUM) + await page.emulateMedia({ reducedMotion: 'reduce' }) + await page.evaluate(() => { + const l = (window as unknown as { __loop: Loop }).__loop + l.graph.getState().setSelection('tpl-src', null) + l.ui.getState().setFocusMode(true) + }) + for (let i = 0; i < 4; i++) { + await call(page, 'stepOnce') + await expect.poll(async () => (await sim(page)).tau).toBe(null) + } + await expect(page.locator('.react-flow__node[data-id="tpl-prod"]')).toHaveClass(/lgr-deemph/) + await expect(page.locator('.react-flow__node[data-id="tpl-conv"]')).toHaveClass(/lgr-deemph/) + const strength = (sel: string) => + page.locator(sel).evaluate((el) => { + let o = 1 + for (let e: Element | null = el; e && !e.classList.contains('react-flow__node'); e = e.parentElement) o *= Number(getComputedStyle(e).opacity) + return o + }) + expect(await strength('.react-flow__node[data-id="tpl-prod"] .nodef__pulse')).toBe(1) + expect(await strength('.react-flow__node[data-id="tpl-conv"] .nodef__conv')).toBe(1) + // the node itself is dimmed + expect(Number(await page.locator('.react-flow__node[data-id="tpl-prod"] .nodef__fill').evaluate((el) => getComputedStyle(el).opacity))).toBeCloseTo(0.26, 2) +}) + +test('reduced motion: the pulse tint and the mark are static, held through a pause, cleared on Reset', async ({ page }) => { + await load(page, EQUILIBRIUM) + await page.emulateMedia({ reducedMotion: 'reduce' }) + for (let i = 0; i < 3; i++) { + await call(page, 'stepOnce') + await expect.poll(async () => (await sim(page)).tau).toBe(null) + } + await expect(markOf(page, 'tpl-conv')).toHaveAttribute('data-conv-mark', 'done') + const still = async () => ({ + pulse: await pulseOf(page, 'tpl-prod').evaluate((el) => [getComputedStyle(el).animationName, getComputedStyle(el).opacity]), + mark: await markOf(page, 'tpl-conv').evaluate((el) => [getComputedStyle(el).animationName, getComputedStyle(el).opacity]), + }) + expect(await still()).toEqual({ pulse: ['none', '1'], mark: ['none', '1'] }) + await page.waitForTimeout(700) + expect(await still()).toEqual({ pulse: ['none', '1'], mark: ['none', '1'] }) + await call(page, 'reset') + await expect(page.locator('.nodef__pulse, .nodef__conv')).toHaveCount(0) +}) + +test('forced colours: the pulse is a 2 px system-colour line under the text, the mark takes the same colour', async ({ page }) => { + await page.emulateMedia({ forcedColors: 'active' }) + await load(page, EQUILIBRIUM, 1.5) + await page.emulateMedia({ forcedColors: 'active', reducedMotion: 'reduce' }) + for (let i = 0; i < 3; i++) { + await call(page, 'stepOnce') + await expect.poll(async () => (await sim(page)).tau).toBe(null) + } + const look = await page.evaluate(() => { + const probe = document.createElement('span') + probe.style.color = 'Highlight' + document.body.appendChild(probe) + const highlight = getComputedStyle(probe).color + probe.remove() + const g = document.querySelector('.react-flow__node[data-id="tpl-prod"] .nodef__pulse')! + const line = g.querySelector('.nodef__pulse-line')! + const mark = document.querySelector('.react-flow__node[data-id="tpl-conv"] .nodef__conv path')! + return { + tint: getComputedStyle(g.querySelector('.nodef__pulse-tint')!).display, + line: getComputedStyle(line).display, + lineStroke: getComputedStyle(line).stroke === highlight, + // the 2 px band: a stroke 2 × 9 px wide, masked to more than 7 px inside + lineWidth: line.getAttribute('stroke-width'), + markStroke: getComputedStyle(mark).stroke === highlight, + // drawn in the node's SVG, which sits under the HTML text + underText: g.closest('svg')!.compareDocumentPosition(document.querySelector('.react-flow__node[data-id="tpl-prod"] .nodef__body')!) === Node.DOCUMENT_POSITION_FOLLOWING, + } + }) + expect(look).toEqual({ tint: 'none', line: 'inline', lineStroke: true, lineWidth: '18', markStroke: true, underText: true }) +}) + +// The 450-instance regression: every Converter of every bundled Template, in +// every shipped language — the mark's spot exists, lies inside the drawn +// outline by 6 px and clear of the title, mode text and chip by 1 px, and is +// the same on a fresh mount and after a language round trip (no re-fit drift). +const LOCALES = ['en', 'ko', 'ja', 'zh-Hans', 'zh-Hant', 'de', 'fr', 'es-ES', 'es-419', 'pt-BR', 'pt-PT', 'it', 'nl', 'ru', 'tr', 'vi', 'th', 'ar'] +const CONVERTER_TEMPLATES: [string, number][] = [ + ['equilibrium', 1], + ['deadlock', 1], + ['mmo-progression', 23], +] + +for (const [tpl, perLocale] of CONVERTER_TEMPLATES) { + test(`${tpl}: every Converter's mark spot, in all 18 languages, is inside the outline by 6 px, clear of its text, and stable`, async ({ page }) => { + test.setTimeout(180_000) + await openApp(page) + await resetAll(page) + const measure = (loc: string, fresh: boolean) => + page.evaluate( + async ([tpl, loc, fresh]) => { + const l = (window as unknown as { __loop: Loop & { rf: { setViewport: (v: object, o: object) => void; getViewport: () => { zoom: number } } } }).__loop + const raf = async (n: number) => { for (let i = 0; i < n; i++) await new Promise((q) => requestAnimationFrame(q)) } + l.i18n.getState().setLocale(loc) + while (document.documentElement.lang !== loc) await raf(1) + if (fresh) { + l.graph.getState().newGraph() + await raf(3) + const T = await import('/src/model/templates.ts') + const L = await import('/src/i18n/templateLabels/index.ts') + await L.ensureTemplateLabelDict(loc) + const o = L.openTemplate(T.TEMPLATES.find((t: { id: string }) => t.id === tpl)!, loc) + l.graph.getState().loadGraph(o.graph, { canvasLocked: false, modelVersion: o.modelVersion, initialView: null, frames: o.graph.frames }) + } + await document.fonts.ready + for (let i = 0; i < 20; i++) { + l.rf.setViewport({ x: 20, y: 20, zoom: 1 }, { duration: 0 }) + await raf(4) + if (Math.abs(l.rf.getViewport().zoom - 1) < 1e-6) break + } + await raf(12) + const rows: { id: string; spot: string | null; out: number; hits: number }[] = [] + const convIds = [...document.querySelectorAll('.react-flow__node')].filter((n) => n.querySelector('.nodef--converter')).map((n) => n.dataset.id!) + for (const cid of convIds) { + // bring it into view: the fill test reads the drawn shape on screen + const pos = l.graph.getState().nodes.find((n: { id: string }) => n.id === cid).position + l.rf.setViewport({ x: 300 - pos.x, y: 300 - pos.y, zoom: 1 }, { duration: 0 }) + await raf(3) + const node = document.querySelector(`.react-flow__node[data-id="${CSS.escape(cid)}"]`)! + const f = node.querySelector('.nodef.nodef--converter')! + const spot = f.dataset.convSpot ?? null + let out = -1 + let hits = -1 + if (spot) { + const [sx, sy] = spot.split(',').map(Number) + const box = f.getBoundingClientRect() + const fill = f.querySelector('path.nodef__fill') as SVGPathElement + const inv = fill.ownerSVGElement!.getScreenCTM()!.inverse() + const inFill = (x: number, y: number) => fill.isPointInFill(new DOMPoint(x, y).matrixTransform(inv)) + const [x0, y0] = [box.left + sx, box.top + sy] + out = 0 + for (let i = 0; i <= 40; i++) { + const t = (i / 40) * 10 + for (const [x, y] of [[x0 + t, y0], [x0 + t, y0 + 10], [x0, y0 + t], [x0 + 10, y0 + t]]) { + for (let a = 0; a < 16; a++) if (!inFill(x + Math.cos((a / 8) * Math.PI) * 5.75, y + Math.sin((a / 8) * Math.PI) * 5.75)) out++ + } + } + const range = document.createRange() + const boxes: DOMRect[] = [] + for (const sel of ['.nodef__title', '.nodef__sub']) { + const el = f.querySelector(sel) + if (!el) continue + range.selectNodeContents(el) + boxes.push(...[...range.getClientRects()].filter((r) => r.width > 0.5)) + } + const chip = f.querySelector('.nodef__chip')?.getBoundingClientRect() + if (chip) boxes.push(chip) + hits = boxes.filter((b) => !(x0 + 10 <= b.left - 0.75 || x0 >= b.right + 0.75 || y0 + 10 <= b.top - 0.75 || y0 >= b.bottom + 0.75)).length + } + rows.push({ id: (node as HTMLElement).dataset.id!, spot, out, hits }) + } + return rows.sort((a, b) => a.id.localeCompare(b.id)) + }, + [tpl, loc, fresh] as const, + ) + let total = 0 + const bad: string[] = [] + let freshEn: string | null = null + for (const loc of LOCALES) { + const rows = await measure(loc, true) + expect(rows.length, `${tpl} ${loc}: Converters`).toBe(perLocale) + total += rows.length + for (const r of rows) if (!r.spot || r.out !== 0 || r.hits !== 0) bad.push(`${loc} ${r.id} ${JSON.stringify(r)}`) + if (loc === 'en') freshEn = JSON.stringify(rows.map((r) => [r.id, r.spot])) + } + expect(bad, bad.join('\n')).toEqual([]) + expect(total).toBe(perLocale * LOCALES.length) + // the same document, back in English after 17 other languages: the same spots + const back = await measure('en', false) + expect(JSON.stringify(back.map((r) => [r.id, r.spot]))).toBe(freshEn) + }) +} diff --git a/e2e/whats-new.spec.ts b/e2e/whats-new.spec.ts index 2980989..8351164 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.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.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 f24d6ce..98b2c43 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "loop-studio", - "version": "0.22.0", + "version": "0.23.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "loop-studio", - "version": "0.22.0", + "version": "0.23.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 896a600..869084b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "loop-studio", "private": true, - "version": "0.22.0", + "version": "0.23.0", "type": "module", "engines": { "node": ">=22.12.0" diff --git a/src/components/nodes/convMark.test.ts b/src/components/nodes/convMark.test.ts new file mode 100644 index 0000000..0e6e422 --- /dev/null +++ b/src/components/nodes/convMark.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest' +import { type Box, CONV_MARK, CONV_MARK_AFTER, CONV_MARK_GAP, CONV_MARK_INSET, converterOutline, convMarkSpot, markFits } from './convMark' +import { arriveTauOf } from './nodeCues' +import { BEAT_ARRIVE, BEAT_SETTLE } from '../../store/simStore' + +// issue #330 PR 2 — the conversion mark's spot inside a Converter + +const dist = (px: number, py: number, [[ax, ay], [bx, by]]: readonly (readonly [number, number])[]) => { + const dx = bx - ax + const dy = by - ay + const t = Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / (dx * dx + dy * dy || 1))) + return Math.hypot(px - (ax + t * dx), py - (ay + t * dy)) +} +const overlaps = (x: number, y: number, b: Box) => + !(x + CONV_MARK <= b.left - CONV_MARK_GAP || x >= b.right + CONV_MARK_GAP || y + CONV_MARK <= b.top - CONV_MARK_GAP || y >= b.bottom + CONV_MARK_GAP) + +// a typical one-line Converter at the compact floor: title on the first line, +// the mode text under it +const W = 118 +const H = 56 +const title: Box = { left: 26, top: 12, right: 97, bottom: 29 } +const mode: Box = { left: 12, top: 30, right: 52.8, bottom: 45 } +const chip: Box = { left: 12, top: 16.5, right: 20, bottom: 24.5 } + +describe('convMarkSpot', () => { + it('finds a spot inside the outline by the inset and clear of every text box', () => { + const s = convMarkSpot(W, H, [title, mode, chip], mode)! + expect(s).not.toBeNull() + const segs = converterOutline(W, H) + // an independent check: every point of the square's edge, every 0.25 px + for (let i = 0; i <= 4 * CONV_MARK; i++) { + const t = i / 4 + for (const [px, py] of [[s.x + t, s.y], [s.x + t, s.y + CONV_MARK], [s.x, s.y + t], [s.x + CONV_MARK, s.y + t]]) { + for (const seg of segs) expect(dist(px, py, seg)).toBeGreaterThanOrEqual(CONV_MARK_INSET - 0.13) + } + } + for (const b of [title, mode, chip]) expect(overlaps(s.x, s.y, b)).toBe(false) + }) + + it('is the free spot nearest the end of the mode text', () => { + const text = [title, mode, chip] + const s = convMarkSpot(W, H, text, mode)! + const tx = mode.right + CONV_MARK_AFTER + const ty = (mode.top + mode.bottom) / 2 - CONV_MARK / 2 + const best = Math.hypot(s.x - tx, s.y - ty) + const segs = converterOutline(W, H) + for (let y = 0; y <= H - CONV_MARK; y += 0.5) { + for (let x = 0; x <= W - CONV_MARK; x += 0.5) { + if (Math.hypot(x - tx, y - ty) >= best - 1e-9) continue + expect(text.some((b) => overlaps(x, y, b)) || !markFits(x, y, segs)).toBe(true) + } + } + }) + + it('follows the mode text: a longer mode moves the spot along', () => { + const a = convMarkSpot(160, H, [title, mode, chip], mode)! + const longer = { ...mode, right: 70 } + const b = convMarkSpot(160, H, [title, longer, chip], longer)! + expect(b.x).toBeGreaterThan(a.x) + }) + + it('is the same for the same input (no drift between reads)', () => { + expect(convMarkSpot(W, H, [title, mode, chip], mode)).toEqual(convMarkSpot(W, H, [title, mode, chip], mode)) + }) + + it('returns null when no spot is free', () => { + const all: Box = { left: 0, top: 0, right: W, bottom: H } + expect(convMarkSpot(W, H, [all], all)).toBeNull() + }) + + it('never accepts a square that reaches into the outline band', () => { + // flush against the bottom edge (y = H − 8), well inside horizontally + expect(markFits(W / 2 - CONV_MARK / 2, H - 8 - CONV_MARK - 2, converterOutline(W, H))).toBe(false) + // outside the shape entirely + expect(markFits(-20, -20, converterOutline(W, H))).toBe(false) + }) +}) + +describe('arriveTauOf', () => { + it('is the τ a connection reaches its arrive beat, on its own local τ', () => { + expect(arriveTauOf(0)).toBe(BEAT_ARRIVE) + const o = 0.3 + // LoopEdge: local τ = (τ − o) / (SETTLE − o) + expect((arriveTauOf(o) - o) / (BEAT_SETTLE - o)).toBeCloseTo(BEAT_ARRIVE, 12) + expect(arriveTauOf(o)).toBeLessThan(BEAT_SETTLE) + }) +}) diff --git a/src/components/nodes/convMark.ts b/src/components/nodes/convMark.ts new file mode 100644 index 0000000..44b7ac5 --- /dev/null +++ b/src/components/nodes/convMark.ts @@ -0,0 +1,99 @@ +// issue #330 PR 2 (v0.23.0), docs/simulation-playback-ordering.md §PBO3 — where +// a Converter's conversion mark sits. The Converter is a waisted shape whose +// title and mode text fill it, and no one fixed spot is free in every node and +// language (measured over every bundled Template in all 18 languages: a fixed +// spot after the mode text missed 20 of 450). So each node gets its own spot: +// the free square nearest the end of the mode text, at least +// `CONV_MARK_INSET` px inside the drawn outline (inside the keyboard-focus +// ring) and `CONV_MARK_GAP` px clear of every text box. Pure geometry: the +// caller measures the text once per content / language / font / size change, +// never per frame (nodes.tsx). + +import { silhouetteSegments } from './silhouette' + +/** the mark's side, CSS px */ +export const CONV_MARK = 10 +/** least distance from the mark to the drawn outline, CSS px (the focus ring + * ends 6 px in, `NODE_RINGS.focus.to`) */ +export const CONV_MARK_INSET = 6 +/** least gap between the mark and any text box, CSS px */ +export const CONV_MARK_GAP = 1 +/** where the search starts: this far after the end of the mode text */ +export const CONV_MARK_AFTER = 3 + +/** a box in CSS px from the node box's top-left */ +export type Box = { left: number; top: number; right: number; bottom: number } +export type Spot = { x: number; y: number } + +type Seg = readonly [readonly [number, number], readonly [number, number]] + +/** distance from point (px, py) to the segment a–b */ +function distToSeg(px: number, py: number, s: Seg): number { + const [[ax, ay], [bx, by]] = s + const dx = bx - ax + const dy = by - ay + const len2 = dx * dx + dy * dy + const t = len2 === 0 ? 0 : Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / len2)) + return Math.hypot(px - (ax + t * dx), py - (ay + t * dy)) +} + +/** even-odd point-in-polygon over the closed outline */ +function inside(px: number, py: number, segs: readonly Seg[]): boolean { + let n = 0 + for (const [[ax, ay], [bx, by]] of segs) { + if (ay > py !== by > py && px < ax + ((py - ay) / (by - ay)) * (bx - ax)) n++ + } + return n % 2 === 1 +} + +/** the outline of a Converter `w` × `h` CSS px, in CSS px (the SVG is drawn + * with `preserveAspectRatio="none"`, so x scales by `w / 120`) */ +export function converterOutline(w: number, h: number): Seg[] { + const k = w / 120 + return silhouetteSegments('converter', h).map(([a, b]) => [[a[0] * k, a[1]], [b[0] * k, b[1]]] as const) +} + +/** is the `CONV_MARK` square at (x, y) inside the outline by `CONV_MARK_INSET`? + * Its boundary is sampled every 0.5 px; a square whose boundary keeps that + * distance from every segment crosses none of them, so its centre decides + * inside or outside. */ +export function markFits(x: number, y: number, segs: readonly Seg[]): boolean { + const S = CONV_MARK + if (!inside(x + S / 2, y + S / 2, segs)) return false + for (let i = 0; i <= 2 * S; i++) { + const t = i / 2 + for (const [px, py] of [[x + t, y], [x + t, y + S], [x, y + t], [x + S, y + t]] as const) { + for (const s of segs) if (distToSeg(px, py, s) < CONV_MARK_INSET) return false + } + } + return true +} + +const clearOf = (x: number, y: number, text: readonly Box[]): boolean => + text.every( + (b) => + x + CONV_MARK <= b.left - CONV_MARK_GAP || + x >= b.right + CONV_MARK_GAP || + y + CONV_MARK <= b.top - CONV_MARK_GAP || + y >= b.bottom + CONV_MARK_GAP, + ) + +/** The mark's top-left for a Converter `w` × `h`, given its text boxes and the + * mode text's box (`mode`, null when there is none): the free spot nearest + * `CONV_MARK_AFTER` px after the mode text's end, centred on its row, on a + * 0.5 px grid (ties: higher, then nearer the start). `null` when no spot fits. */ +export function convMarkSpot(w: number, h: number, text: readonly Box[], mode: Box | null): Spot | null { + const segs = converterOutline(w, h) + const S = CONV_MARK + const tx = mode ? mode.right + CONV_MARK_AFTER : w / 2 - S / 2 + const ty = mode ? (mode.top + mode.bottom) / 2 - S / 2 : h / 2 - S / 2 + const cands: [number, number, number][] = [] + for (let y = 0; y <= h - S; y += 0.5) { + for (let x = 0; x <= w - S; x += 0.5) cands.push([Math.hypot(x - tx, y - ty), x, y]) + } + cands.sort((a, b) => a[0] - b[0] || a[2] - b[2] || a[1] - b[1]) + for (const [, x, y] of cands) { + if (clearOf(x, y, text) && markFits(x, y, segs)) return { x, y } + } + return null +} diff --git a/src/components/nodes/nodeCues.ts b/src/components/nodes/nodeCues.ts new file mode 100644 index 0000000..11b3d4b --- /dev/null +++ b/src/components/nodes/nodeCues.ts @@ -0,0 +1,102 @@ +// issue #330 PR 2 (v0.23.0) — when the cues INSIDE a node play, read from the +// playback transition (docs/simulation-playback.md §PB2.1, §PB9; +// docs/simulation-playback-ordering.md §PBO3). A presentation layer only: it +// reads the store, never writes it. +// +// - Pool arrival pulse: starts the moment the first round token reaches the +// Pool (that connection's arrive beat), runs a fixed time, and carries on +// across the settle under the same key; it restarts only at that Pool's next +// arrival. The value still changes at settle. Past the 24 pairs and at L0 the +// pulse plays at the same moment, without a token. +// - Conversion mark: shown from the Converter's own onset (its pull-in and +// push-out share one onset) through the settle, then fades. +// +// The per-node moments are derived ONCE per transition (keyed on its +// `flowByEdge` identity, which every τ tick carries by reference) and once per +// committed step (keyed on `activeByEdge`), so a τ frame reads two map lookups. + +import { useGraphStore } from '../../store/graphStore' +import { BEAT_ARRIVE, BEAT_SETTLE, useSimStore } from '../../store/simStore' + +type Transition = NonNullable['transition']> + +/** the global τ at which a connection with onset `o` reaches its arrive beat + * (LoopEdge's local τ: `τ` itself at onset 0, else `(τ − o) / (SETTLE − o)`) */ +export const arriveTauOf = (o: number): number => (o <= 0 ? BEAT_ARRIVE : o + BEAT_ARRIVE * (BEAT_SETTLE - o)) + +type StepCues = { + /** poolId → the τ its first token arrives */ + arrive: Map + /** converterId → the τ its connections start (the earliest onset) */ + convert: Map +} +const stepCues = new WeakMap() + +function cuesOf(t: Transition): StepCues { + const hit = stepCues.get(t.flowByEdge) + if (hit) return hit + const kind = new Map(useGraphStore.getState().nodes.map((n) => [n.id, n.data.kind])) + const arrive = new Map() + const convert = new Map() + for (const ev of t.events) { + if (!(ev.amount > 0)) continue + const onset = t.onsetByEdge[ev.edgeId] ?? 0 + if (kind.get(ev.to) === 'pool') { + const at = arriveTauOf(onset) + const was = arrive.get(ev.to) + if (was === undefined || at < was) arrive.set(ev.to, at) + } + for (const id of [ev.from, ev.to]) { + if (kind.get(id) !== 'converter') continue + const was = convert.get(id) + if (was === undefined || onset < was) convert.set(id, onset) + } + } + const c = { arrive, convert } + stepCues.set(t.flowByEdge, c) + return c +} + +/** the Converters a committed step moved something into or out of */ +const convertedCache = new WeakMap>() +function convertedIn(activeByEdge: Record): Set { + const hit = convertedCache.get(activeByEdge) + if (hit) return hit + const { nodes, edges } = useGraphStore.getState() + const conv = new Set(nodes.filter((n) => n.data.kind === 'converter').map((n) => n.id)) + const out = new Set() + for (const e of edges) { + if (!((activeByEdge[e.id] ?? 0) > 0)) continue + if (conv.has(e.source)) out.add(e.source) + if (conv.has(e.target)) out.add(e.target) + } + convertedCache.set(activeByEdge, out) + return out +} + +/** The Pool arrival pulse's key — the step it belongs to — or null for none. + * The same number before and after the settle, so the pulse is not + * restarted there; a new one starts at the next arrival. */ +export function usePoolPulseKey(id: string): number | null { + return useSimStore((s) => { + const t = s.transition + if (t) { + const at = cuesOf(t).arrive.get(id) + if (at != null && t.tau >= at) return t.fromStep + 1 + } + return s.arrivedPoolIds.includes(id) ? s.stepIndex : null + }) +} + +/** The conversion mark's state: `:live` while the step's tokens move, + * `:done` once it has settled (it fades), or null. */ +export function useConversionMark(id: string): string | null { + return useSimStore((s) => { + const t = s.transition + if (t) { + const at = cuesOf(t).convert.get(id) + if (at != null && t.tau >= at) return `${t.fromStep + 1}:live` + } + return convertedIn(s.activeByEdge).has(id) ? `${s.stepIndex}:done` : null + }) +} diff --git a/src/components/nodes/nodes.tsx b/src/components/nodes/nodes.tsx index 2dfecd9..51591cd 100644 --- a/src/components/nodes/nodes.tsx +++ b/src/components/nodes/nodes.tsx @@ -12,6 +12,7 @@ import { clampNodeHeight, maskBox, NODE_RINGS, + POOL_PULSE_INSET, silhouettePath, VESSEL_INSET_Y, VESSEL_MIN_PAD_Y, @@ -27,6 +28,8 @@ import { useI18n } from '../../i18n/store' import { usePhrasedTitle } from './phraseTitle' import { InsideMask, OutsideMask } from './RingMasks' import { fitRows, type MeasuredRow, type RowFit, rowFitMeasured } from './rowFit' +import { type Box, convMarkSpot, type Spot } from './convMark' +import { useConversionMark, usePoolPulseKey } from './nodeCues' import type { ConverterData, DrainData, @@ -148,6 +151,41 @@ function measureRowFit(frame: HTMLElement, stack: HTMLElement, kind: NodeKind, h ) } +/** issue #330 PR 2 — the Converter's conversion-mark spot (./convMark): its + * title, mode text and chip boxes in CSS px within the box (rects scaled + * back by the canvas zoom), then the geometry decides. One pass per change, + * like the row fit; the mark is absolutely placed, so it moves nothing. */ +function measureConvSpot(frame: HTMLElement, width: number, h: number): Spot | null { + const box = frame.getBoundingClientRect() + const scale = box.width / width || 1 + // `h` may not be drawn yet: the body centres the stack vertically + const dy = (h - frame.offsetHeight) / 2 + const rel = (r: DOMRect): Box => ({ + left: (r.left - box.left) / scale, + top: (r.top - box.top) / scale + dy, + right: (r.right - box.left) / scale, + bottom: (r.bottom - box.top) / scale + dy, + }) + const glyphs = (el: Element | null): Box[] => { + if (!el) return [] + const range = document.createRange() + range.selectNodeContents(el) + return [...range.getClientRects()].filter((r) => r.width > 0.5).map(rel) + } + const mode = glyphs(frame.querySelector('.nodef__sub')) + const chip = frame.querySelector('.nodef__chip') + const text = [...glyphs(frame.querySelector('.nodef__title')), ...mode, ...(chip ? [rel(chip.getBoundingClientRect())] : [])] + const modeBox: Box | null = mode.length + ? { + left: Math.min(...mode.map((b) => b.left)), + top: Math.min(...mode.map((b) => b.top)), + right: Math.max(...mode.map((b) => b.right)), + bottom: Math.max(...mode.map((b) => b.bottom)), + } + : null + return convMarkSpot(width, h, text, modeBox) +} + /** a count of the web-font loads that have finished: a face that arrives after * the first measurement changes a row's text width without resizing a stack * the fit already holds, so each load re-reads the fit once */ @@ -257,7 +295,12 @@ type FrameProps = { * primary-fill copy of the silhouette at this opacity (0…~0.15). 0 / absent * ⇒ nothing renders. Never covers the run cues / rings (drawn after it). */ activity?: number - arriving?: boolean + /** issue #330 PR 2 — the Pool arrival pulse's step key (./nodeCues), or + * null / absent for none */ + pulseKey?: number | null + /** issue #330 PR 2 — the Converter's conversion mark, `:live` / + * `:done` (./nodeCues), or null / absent for none */ + conversion?: string | null /** §VL3 — the model layer's `invalid` state (a Register the engine can't * evaluate, or an unreadable model node). A `--warning` dashed outline + a * top-right `!` flag; carries no value (the caller passes `—`). */ @@ -280,7 +323,8 @@ function NodeFrame({ firing, evaluated, activity, - arriving, + pulseKey, + conversion, invalid, stepKey, }: FrameProps) { @@ -379,6 +423,26 @@ function NodeFrame({ const next = measureRowFit(frame, stack, kind, h) setFit((prev) => (sameFit(prev, next) ? prev : next)) }, [fitKey, boxH, kind]) + // issue #330 PR 2 — a Converter's conversion-mark spot, read once per change + // of the rendered strings, the language, the fonts or the box size (never per + // frame, never while a step plays); the same at L1, where the mode row is + // hidden but keeps its place + const [convSpot, setConvSpot] = useState(null) + const convInput = useRef('') + useLayoutEffect(() => { + const frame = frameRef.current + if (!frame || kind !== 'converter') return + const width = parseFloat(getComputedStyle(frame).width) || frame.offsetWidth + const input = `${fitKey}|${boxH}|${width}` + if (input === convInput.current) return + convInput.current = input + const next = measureConvSpot(frame, width, boxH) + setConvSpot((prev) => (prev?.x === next?.x && prev?.y === next?.y ? prev : next)) + }, [fitKey, boxH, kind]) + const convPhase = conversion ? conversion.slice(conversion.indexOf(':') + 1) : null + const convStep = conversion ? conversion.slice(0, conversion.indexOf(':')) : null + // at L0 the mark takes the type dot's place; elsewhere it needs its spot + const convOn = convPhase != null && (mapOnly || convSpot != null) // No explicit `updateNodeInternals` call here: React Flow's own internal // per-node ResizeObserver already keeps `node.measured` (and the handle // bounds edge routing reads) in sync with this wrapper's real DOM size, @@ -441,6 +505,7 @@ function NodeFrame({ } data-invalid={invalid ? '' : undefined} data-accent={accent} + data-conv-spot={convSpot ? `${convSpot.x},${convSpot.y}` : undefined} style={ grown || accent || fit ? { @@ -495,7 +560,7 @@ function NodeFrame({ {/* FC-4.1 — the masks that cut each ring out of a wider stroke at a fixed px distance from the silhouette (NODE_RINGS), and the clip that keeps the flow-colour band inside it */} - {accent || selected || invalid || focused ? ( + {accent || selected || invalid || focused || pulseKey != null ? ( {accent ? ( @@ -505,6 +570,7 @@ function NodeFrame({ {selected ? : null} {invalid ? : null} {focused ? : null} + {pulseKey != null ? : null} ) : null} {/* FC-4.1 — the flow-colour band: just inside the structure line, @@ -553,17 +619,25 @@ function NodeFrame({ /> ) : null} {firing ? : null} - {arriving ? ( - + {/* issue #330 PR 2 — the Pool arrival pulse: a soft tint of the + silhouette from `POOL_PULSE_INSET` px inside (clear of every ring), + under the text, from the first token's arrival; its forced-colours + form is the 2 px line (index.css). Keyed on its step, so the settle + does not restart it. */} + {pulseKey != null ? ( + + + + ) : null} - {/* L0 map: type colour collapses to one dot inside the silhouette */} - {mapOnly ? : null} + {/* L0 map: type colour collapses to one dot inside the silhouette; a + conversion mark takes its place while it shows */} + {mapOnly && !convOn ? : null} {/* §VL4 — one persistent flag, top-right, non-colour tell for `invalid` */} @@ -586,6 +660,24 @@ function NodeFrame({