diff --git a/.changes/draw-start-batch.json b/.changes/draw-start-batch.json new file mode 100644 index 00000000..9d6b0e9a --- /dev/null +++ b/.changes/draw-start-batch.json @@ -0,0 +1 @@ +{ "type": "user-facing", "releaseNoteId": "release:0.28.4" } diff --git a/CHANGELOG.md b/CHANGELOG.md index e7d9382c..2246933c 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.28.4 — 2026-10-17 + +Starting and finishing a connection drag responds faster ([`docs/edge-routing.md`](docs/edge-routing.md) §ER17.1). + +- **What was slow.** While a connection or an end grip is dragged, every node shows all of its sides so the drop can choose one, and when the drag ends the unused ones go. Each node then measured its handles again on its own — one update of the canvas's state per node, each passed to everything listening to it — so the start and the end of a drag grew slow on large diagrams. +- **Now.** One measurement covers every node: at the start at once, before anything is drawn; at the end on the next frame, as before. A new drag started right after one ends is measured on its own. While drawing, every side of every node is still offered. +- **How much.** On one machine's production build, the 63-node 3-zone gacha banner comparison started a drag in about 70–80 ms instead of 100–120 ms, and in about 370–460 ms instead of 800–850 ms with the processor slowed four times; releasing the drag on the empty canvas improved about as much; Coffee roastery operations flow did not get slower. Only those two gestures were timed: a release that makes a connection and an end grip drag take the same path and were checked to work as before, not timed. The full measurement is kept with the work, not in the app. + +**Notes.** Three What's new lines in 18 languages: the translation contract tests pass, and none had native-speaker review. + ## v0.28.3 — 2026-10-15 Undo and Redo leave nothing selected, on the canvas as in the Inspector ([`docs/large-graph-readability.md`](docs/large-graph-readability.md) LGR-D17); a module insert restores its own selection ([`docs/module-system.md`](docs/module-system.md) §MS3.5). diff --git a/README.md b/README.md index eef22883..7a3c9c74 100644 --- a/README.md +++ b/README.md @@ -140,7 +140,16 @@ Additional feature-specific design documents (localization, mobile, module system, large-graph readability, simulation playback, edge routing, data import, …) live under [`docs/`](docs/). -## Latest — v0.28.3 +## Latest — v0.28.4 + +Starting and finishing a connection responds faster. + +- **Starting to draw**: responds faster, with a larger improvement in large diagrams +- **Releasing on the empty canvas**: a connection drag released there also responds faster +- **Every side stays available**: while drawing a connection, handles on every side of every + node remain available + +## v0.28.3 Undo and Redo leave nothing selected. @@ -176,23 +185,8 @@ A connection's label keeps off the bend points you placed on it, where its line - **Early MMO example**: no longer listed under Templates; it stays as developer test material for large diagrams, and a document you already made from it still opens and works as before -## v0.28.0 - -A connection can be turned around without drawing it again. - -- **Reverse a connection**: select it and press Reverse beside Ends in the Inspector; its - amount, condition, colour and line shape stay as they were and are read in the new - direction, the bends you placed keep their places, and one undo step takes it back -- **What may look different**: the automatic route, the line next to each end and the - label's place may be drawn differently after a reversal -- **Refused reversals say why**: a reversal that would end a connection at a node with no - input, start it at a node with no output, repeat a connection drawn the same way, or leave a - state connection with no effect is not applied, and the reason is shown - -On a phone the diagram stays read-only. Curves you shape yourself and moving a connection's -label are not in this release. - -See [`CHANGELOG.md`](CHANGELOG.md) for the full notes of these releases, v0.27.0 (a +See [`CHANGELOG.md`](CHANGELOG.md) for the full notes of these releases, v0.28.0 (a +connection turned around without drawing it again), v0.27.0 (a connection's end moves without drawing the connection again), v0.26.2 (playback in the chosen colours, and the moving `+N` badges kept off the nodes), v0.26.0 (the compact node with its name outside, connections from any side, notices under the toolbar), diff --git a/docs/edge-routing.md b/docs/edge-routing.md index 9731e798..4a70cb6c 100644 --- a/docs/edge-routing.md +++ b/docs/edge-routing.md @@ -1000,6 +1000,39 @@ on purpose: a face is placement, and taking the whole document away from an olde to protect it would cost more than it saves (decision D-A). The user-facing change note of the release that ships this says the same in one sentence. +### ER17.1 The face handles and when they are measured + +A node mounts the handle of a face only when it is needed: a face one of its +connections uses (always, hidden — React Flow draws no edge whose handle id is +missing), and every face while a connection or an end grip is dragged (every +node, so the drop can choose a face) or while the node is selected or +keyboard-focused (`src/components/nodes/nodes.tsx`, `facesOn`). A mount or +unmount without a size change is invisible to React Flow's ResizeObserver, so +the handles' bounds are measured again: + +- a change that ADDS a handle — at once, in the node's layout effect: before + paint and before React Flow's edges reach its store (a passive effect), so the + connection that needs the handle finds it (React Flow error 008 otherwise); +- a change that only REMOVES handles — on the next animation frame: a store + update in the layout effect would run React Flow's edge selectors of the + previous render, which still look for the removed handle; +- **the start and the end of a drag change every node in the same commit and + are measured ONCE for all nodes** (`src/components/nodes/faceBatch.ts`): the + first node whose layout effect sees the drag state change measures every + node in one `updateNodeInternals` call — at the start at once (the commit's + DOM is complete, nothing painted), at the end on the next frame (only + removals) — and a new drag cancels an end's frame that has not run yet (a + frame left when the canvas goes away still runs, finds no canvas and + measures nothing). The state is each canvas's, keyed by the store's own + `getState` (`useStoreApi()` gives every component its own wrapper object), + and a drag also ends when React Flow's `connection.inProgress` goes false, + so the next drag is measured even when no node saw the last one end (every + node gone during the drag, e.g. its insert undone). + Measured one node at a time, each was one React Flow store update run + through every subscriber, and starting or ending a drag on the 63-node gacha + banner Template took about 1.5 to 2.5 times as long, on the production build + of one machine at CPU × 1 and × 4 (share note 58). + ## ER18. Moving a connection's end (line editing, first unit) An existing connection's source or target end can be moved to another side of the diff --git a/e2e/connection-draw-faces.spec.ts b/e2e/connection-draw-faces.spec.ts new file mode 100644 index 00000000..cf4279d8 --- /dev/null +++ b/e2e/connection-draw-faces.spec.ts @@ -0,0 +1,346 @@ +import { readFileSync } from 'node:fs' +import type { Page } from '@playwright/test' +import { expect, openApp, routesSettled, test } from './support/loop' + +// The face handles while a connection is drawn (share note 58): when a drag +// begins every node mounts its faces and ONE measurement covers every node +// (src/components/nodes/faceBatch.ts); when it ends the unused faces go and one +// measurement on the next frame follows. On the small node-structure fixture: +// - one drag can land on an unused face of EVERY node: React Flow picks the +// closest handle from its measured bounds (`connectingto`), so a node left +// out of the measurement would offer none; +// - a drag released and started again in the same task (before the end's +// measurement has run), and five drags started and released back to back, +// leave the next connection made on the face it is dropped on, ending on +// that handle, its line drawn on every frame, the faces back afterwards; +// - a refused drawing (a repeat of a connection drawn the same way) makes +// nothing and the next connection is made; +// - Esc cancels a drag of an end grip (the canvas's one Esc-cancellable +// drag; drawing a new connection has no Esc cancel), the faces go back, +// and the next connection is made; +// - no React Flow 008 warning in any of it (printed by development builds). +// The cancel-and-restart checks dispatch mouse events in the page (React +// Flow's handle drag listens to `mousedown` on the handle, then `mousemove` / +// `mouseup` on the document), so the order inside one task is exact. + +const FIXTURE = readFileSync(new URL('./fixtures/node-structure-small.json', import.meta.url), 'utf8') +type L = { graph: { getState: () => any }; rf: { setViewport: (v: object, o?: object) => void } } +const AT_REST = { arrivals: [], waiting: [], triage: [], treated: ['out@bottom'], discharged: ['in@top'], rate: [], occupancy: [] } + +async function open(page: Page, warnings: string[]) { + page.on('console', (m) => { + if (/Couldn't create edge|error#008/.test(m.text())) warnings.push(m.text().slice(0, 160)) + }) + await openApp(page) + await page.setInputFiles('.toolbar__actions input[type=file]', { name: 'node-structure-small.json', mimeType: 'application/json', buffer: Buffer.from(FIXTURE, 'utf8') }) + await expect(page.locator('.react-flow__node')).toHaveCount(7) + await page.evaluate(() => (window as unknown as { __loop: L }).__loop.rf.setViewport({ x: 40, y: 60, zoom: 1 }, { duration: 0 })) + await routesSettled(page) +} + +const mountedFaces = (page: Page) => + page.evaluate(() => { + const out: Record = {} + for (const n of document.querySelectorAll('.react-flow__node')) + out[(n as HTMLElement).dataset.id!] = [...n.querySelectorAll('.react-flow__handle.h--extra')].map((h) => (h as HTMLElement).dataset.handleid!).sort() + return out + }) +const edges = (page: Page) => + page.evaluate(() => { + const g = (window as unknown as { __loop: L }).__loop.graph.getState() + return { past: g.past.length as number, list: g.edges.map((e: any) => ({ id: e.id as string, source: e.source as string, target: e.target as string, ports: e.data?.ports ?? null })) } + }) +const face = (page: Page, node: string, handle: string) => page.locator(`.react-flow__node[data-id="${node}"] .react-flow__handle[data-handleid="${handle}"]`) +const centreOf = async (page: Page, node: string, handle: string) => { + const b = (await face(page, node, handle).boundingBox())! + return { x: b.x + b.width / 2, y: b.y + b.height / 2 } +} + +/** an empty spot of the pane, away from every node and line: the first point + * of a grid, from the lower right, where the pane itself is on top */ +async function emptySpot(page: Page) { + const p = await page.evaluate(() => { + const r = document.querySelector('.react-flow__pane')!.getBoundingClientRect() + for (let y = r.bottom - 40; y > r.top + 40; y -= 24) + for (let x = r.right - 380; x > r.left + 40; x -= 24) + if (document.elementFromPoint(x, y)?.classList.contains('react-flow__pane')) return { x, y } + return null + }) + expect(p, 'an empty spot of the pane').not.toBeNull() + return p! +} + +/** after a drop: nothing selected, nothing focused, so each node shows the faces its connections use */ +async function settle(page: Page) { + const e = await emptySpot(page) + await page.mouse.click(e.x, e.y) + await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur()) +} + +/** the connection's end is on the handle's centre, within 2 px */ +const endToHandle = (page: Page, edgeId: string, node: string, handle: string) => + page.evaluate( + ({ edgeId, node, handle }) => { + const p = document.querySelector(`.react-flow__edge[data-id="${edgeId}"] path.react-flow__edge-path`) as SVGPathElement | null + const h = document.querySelector(`.react-flow__node[data-id="${node}"] .react-flow__handle[data-handleid="${handle}"]`) + if (!p || !h) return null + const q = p.getPointAtLength(p.getTotalLength()) + const m = p.getScreenCTM()! + const r = h.getBoundingClientRect() + return Math.hypot(m.a * q.x + m.c * q.y + m.e - (r.left + r.right) / 2, m.b * q.x + m.d * q.y + m.f - (r.top + r.bottom) / 2) + }, + { edgeId, node, handle }, + ) + +/** the connection's path is in the DOM with a `d` on every animation frame for 1 s; the frames without it */ +const framesWithoutPath = (page: Page, edgeId: string) => + page.evaluate( + (edgeId) => + new Promise((res) => { + let missing = 0 + const t0 = performance.now() + const tick = () => { + const p = document.querySelector(`.react-flow__edge[data-id="${edgeId}"] path.react-flow__edge-path`) + if (!p || !p.getAttribute('d')) missing++ + if (performance.now() - t0 < 1000) requestAnimationFrame(tick) + else res(missing) + } + requestAnimationFrame(tick) + }), + edgeId, + ) + +/** draw from `from`'s handle onto `to`'s face with the real pointer */ +async function draw(page: Page, from: [string, string], to: [string, string]) { + const a = await centreOf(page, ...from) + await page.mouse.move(a.x, a.y) + await page.mouse.down() + await page.mouse.move(a.x + 30, a.y + 30, { steps: 4 }) + await expect(face(page, ...to)).toHaveCount(1) + const b = await centreOf(page, ...to) + await page.mouse.move(b.x, b.y, { steps: 12 }) + await page.mouse.up() +} + +/** the last connection is `from` → `to` on the target face, drawn on its handle on every frame */ +async function expectMade(page: Page, n0: number, source: string, target: string, handle: string, side: string) { + await expect.poll(async () => (await edges(page)).list.length).toBe(n0 + 1) + const made = (await edges(page)).list.at(-1)! + expect(made).toMatchObject({ source, target, ports: { target: side } }) + await expect.poll(() => endToHandle(page, made.id, target, handle)).toBeLessThan(2) + expect(await framesWithoutPath(page, made.id), 'animation frames without the new line').toBe(0) + return made +} + +/** in-page mouse events on a handle and the document, in React Flow's order */ +const dispatch = (page: Page, steps: ([string, string, string] | ['move' | 'up', number, number])[]) => + page.evaluate((steps) => { + let rafRan = false + requestAnimationFrame(() => (rafRan = true)) + const fire = (t: EventTarget, type: string, x: number, y: number, buttons: number) => + t.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, clientX: x, clientY: y, button: 0, buttons, view: window })) + let last = { x: 0, y: 0 } + for (const s of steps) { + if (s[0] === 'down') { + const h = document.querySelector(`.react-flow__node[data-id="${s[1]}"] .react-flow__handle[data-handleid="${s[2]}"]`)! + const r = h.getBoundingClientRect() + last = { x: (r.left + r.right) / 2, y: (r.top + r.bottom) / 2 } + fire(h, 'mousedown', last.x, last.y, 1) + } else if (s[0] === 'move') { + last = { x: last.x + (s[1] as number), y: last.y + (s[2] as number) } + fire(document, 'mousemove', last.x, last.y, 1) + } else { + fire(document.elementFromPoint(last.x, last.y) ?? document, 'mouseup', last.x, last.y, 0) + } + } + return { rafRan } + }, steps) + +test.describe('drawing a connection: the face handles of every node (share note 58)', () => { + test('one drag can land on an unused face of every node; the faces go when it ends', async ({ page }) => { + const warnings: string[] = [] + await open(page, warnings) + const a = await centreOf(page, 'arrivals', 'out') + await page.mouse.move(a.x, a.y) + await page.mouse.down() + await page.mouse.move(a.x + 30, a.y + 30, { steps: 4 }) + // every node that takes an input offers its unused faces, and React Flow + // finds each (the Register has no resource input: its state input face) + for (const [node, handle] of [['waiting', 'in@top'], ['triage', 'in@bottom'], ['treated', 'in@top'], ['discharged', 'in@bottom'], ['occupancy', 'state-target@left']] as const) { + await expect(face(page, node, handle)).toHaveCount(1) + const b = await centreOf(page, node, handle) + await page.mouse.move(b.x, b.y, { steps: 6 }) + await expect(face(page, node, handle), `${node} ${handle}: measured, so React Flow picks it`).toHaveClass(/\bconnectingto\b/) + } + const n0 = (await edges(page)).list.length + const e = await emptySpot(page) + await page.mouse.move(e.x, e.y, { steps: 6 }) + await page.mouse.up() + await settle(page) + expect((await edges(page)).list.length).toBe(n0) + await expect.poll(() => mountedFaces(page)).toEqual(AT_REST) + expect(warnings).toEqual([]) + }) + + test('released and started again in one task, then dropped on a face: made on that face', async ({ page }) => { + const warnings: string[] = [] + await open(page, warnings) + const n0 = (await edges(page)).list.length + const e = await emptySpot(page) + const out = await centreOf(page, 'arrivals', 'out') + const { rafRan } = await dispatch(page, [ + ['down', 'arrivals', 'out'], + ['move', 30, 30], + ['move', e.x - out.x - 30, e.y - out.y - 30], + ['up', 0, 0], + ['down', 'arrivals', 'out'], + ['move', 30, 30], + ]) + expect(rafRan, 'the new drag started before the next animation frame').toBe(false) + await expect.poll(async () => (await mountedFaces(page)).discharged).toContain('in@bottom') + // the rest of the drag with the real pointer is not possible (its button is + // not down), so it is finished in the page too, onto discharged's bottom face + const t = await centreOf(page, 'discharged', 'in@bottom') + await page.evaluate(({ x, y }) => { + const fire = (tg: EventTarget, type: string, b: number) => tg.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, clientX: x, clientY: y, button: 0, buttons: b, view: window })) + fire(document, 'mousemove', 1) + fire(document.elementFromPoint(x, y) ?? document, 'mouseup', 0) + }, t) + await expectMade(page, n0, 'arrivals', 'discharged', 'in@bottom', 'bottom') + await settle(page) + await expect.poll(() => mountedFaces(page)).toEqual({ ...AT_REST, discharged: ['in@bottom', 'in@top'] }) + expect(warnings).toEqual([]) + }) + + test('five drags started and released back to back, then a connection drawn onto a face', async ({ page }) => { + const warnings: string[] = [] + await open(page, warnings) + const n0 = (await edges(page)).list.length + const e = await emptySpot(page) + for (const [node, handle] of [['arrivals', 'out'], ['waiting', 'out'], ['triage', 'out'], ['arrivals', 'out'], ['waiting', 'out']] as const) { + const c = await centreOf(page, node, handle) + await dispatch(page, [['down', node, handle], ['move', 25, 25], ['move', e.x - c.x - 25, e.y - c.y - 25], ['up', 0, 0]]) + } + expect((await edges(page)).list.length, 'the cancelled drags made nothing').toBe(n0) + await draw(page, ['waiting', 'out'], ['treated', 'in@top']) + await expectMade(page, n0, 'waiting', 'treated', 'in@top', 'top') + await settle(page) + await expect.poll(() => mountedFaces(page)).toEqual({ ...AT_REST, treated: ['in@top', 'out@bottom'] }) + expect(warnings).toEqual([]) + }) + + test('a refused drawing makes nothing, the faces go back, and the next connection is made', async ({ page }) => { + const warnings: string[] = [] + await open(page, warnings) + const before = await edges(page) + // arrivals → waiting on the default faces repeats e_arrive: refused + await draw(page, ['arrivals', 'out'], ['waiting', 'in']) + await settle(page) + const after = await edges(page) + expect(after.list).toEqual(before.list) + expect(after.past, 'nothing recorded').toBe(before.past) + await expect.poll(() => mountedFaces(page)).toEqual(AT_REST) + await draw(page, ['arrivals', 'out'], ['discharged', 'in@bottom']) + await expectMade(page, before.list.length, 'arrivals', 'discharged', 'in@bottom', 'bottom') + expect(warnings).toEqual([]) + }) + + test('every node gone during a drag (its insert undone) does not stop the next drag measuring every node', async ({ page }) => { + const warnings: string[] = [] + page.on('console', (m) => { + if (/Couldn't create edge|error#008/.test(m.text())) warnings.push(m.text().slice(0, 160)) + }) + await openApp(page) + await page.evaluate(() => (window as unknown as { __loop: L }).__loop.graph.getState().newGraph()) + await expect(page.locator('.react-flow__node')).toHaveCount(0) + // one undo step that adds every node: a module insert + const menu = page.locator('.toolbar__actions .menu', { has: page.getByRole('button', { name: 'Insert module' }) }) + await menu.getByRole('button', { name: 'Insert module' }).click() + await menu.locator('.menu__item').filter({ has: page.locator('.menu__name', { hasText: 'Reward split loop' }) }).click() + await expect.poll(() => page.locator('.react-flow__node').count()).toBeGreaterThan(2) + await routesSettled(page) + await settle(page) + const ids = await page.evaluate(() => + [...document.querySelectorAll('.react-flow__node')].map((n) => ({ + id: (n as HTMLElement).dataset.id!, + out: !!n.querySelector('.react-flow__handle[data-handleid="out"]'), + in: !!n.querySelector('.react-flow__handle[data-handleid="in"]'), + })), + ) + const from = ids.find((n) => n.out)! + const to = ids.find((n) => n.in && n.id !== from.id)! + // a drag from a node, then Ctrl+Z takes every node away while it is held, + // and the drag is released on the empty canvas: no node sees it end + const a = await centreOf(page, from.id, 'out') + await page.mouse.move(a.x, a.y) + await page.mouse.down() + await page.mouse.move(a.x + 30, a.y + 30, { steps: 4 }) + await expect.poll(async () => (await mountedFaces(page))[to.id]?.length ?? 0).toBeGreaterThan(0) + await page.keyboard.press('Control+z') + await expect(page.locator('.react-flow__node')).toHaveCount(0) + const e = await emptySpot(page) + await page.mouse.move(e.x, e.y, { steps: 4 }) + await page.mouse.up() + // the nodes come back; a new drag must offer — and React Flow must find — an unused face of another node + await page.keyboard.press('Control+y') + await expect(page.locator('.react-flow__node')).toHaveCount(ids.length) + await routesSettled(page) + // the redo selects the inserted nodes again (§MS3.5): clear that through + // the store (a click on the empty pane did not clear it here — not traced), + // so the target's faces are the used ones until the drag starts + await page.evaluate(() => { + const g = (window as unknown as { __loop: L }).__loop.graph.getState() + g.onNodesChange(g.nodes.map((n: any) => ({ type: 'select', id: n.id, selected: false }))) + g.setSelection(null, null) + }) + await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur()) + await expect + .poll(() => page.evaluate(() => (window as unknown as { __loop: L }).__loop.graph.getState().nodes.filter((n: any) => n.selected).length)) + .toBe(0) + await expect.poll(async () => ((await mountedFaces(page))[to.id] ?? []).length).toBeLessThan(12) + const rest = (await mountedFaces(page))[to.id] ?? [] + const b0 = await centreOf(page, from.id, 'out') + await page.mouse.move(b0.x, b0.y) + await page.mouse.down() + await page.mouse.move(b0.x + 30, b0.y + 30, { steps: 4 }) + await expect.poll(async () => (await mountedFaces(page))[to.id]?.length ?? 0).toBeGreaterThan(rest.length) + const unused = ((await mountedFaces(page))[to.id] ?? []).find((h) => h.startsWith('in@') && !rest.includes(h))! + expect(unused, 'an unused input face of the target').toBeTruthy() + const t = await centreOf(page, to.id, unused) + await page.mouse.move(t.x, t.y, { steps: 8 }) + await expect(face(page, to.id, unused), 'measured at this drag\'s start, so React Flow picks it').toHaveClass(/\bconnectingto\b/) + await page.mouse.move(e.x, e.y, { steps: 4 }) + await page.mouse.up() + expect(warnings).toEqual([]) + }) + + test('Esc cancels an end grip drag, the faces go back, and the next connection is made', async ({ page }) => { + const warnings: string[] = [] + await open(page, warnings) + const before = await edges(page) + // select e_leave on its own line, so its end grips show + const p = await page.locator('.react-flow__edge[data-id="e_leave"] path.react-flow__edge-path').evaluate((el) => { + const s = el as SVGPathElement + const q = s.getPointAtLength(s.getTotalLength() * 0.3) + const m = s.getScreenCTM()! + return { x: q.x * m.a + q.y * m.c + m.e, y: q.x * m.b + q.y * m.d + m.f } + }) + await page.mouse.click(p.x, p.y) + const grip = page.locator('.end-grip[data-end="target"]') + await expect(grip).toHaveCount(1) + const g = (await grip.boundingBox())! + await page.mouse.move(g.x + g.width / 2, g.y + g.height / 2) + await page.mouse.down() + await page.mouse.move(g.x + g.width / 2 + 20, g.y + g.height / 2 + 20, { steps: 5 }) + await expect.poll(async () => (await mountedFaces(page)).waiting).toContain('in@top') + await page.keyboard.press('Escape') + await expect(page.locator('.react-flow__connection')).toHaveCount(0) + await page.mouse.up() + await settle(page) + expect((await edges(page)).list).toEqual(before.list) + await expect.poll(() => mountedFaces(page)).toEqual(AT_REST) + await draw(page, ['arrivals', 'out'], ['discharged', 'in@bottom']) + await expectMade(page, before.list.length, 'arrivals', 'discharged', 'in@bottom', 'bottom') + expect(warnings).toEqual([]) + }) +}) diff --git a/e2e/whats-new.spec.ts b/e2e/whats-new.spec.ts index 4375186a..96306d22 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.28.3', '0.28.2', '0.28.1', '0.28.0', '0.27.0', '0.26.2', '0.26.0', '0.25.0', '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']) + expect(RELEASE_NOTES.map((n) => n.version)).toEqual(['0.28.4', '0.28.3', '0.28.2', '0.28.1', '0.28.0', '0.27.0', '0.26.2', '0.26.0', '0.25.0', '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 3334ed91..02754647 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "loop-studio", - "version": "0.28.3", + "version": "0.28.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "loop-studio", - "version": "0.28.3", + "version": "0.28.4", "dependencies": { "@fontsource/ibm-plex-mono": "^5.3.0", "@fontsource/ibm-plex-sans": "^5.3.0", diff --git a/package.json b/package.json index bf87f68b..c0ade856 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "loop-studio", "private": true, - "version": "0.28.3", + "version": "0.28.4", "type": "module", "engines": { "node": ">=22.12.0" diff --git a/src/components/nodes/faceBatch.test.ts b/src/components/nodes/faceBatch.test.ts new file mode 100644 index 00000000..21b144ae --- /dev/null +++ b/src/components/nodes/faceBatch.test.ts @@ -0,0 +1,203 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { measureFacesForDrag } from './faceBatch' + +// ./faceBatch (share note 58): when a connection drag begins or ends, every +// node's layout effect asks for its faces to be measured; those requests +// become ONE React Flow `updateNodeInternals` call holding every node on the +// canvas — at once for a drag's start, on the next animation frame for its +// end — a start cancels an end's frame still waiting, and a drag also ends +// when React Flow's connection ends with no node asking. What is pinned is the +// measurement calls and the nodes in them, not React Flow's own store updates +// (it makes others at a start and a release). + +type Update = Map +type Conn = { connection: { inProgress: boolean } } + +/** a canvas with node elements (`undefined` is an element without a node id) + * and a React Flow connection state that can start and end */ +function canvas(ids: (string | undefined)[]) { + const calls: { ids: string[]; force: boolean[]; params: unknown }[] = [] + let els = ids.map((id) => ({ dataset: id === undefined ? {} : { id } })) + let conn: Conn = { connection: { inProgress: false } } + const listeners: ((s: Conn, prev: Conn) => void)[] = [] + const store = { + getState: () => ({ + ...conn, + domNode: { + querySelectorAll: (sel: string) => { + expect(sel).toBe('.react-flow__node') + return els + }, + }, + updateNodeInternals: (updates: Update, params: unknown) => { + calls.push({ ids: [...updates.keys()], force: [...updates.values()].map((u) => u.force), params }) + }, + }), + subscribe: (l: (s: Conn, prev: Conn) => void) => { + listeners.push(l) + return () => listeners.splice(listeners.indexOf(l), 1) + }, + } + /** React Flow's own connection state changes (no node involved) */ + const setInProgress = (inProgress: boolean) => { + const prev = conn + conn = { connection: { inProgress } } + for (const l of [...listeners]) l(conn, prev) + } + return { store: store as never, calls, setInProgress, setNodes: (next: string[]) => (els = next.map((id) => ({ dataset: { id } }))) } +} + +/** animation frames run by hand */ +const frames = new Map() +let nextFrame = 1 +const runFrames = () => { + const due = [...frames.entries()] + frames.clear() + for (const [, cb] of due) cb(0) +} + +beforeEach(() => { + frames.clear() + vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => { + const id = nextFrame++ + frames.set(id, cb) + return id + }) + vi.stubGlobal('cancelAnimationFrame', (id: number) => { + frames.delete(id) + }) +}) +// every test makes its own canvas (its own store), so no state is shared +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('the face measurement of a drag start / end', () => { + it('a start: the requests of every node are one measurement, at once, of every node', () => { + const c = canvas(['a', 'b', 'c', 'd']) + for (let i = 0; i < 4; i++) measureFacesForDrag(c.store, true) + expect(c.calls).toHaveLength(1) + expect(c.calls[0]!.ids).toEqual(['a', 'b', 'c', 'd']) + expect(c.calls[0]!.force).toEqual([true, true, true, true]) + expect(c.calls[0]!.params).toEqual({ triggerFitView: false }) + expect(frames.size, 'nothing deferred at a start').toBe(0) + }) + + it('the requests of one start may come in separate tasks: still one measurement', async () => { + const c = canvas(['a', 'b', 'c']) + measureFacesForDrag(c.store, true) + await Promise.resolve() + measureFacesForDrag(c.store, true) + await new Promise((r) => setTimeout(r, 0)) + measureFacesForDrag(c.store, true) + expect(c.calls).toHaveLength(1) + }) + + it('an element without a node id is not measured', () => { + const c = canvas(['a', undefined, 'b']) + measureFacesForDrag(c.store, true) + expect(c.calls[0]!.ids).toEqual(['a', 'b']) + }) + + it('an end: nothing at once; one measurement of every node on the next animation frame', () => { + const c = canvas(['a', 'b', 'c']) + measureFacesForDrag(c.store, true) + for (let i = 0; i < 3; i++) measureFacesForDrag(c.store, false) + expect(c.calls, 'the end measures nothing at once').toHaveLength(1) + expect(frames.size).toBe(1) + runFrames() + expect(c.calls).toHaveLength(2) + expect(c.calls[1]!.ids).toEqual(['a', 'b', 'c']) + runFrames() + expect(c.calls, 'one frame, one measurement').toHaveLength(2) + }) + + it('an end seen by React Flow and by the nodes is one measurement', () => { + const c = canvas(['a', 'b']) + c.setInProgress(true) + measureFacesForDrag(c.store, true) + c.setInProgress(false) + measureFacesForDrag(c.store, false) + measureFacesForDrag(c.store, false) + expect(frames.size).toBe(1) + runFrames() + expect(c.calls).toHaveLength(2) + }) + + it("a new drag before the end's frame: that frame never measures, the start measures at once", () => { + const c = canvas(['a', 'b']) + measureFacesForDrag(c.store, true) + measureFacesForDrag(c.store, false) + expect(frames.size).toBe(1) + measureFacesForDrag(c.store, true) + measureFacesForDrag(c.store, true) + expect(frames.size, "the end's waiting frame is cancelled").toBe(0) + expect(c.calls).toHaveLength(2) + runFrames() + expect(c.calls, 'no late measurement lands on the new drag').toHaveLength(2) + measureFacesForDrag(c.store, false) + runFrames() + expect(c.calls).toHaveLength(3) + }) + + it("the nodes measured are the canvas's when the measurement runs", () => { + const c = canvas(['a', 'b']) + measureFacesForDrag(c.store, true) + measureFacesForDrag(c.store, false) + c.setNodes(['a', 'b', 'n']) + runFrames() + expect(c.calls[1]!.ids).toEqual(['a', 'b', 'n']) + }) + + it('a drag whose end no node reported (every node gone mid-drag) is over when React Flow ends it: the next start is measured', () => { + const c = canvas(['a', 'b']) + c.setInProgress(true) + measureFacesForDrag(c.store, true) + // the nodes unmount during the drag (its insert undone): no node's layout + // effect sees the drag end; React Flow's connection still ends + c.setNodes([]) + c.setInProgress(false) + runFrames() + expect(c.calls, 'the end frame finds no node to measure').toHaveLength(1) + c.setNodes(['n1', 'n2']) + c.setInProgress(true) + measureFacesForDrag(c.store, true) + expect(c.calls).toHaveLength(2) + expect(c.calls[1]!.ids).toEqual(['n1', 'n2']) + }) + + it("every node's own wrapper of one canvas's store is that one canvas: merged", () => { + // useStoreApi() gives each component a new { getState, setState, subscribe } + const c = canvas(['a', 'b', 'c']) + const s = c.store as unknown as { getState: unknown; subscribe: unknown } + const wrappers = [0, 1, 2].map(() => ({ getState: s.getState, subscribe: s.subscribe }) as never) + for (const w of wrappers) measureFacesForDrag(w, true) + expect(c.calls).toHaveLength(1) + for (const w of wrappers) measureFacesForDrag(w, false) + expect(frames.size).toBe(1) + runFrames() + expect(c.calls).toHaveLength(2) + }) + + it('two canvases are measured apart', () => { + const one = canvas(['a']) + const two = canvas(['x', 'y']) + measureFacesForDrag(one.store, true) + measureFacesForDrag(two.store, true) + expect(one.calls.map((c) => c.ids)).toEqual([['a']]) + expect(two.calls.map((c) => c.ids)).toEqual([['x', 'y']]) + measureFacesForDrag(one.store, false) + expect(frames.size, 'one canvas ending does not cancel nor merge the other').toBe(1) + runFrames() + expect(one.calls).toHaveLength(2) + expect(two.calls).toHaveLength(1) + }) + + it('an end with no drag begun measures nothing', () => { + const c = canvas(['a']) + measureFacesForDrag(c.store, false) + c.setInProgress(false) + runFrames() + expect(c.calls).toHaveLength(0) + }) +}) diff --git a/src/components/nodes/faceBatch.ts b/src/components/nodes/faceBatch.ts new file mode 100644 index 00000000..7f6ebd5d --- /dev/null +++ b/src/components/nodes/faceBatch.ts @@ -0,0 +1,96 @@ +import type { useStoreApi } from '@xyflow/react' + +/** + * The face handles' measurement when a connection drag begins or ends. + * + * Every node mounts its face handles while a connection is dragged (so the + * drop can choose a face, `nodes.tsx` `facesOn`) and unmounts the unused ones + * when it ends. A mount or unmount without a size change is invisible to React + * Flow's ResizeObserver, so the handles' bounds must be measured again; done + * one node at a time, that was one React Flow store update per node, each run + * through every store subscriber, and it made the start and the end of a drag + * on a large diagram slow (share note 58: the 63-node gacha banner Template). + * + * So the measurement requests of the nodes' layout effects are merged into ONE + * `updateNodeInternals` call holding every node on the canvas: + * - a drag's start: the first request measures every node at once (its new + * handles are in the DOM, nothing is painted yet, and React Flow's edges — + * which reach its store in a passive effect — have not caught up: the moment + * each node measured itself in before); every later request of the same drag + * is answered by it; + * - a drag's end: every node is measured on the next animation frame. The end + * only removes handles (every face was mounted during the drag), and a + * removal is measured late, as a node's own removal is (a store update in + * the layout effect would run React Flow's edge selectors of the previous + * render, which still look for the removed handle — React Flow error 008, + * share note 53). The end is taken from React Flow's own connection state + * (`connection.inProgress` going false) as well as from the nodes, so a drag + * is over even when no node saw it end (every node gone during the drag: its + * insert undone, the node structure switched) and the next drag's start is + * measured; + * - a start cancels an end's frame that has not run yet. A frame is not + * cancelled when the canvas goes away: it runs, reads the store and the DOM + * then, and finds no canvas (React Flow resets its store on unmount, so + * `domNode` is empty) — it measures nothing. + * Every other change of a node's mounted faces (a selection, a keyboard focus, + * a document change) is measured by that node alone, as before. + * + * The state is each canvas's. `useStoreApi()` hands every component its own + * wrapper object (`{ getState, setState, subscribe }`, memoized per hook + * call), so the wrapper cannot be the key — keyed by it, every node had its + * own state and nothing was merged (share note 58). The key is the store's own + * `getState`, the same function in every wrapper of one canvas. + */ +type RfStore = Pick, 'getState' | 'subscribe'> +type Batch = { on: boolean; frame: number } + +const batches = new WeakMap() + +/** measure every node's handles in ONE React Flow update */ +function measureAll(rfStore: RfStore): void { + const { domNode, updateNodeInternals } = rfStore.getState() + const updates = new Map() + domNode?.querySelectorAll('.react-flow__node').forEach((el) => { + const id = el.dataset.id + if (id) updates.set(id, { id, nodeElement: el, force: true }) + }) + if (updates.size) updateNodeInternals(updates, { triggerFitView: false }) +} + +/** the drag is over: one measurement of every node on the next frame */ +function endDrag(rfStore: RfStore, b: Batch): void { + if (!b.on) return + b.on = false + cancelAnimationFrame(b.frame) + b.frame = requestAnimationFrame(() => { + b.frame = 0 + measureAll(rfStore) + }) +} + +function batchOf(rfStore: RfStore): Batch { + const known = batches.get(rfStore.getState) + if (known) return known + const b: Batch = { on: false, frame: 0 } + batches.set(rfStore.getState, b) + // the end of a drag as React Flow has it, whether or not a node saw it + rfStore.subscribe((s, prev) => { + if (prev.connection.inProgress && !s.connection.inProgress) endDrag(rfStore, b) + }) + return b +} + +/** a node's layout effect saw the drag state become `connecting` */ +export function measureFacesForDrag(rfStore: RfStore, connecting: boolean): void { + const b = batchOf(rfStore) + if (!connecting) { + endDrag(rfStore, b) + return + } + // a request of a drag already measured: answered by that measurement + if (b.on) return + b.on = true + cancelAnimationFrame(b.frame) + b.frame = 0 + measureAll(rfStore) +} diff --git a/src/components/nodes/nodes.tsx b/src/components/nodes/nodes.tsx index 43c6b485..79997b1e 100644 --- a/src/components/nodes/nodes.tsx +++ b/src/components/nodes/nodes.tsx @@ -36,6 +36,7 @@ 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 { measureFacesForDrag } from './faceBatch' import type { ConverterData, DrainData, @@ -752,14 +753,24 @@ function NodeFrame({ // store update in this layout effect would run React Flow's edge selectors of // the previous render (useSyncExternalStore swaps them in a passive effect), // which still look for the removed handle. + // When a connection drag begins or ends, every node's faces change in the + // same commit: the first node to get here measures them all at once, in the + // same order (./faceBatch, share note 58). const rfStore = useStoreApi() const updateNodeInternals = useUpdateNodeInternals() const mountedFacesKey = outside ? (facesOn ? '*' : usedHandlesKey) : '' const facesKeyRef = useRef(mountedFacesKey) + const connectingRef = useRef(connecting) useLayoutEffect(() => { const prev = facesKeyRef.current + const wasConnecting = connectingRef.current + connectingRef.current = connecting if (prev === mountedFacesKey) return facesKeyRef.current = mountedFacesKey + if (wasConnecting !== connecting) { + measureFacesForDrag(rfStore, connecting) + return + } if (addsHandle(prev, mountedFacesKey)) { const { domNode, updateNodeInternals: measureNow } = rfStore.getState() const nodeElement = domNode?.querySelector(`.react-flow__node[data-id="${CSS.escape(nodeId)}"]`) @@ -767,7 +778,7 @@ function NodeFrame({ } else { updateNodeInternals(nodeId) } - }, [mountedFacesKey, nodeId, rfStore, updateNodeInternals]) + }, [mountedFacesKey, connecting, nodeId, rfStore, updateNodeInternals]) // §VL3 stacking — every state is its own layer, so a Register that is // selected AND keyboard-focused AND invalid shows all three cues at once: // the outer --warning invalid ring, the solid selection ring, the inset diff --git a/src/i18n/itCopy.test.ts b/src/i18n/itCopy.test.ts index be0c6c3c..0a91d75c 100644 --- a/src/i18n/itCopy.test.ts +++ b/src/i18n/itCopy.test.ts @@ -208,15 +208,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(1145) // 0.28.3: +4 (the four release-note lines); 0.28.2: +3 (the three release-note lines); 0.28.1: +4 (the four release-note lines); 0.28.0: +3 (the three release-note lines); §ER19 Reverse: +13 (inspector.ends.reverse.*); §ER18 grips: +2 (inspector.ends.noGrip.source / target); 0.27.0: +3 (the three release-note lines); the connection Ends group (docs/edge-routing.md §ER18): +24 (inspector.ends.*, canvas.endGrip.source / target); 0.26.2: +4 (the four release-note lines); 0.26.0: +5 (the five release-note lines); the node structure: +1 (inspector.field.currentAmount, the Pool's read-only current amount); issue #344: +2 (the 0.25.0 release note is five lines, was three); issue #344 Step 3: +9 (the connection shape and bend-point strings and the keyboard bend notice); issue #344 Step 1: +3 (three 0.25.0 release-note lines); issue #344 Step 1: +1 (canvas.tidy, the Tidy to grid rail button); 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(KEYS).toHaveLength(1148) // 0.28.4: +3 (the three release-note lines); 0.28.3: +4 (the four release-note lines); 0.28.2: +3 (the three release-note lines); 0.28.1: +4 (the four release-note lines); 0.28.0: +3 (the three release-note lines); §ER19 Reverse: +13 (inspector.ends.reverse.*); §ER18 grips: +2 (inspector.ends.noGrip.source / target); 0.27.0: +3 (the three release-note lines); the connection Ends group (docs/edge-routing.md §ER18): +24 (inspector.ends.*, canvas.endGrip.source / target); 0.26.2: +4 (the four release-note lines); 0.26.0: +5 (the five release-note lines); the node structure: +1 (inspector.field.currentAmount, the Pool's read-only current amount); issue #344: +2 (the 0.25.0 release note is five lines, was three); issue #344 Step 3: +9 (the connection shape and bend-point strings and the keyboard bend notice); issue #344 Step 1: +3 (three 0.25.0 release-note lines); issue #344 Step 1: +1 (canvas.tidy, the Tidy to grid rail button); 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: 1145, template: 196, frame: 7, module: 19 }) - expect(RUNTIME).toHaveLength(1367) + expect(per).toEqual({ catalog: 1148, template: 196, frame: 7, module: 19 }) + expect(RUNTIME).toHaveLength(1370) }) 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 fb52947b..0ac56411 100644 --- a/src/i18n/locales/ar/ui.ts +++ b/src/i18n/locales/ar/ui.ts @@ -656,6 +656,9 @@ const ui = { 'whatsNew.v0283.hidden': 'بعد التراجع أو الإعادة لم يعد الرابط أو العقدة يبقى مُبرَزًا بينما المفتّش فارغ. وإلى أن تنقر عليه لتحديده، لا يحذفه المفتاح Delete، ولا تحرّك مفاتيح الأسهم العقدة.', 'whatsNew.v0283.history': 'لم يعد التراجع والإعادة يعيدان تحديدًا من خطوة سابقة.', 'whatsNew.v0283.module': 'الاستثناء الوحيد هو إدراج وحدة: يحدد التراجع من جديد ما كان محددًا قبل الإدراج، وتحدد الإعادة العُقد المُدرجة.', + 'whatsNew.v0284.start': 'أصبح بدء رسم الرابط يستجيب أسرع، ويكون التحسّن أكبر في المخططات الكبيرة.', + 'whatsNew.v0284.release': 'وأصبح إفلات رابط مسحوب على لوحة فارغة يستجيب أسرع أيضًا.', + 'whatsNew.v0284.same': 'أثناء رسم رابط تبقى المنافذ على كل جوانب كل العقد متاحة.', } as const export default ui diff --git a/src/i18n/locales/de/ui.ts b/src/i18n/locales/de/ui.ts index 6023e461..fe95aba8 100644 --- a/src/i18n/locales/de/ui.ts +++ b/src/i18n/locales/de/ui.ts @@ -691,6 +691,9 @@ const ui = { 'whatsNew.v0283.hidden': 'Nach Rückgängig oder Wiederherstellen bleibt keine Verbindung und kein Knoten mehr hervorgehoben, während die Eigenschaften leer sind. Bis Sie das Element anklicken und damit auswählen, entfernt Entf es nicht, und die Pfeiltasten bewegen keinen Knoten.', 'whatsNew.v0283.history': 'Rückgängig und Wiederherstellen holen keine Auswahl aus einem früheren Schritt mehr zurück.', 'whatsNew.v0283.module': 'Einzige Ausnahme ist das Einfügen eines Moduls: Rückgängig wählt wieder aus, was vor dem Einfügen ausgewählt war, und Wiederherstellen wählt die eingefügten Knoten aus.', + 'whatsNew.v0284.start': 'Wenn Sie beginnen, eine Verbindung zu zeichnen, reagiert die Ansicht schneller, in großen Diagrammen besonders deutlich.', + 'whatsNew.v0284.release': 'Auch das Loslassen einer gezogenen Verbindung auf der leeren Arbeitsfläche reagiert schneller.', + 'whatsNew.v0284.same': 'Während Sie eine Verbindung zeichnen, bleiben die Verbindungspunkte auf allen Seiten aller Knoten verfügbar.', } satisfies Record export default ui diff --git a/src/i18n/locales/en/ui.ts b/src/i18n/locales/en/ui.ts index 3513118f..3c29d806 100644 --- a/src/i18n/locales/en/ui.ts +++ b/src/i18n/locales/en/ui.ts @@ -601,6 +601,9 @@ const ui = { 'whatsNew.v0283.hidden': 'After Undo or Redo, a connection or node no longer stays highlighted while the Inspector is empty. Delete won’t remove it, and the arrow keys won’t move a node until you select it.', 'whatsNew.v0283.history': 'Undo and Redo no longer bring back a selection from an earlier step.', 'whatsNew.v0283.module': 'Inserting a module is the one exception: Undo selects again what was selected before the insert, and Redo selects the inserted nodes.', + 'whatsNew.v0284.start': 'Starting to draw a connection responds faster, with a larger improvement in large diagrams.', + 'whatsNew.v0284.release': 'Releasing a connection drag on the empty canvas also responds faster.', + 'whatsNew.v0284.same': 'While drawing a connection, handles on every side of every node remain available.', } 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 a7a5c7fc..09481191 100644 --- a/src/i18n/locales/es-419/ui.ts +++ b/src/i18n/locales/es-419/ui.ts @@ -615,6 +615,9 @@ const ui = { 'whatsNew.v0283.hidden': 'Después de Deshacer o Rehacer, una conexión o un nodo ya no queda resaltado mientras Propiedades no muestra nada. Hasta que haga clic para seleccionarlo, Suprimir no lo elimina y las teclas de flecha no mueven ningún nodo.', 'whatsNew.v0283.history': 'Deshacer y Rehacer ya no recuperan una selección de un paso anterior.', 'whatsNew.v0283.module': 'Insertar un módulo es la única excepción: Deshacer vuelve a seleccionar lo que estaba seleccionado antes de insertarlo, y Rehacer selecciona los nodos insertados.', + 'whatsNew.v0284.start': 'Empezar a dibujar una conexión responde más rápido, con una mejora mayor en diagramas grandes.', + 'whatsNew.v0284.release': 'Soltar en el lienzo vacío una conexión que se está arrastrando también responde más rápido.', + 'whatsNew.v0284.same': 'Mientras dibuja una conexión, los puertos de todos los lados de todos los nodos siguen disponibles.', } 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 fe55d4d6..9decdf1b 100644 --- a/src/i18n/locales/es-ES/ui.ts +++ b/src/i18n/locales/es-ES/ui.ts @@ -629,6 +629,9 @@ const ui = { 'whatsNew.v0283.hidden': 'Después de Deshacer o Rehacer, una conexión o un nodo ya no queda resaltado mientras Propiedades no muestra nada. Hasta que haga clic para seleccionarlo, Suprimir no lo elimina y las teclas de flecha no mueven ningún nodo.', 'whatsNew.v0283.history': 'Deshacer y Rehacer ya no recuperan una selección de un paso anterior.', 'whatsNew.v0283.module': 'Insertar un módulo es la única excepción: Deshacer vuelve a seleccionar lo que estaba seleccionado antes de insertarlo, y Rehacer selecciona los nodos insertados.', + 'whatsNew.v0284.start': 'Empezar a dibujar una conexión responde más rápido, con una mejora mayor en diagramas grandes.', + 'whatsNew.v0284.release': 'Soltar en el lienzo vacío una conexión que se está arrastrando también responde más rápido.', + 'whatsNew.v0284.same': 'Mientras dibuja una conexión, los puertos de todos los lados de todos los nodos siguen disponibles.', } 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 d86cc2a8..63f38683 100644 --- a/src/i18n/locales/fr/ui.ts +++ b/src/i18n/locales/fr/ui.ts @@ -685,6 +685,9 @@ const ui = { 'whatsNew.v0283.hidden': 'Après Annuler ou Rétablir, une connexion ou un nœud ne reste plus en surbrillance alors que les Propriétés n’affichent rien. Tant que vous ne cliquez pas dessus pour le sélectionner, Suppr ne le supprime pas et les flèches ne déplacent aucun nœud.', 'whatsNew.v0283.history': 'Annuler et Rétablir ne ramènent plus une sélection d’une étape antérieure.', 'whatsNew.v0283.module': 'L’insertion d’un module est la seule exception. Annuler sélectionne de nouveau ce qui l’était avant l’insertion, et Rétablir sélectionne les nœuds insérés.', + 'whatsNew.v0284.start': 'Commencer à tracer une connexion réagit plus vite, et l’amélioration est plus nette dans les grands diagrammes.', + 'whatsNew.v0284.release': 'Relâcher une connexion en cours de tracé sur le canevas vide réagit aussi plus vite.', + 'whatsNew.v0284.same': 'Pendant le tracé d’une connexion, les points de connexion de tous les côtés de tous les nœuds restent disponibles.', } satisfies Record export default ui diff --git a/src/i18n/locales/it/ui.ts b/src/i18n/locales/it/ui.ts index 062617d4..21f71ecd 100644 --- a/src/i18n/locales/it/ui.ts +++ b/src/i18n/locales/it/ui.ts @@ -631,6 +631,9 @@ const ui = { 'whatsNew.v0283.hidden': 'Dopo Annulla o Ripeti, un collegamento o un nodo non resta più evidenziato mentre l’Ispettore non mostra nulla. Finché non ci fai clic per selezionarlo, Canc non lo rimuove e i tasti freccia non spostano nessun nodo.', 'whatsNew.v0283.history': 'Annulla e Ripeti non riportano più una selezione di un passaggio precedente.', 'whatsNew.v0283.module': 'L’unica eccezione è l’inserimento di un modulo: Annulla seleziona di nuovo ciò che era selezionato prima dell’inserimento, e Ripeti seleziona i nodi inseriti.', + 'whatsNew.v0284.start': 'Iniziare a disegnare un collegamento risponde più rapidamente, con un miglioramento maggiore nei diagrammi grandi.', + 'whatsNew.v0284.release': 'Anche rilasciare sull’area di disegno vuota un collegamento che stai trascinando risponde più rapidamente.', + 'whatsNew.v0284.same': 'Mentre disegni un collegamento, le porte su tutti i lati di tutti i nodi restano disponibili.', } satisfies Record export default ui diff --git a/src/i18n/locales/ja/ui.ts b/src/i18n/locales/ja/ui.ts index d93a47e5..33c33ca4 100644 --- a/src/i18n/locales/ja/ui.ts +++ b/src/i18n/locales/ja/ui.ts @@ -636,6 +636,9 @@ const ui = { 'whatsNew.v0283.hidden': '元に戻す・やり直すの後、インスペクターが空なのに接続やノードが強調されたまま残ることはなくなりました。クリックして選択するまで、Delete で削除されることも、ノードが矢印キーで動くこともありません。', 'whatsNew.v0283.history': '元に戻す・やり直すで、以前の手順の選択が戻ってくることはなくなりました。', 'whatsNew.v0283.module': 'モジュールの挿入だけは例外です。元に戻すと挿入前に選択していたものが再び選択され、やり直すと挿入したノードが選択されます。', + 'whatsNew.v0284.start': '接続を描き始めるときの反応が速くなりました。大きなダイアグラムほど改善の幅が大きくなります。', + 'whatsNew.v0284.release': '接続をドラッグして空のキャンバスで離したときも、反応が速くなりました。', + 'whatsNew.v0284.same': '接続を描いている間は、すべてのノードのすべての辺にあるハンドルを引き続き使えます。', } satisfies Record export default ui diff --git a/src/i18n/locales/ko/ui.ts b/src/i18n/locales/ko/ui.ts index 0a5607d6..64bb9b02 100644 --- a/src/i18n/locales/ko/ui.ts +++ b/src/i18n/locales/ko/ui.ts @@ -604,6 +604,9 @@ const ui = { 'whatsNew.v0283.hidden': '실행 취소나 다시 실행 뒤, 인스펙터는 비어 있는데 연결이나 노드가 강조된 채 남는 일이 없습니다. 클릭해서 선택하기 전에는 Delete로 지워지지 않고, 노드가 화살표 키로 움직이지도 않습니다.', 'whatsNew.v0283.history': '실행 취소와 다시 실행이 이전 단계의 선택을 되살리지 않습니다.', 'whatsNew.v0283.module': '모듈 삽입만 예외입니다. 실행 취소하면 삽입 전에 선택했던 것이 다시 선택되고, 다시 실행하면 삽입한 노드가 선택됩니다.', + 'whatsNew.v0284.start': '연결을 그리기 시작할 때 반응이 빨라졌으며, 큰 다이어그램에서 개선 폭이 더 큽니다.', + 'whatsNew.v0284.release': '연결을 끌다가 빈 캔버스에 놓을 때도 반응이 빨라졌습니다.', + 'whatsNew.v0284.same': '연결을 그리는 동안 모든 노드의 모든 면에 있는 핸들은 계속 표시됩니다.', } satisfies Record export default ui diff --git a/src/i18n/locales/nl/ui.ts b/src/i18n/locales/nl/ui.ts index 8b51105b..9c0bc0f6 100644 --- a/src/i18n/locales/nl/ui.ts +++ b/src/i18n/locales/nl/ui.ts @@ -612,6 +612,9 @@ const ui = { 'whatsNew.v0283.hidden': 'Na Ongedaan maken of Opnieuw uitvoeren blijft een verbinding of knooppunt niet meer gemarkeerd terwijl de Inspector niets toont. Tot je erop klikt om het te selecteren, verwijdert Delete het niet en verplaatsen de pijltoetsen geen knooppunt.', 'whatsNew.v0283.history': 'Ongedaan maken en Opnieuw uitvoeren halen geen selectie uit een eerdere stap meer terug.', 'whatsNew.v0283.module': 'Een module invoegen is de enige uitzondering: Ongedaan maken herstelt de selectie van vóór het invoegen, en Opnieuw uitvoeren selecteert de ingevoegde knooppunten.', + 'whatsNew.v0284.start': 'Beginnen met het tekenen van een verbinding reageert sneller, met een grotere verbetering in grote diagrammen.', + 'whatsNew.v0284.release': 'Ook een gesleepte verbinding loslaten op het lege tekengebied reageert sneller.', + 'whatsNew.v0284.same': 'Tijdens het tekenen van een verbinding blijven de poorten op alle zijden van alle knooppunten beschikbaar.', } as const export default ui diff --git a/src/i18n/locales/pt-BR/ui.ts b/src/i18n/locales/pt-BR/ui.ts index a60ead04..ab935ed6 100644 --- a/src/i18n/locales/pt-BR/ui.ts +++ b/src/i18n/locales/pt-BR/ui.ts @@ -617,6 +617,9 @@ const ui = { 'whatsNew.v0283.hidden': 'Depois de Desfazer ou Refazer, uma ligação ou um nó deixa de ficar destacado enquanto o Inspetor não mostra nada. Até que clique nele para selecioná-lo, Delete não o remove e as teclas de seta não movem nenhum nó.', 'whatsNew.v0283.history': 'Desfazer e Refazer deixam de trazer de volta uma seleção de um passo anterior.', 'whatsNew.v0283.module': 'Inserir um módulo é a única exceção: Desfazer volta a selecionar o que estava selecionado antes da inserção, e Refazer seleciona os nós inseridos.', + 'whatsNew.v0284.start': 'Começar a desenhar uma ligação responde mais rapidamente, com uma melhoria maior em diagramas grandes.', + 'whatsNew.v0284.release': 'Soltar uma ligação arrastada na tela vazia também responde mais rapidamente.', + 'whatsNew.v0284.same': 'Enquanto desenha uma ligação, as portas de todos os lados de todos os nós continuam disponíveis.', } 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 09c90c6a..4ed9f224 100644 --- a/src/i18n/locales/pt-PT/ui.ts +++ b/src/i18n/locales/pt-PT/ui.ts @@ -631,6 +631,9 @@ const ui = { 'whatsNew.v0283.hidden': 'Depois de Desfazer ou Refazer, uma ligação ou um nó deixa de ficar destacado enquanto o Inspetor não mostra nada. Até que clique nele para selecioná-lo, Delete não o remove e as teclas de seta não movem nenhum nó.', 'whatsNew.v0283.history': 'Desfazer e Refazer deixam de trazer de volta uma seleção de um passo anterior.', 'whatsNew.v0283.module': 'Inserir um módulo é a única exceção: Desfazer volta a selecionar o que estava selecionado antes da inserção, e Refazer seleciona os nós inseridos.', + 'whatsNew.v0284.start': 'Começar a desenhar uma ligação responde mais rapidamente, com uma melhoria maior em diagramas grandes.', + 'whatsNew.v0284.release': 'Soltar uma ligação arrastada na tela vazia também responde mais rapidamente.', + 'whatsNew.v0284.same': 'Enquanto desenha uma ligação, as portas de todos os lados de todos os nós continuam disponíveis.', } 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 25e4dfb2..21748334 100644 --- a/src/i18n/locales/ru/ui.ts +++ b/src/i18n/locales/ru/ui.ts @@ -684,6 +684,9 @@ const ui = { 'whatsNew.v0283.hidden': 'После отмены или повтора связь или узел больше не остаётся подсвеченным, пока инспектор пуст. Пока вы не выделите элемент щелчком, Delete его не удалит, а стрелки не сдвинут узел.', 'whatsNew.v0283.history': 'Отмена и повтор больше не возвращают выделение из более раннего шага.', 'whatsNew.v0283.module': 'Единственное исключение составляет вставка модуля: отмена снова выделяет то, что было выделено до вставки, а повтор выделяет вставленные узлы.', + 'whatsNew.v0284.start': 'Начало рисования связи откликается быстрее, и в больших диаграммах улучшение заметнее.', + 'whatsNew.v0284.release': 'Отпускание перетаскиваемой связи на пустом холсте тоже откликается быстрее.', + 'whatsNew.v0284.same': 'Пока вы рисуете связь, порты на всех сторонах всех узлов остаются доступными.', } 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 4b7a0894..2fa5b9c3 100644 --- a/src/i18n/locales/th/ui.ts +++ b/src/i18n/locales/th/ui.ts @@ -676,6 +676,9 @@ const ui = { 'whatsNew.v0283.hidden': 'หลังเลิกทำหรือทำซ้ำ เส้นเชื่อมหรือโหนดจะไม่ค้างแสดงว่าถูกเลือกขณะที่ตัวตรวจสอบว่างอีกต่อไป จนกว่าจะคลิกเพื่อเลือก Delete จะไม่ลบสิ่งนั้น และปุ่มลูกศรจะไม่ย้ายโหนด', 'whatsNew.v0283.history': 'การเลิกทำและการทำซ้ำจะไม่นำการเลือกจากขั้นตอนก่อนหน้ากลับมาอีก', 'whatsNew.v0283.module': 'การแทรกมอดูลเป็นข้อยกเว้นเพียงกรณีเดียว การเลิกทำจะเลือกสิ่งที่เคยเลือกไว้ก่อนแทรกอีกครั้ง และการทำซ้ำจะเลือกโหนดที่แทรก', + 'whatsNew.v0284.start': 'การเริ่มวาดเส้นเชื่อมตอบสนองเร็วขึ้น และยิ่งไดอะแกรมใหญ่ยิ่งเห็นผลชัด', + 'whatsNew.v0284.release': 'การปล่อยเส้นเชื่อมที่กำลังลากบนพื้นที่ว่างก็ตอบสนองเร็วขึ้นเช่นกัน', + 'whatsNew.v0284.same': 'ระหว่างวาดเส้นเชื่อม จุดเชื่อมทุกด้านของทุกโหนดยังคงใช้ได้', } 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 89d2eb04..4da624fa 100644 --- a/src/i18n/locales/tr/ui.ts +++ b/src/i18n/locales/tr/ui.ts @@ -670,6 +670,9 @@ const ui = { 'whatsNew.v0283.hidden': 'Geri al ya da Yinele sonrasında, İnceleyici boşken bir bağlantı ya da düğüm artık vurgulu kalmaz. Tıklayıp seçene kadar Delete onu silmez, ok tuşları da düğümü taşımaz.', 'whatsNew.v0283.history': 'Geri al ve Yinele artık önceki bir adımdaki seçimi geri getirmez.', 'whatsNew.v0283.module': 'Tek istisna modül eklemedir: Geri al, eklemeden önce seçili olanı yeniden seçer; Yinele ise eklenen düğümleri seçer.', + 'whatsNew.v0284.start': 'Bağlantı çizmeye başlamak daha hızlı tepki veriyor; büyük diyagramlarda iyileşme daha belirgin.', + 'whatsNew.v0284.release': 'Sürüklenen bir bağlantıyı boş tuvale bırakmak da daha hızlı tepki veriyor.', + 'whatsNew.v0284.same': 'Bağlantı çizerken tüm düğümlerin tüm kenarlarındaki bağlantı noktaları kullanılabilir kalır.', } 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 d7fdf3dd..9f4bb55b 100644 --- a/src/i18n/locales/vi/ui.ts +++ b/src/i18n/locales/vi/ui.ts @@ -617,6 +617,9 @@ const ui = { 'whatsNew.v0283.hidden': 'Sau khi Hoàn tác hoặc Làm lại, liên kết hay nút không còn được làm nổi khi Bảng thuộc tính trống. Cho đến khi bạn nhấp để chọn, phím Delete không xóa nó và phím mũi tên không di chuyển nút.', 'whatsNew.v0283.history': 'Hoàn tác và Làm lại không còn khôi phục vùng chọn từ một bước trước đó.', 'whatsNew.v0283.module': 'Chèn mô-đun là ngoại lệ duy nhất: Hoàn tác chọn lại những gì đã được chọn trước khi chèn, còn Làm lại chọn các nút vừa chèn.', + 'whatsNew.v0284.start': 'Bắt đầu vẽ liên kết phản hồi nhanh hơn, và sơ đồ càng lớn thì cải thiện càng rõ.', + 'whatsNew.v0284.release': 'Thả một liên kết đang kéo trên khung vẽ trống cũng phản hồi nhanh hơn.', + 'whatsNew.v0284.same': 'Trong khi vẽ liên kết, cổng kết nối ở mọi cạnh của mọi nút vẫn dùng được.', } satisfies Record export default ui diff --git a/src/i18n/locales/zh-Hans/ui.ts b/src/i18n/locales/zh-Hans/ui.ts index e24af652..452143f0 100644 --- a/src/i18n/locales/zh-Hans/ui.ts +++ b/src/i18n/locales/zh-Hans/ui.ts @@ -609,6 +609,9 @@ const ui = { 'whatsNew.v0283.hidden': '撤销或重做之后,不会再出现属性面板为空、连线或节点却仍然高亮的情况。在点击选中之前,按 Delete 键不会删除它,方向键也不会移动节点。', 'whatsNew.v0283.history': '撤销和重做不再恢复之前某一步的选中状态。', 'whatsNew.v0283.module': '插入模块是唯一的例外:撤销会重新选中插入前选中的内容,重做会选中插入的节点。', + 'whatsNew.v0284.start': '开始绘制连线时的响应更快了,图表越大,改善越明显。', + 'whatsNew.v0284.release': '拖动连线后在空白画布上松开时,响应也更快了。', + 'whatsNew.v0284.same': '绘制连线时,每个节点每条边上的端口仍然可用。', } satisfies Record export default ui diff --git a/src/i18n/locales/zh-Hant/ui.ts b/src/i18n/locales/zh-Hant/ui.ts index e6216794..53a6741e 100644 --- a/src/i18n/locales/zh-Hant/ui.ts +++ b/src/i18n/locales/zh-Hant/ui.ts @@ -634,6 +634,9 @@ const ui = { 'whatsNew.v0283.hidden': '復原或重做之後,不會再出現屬性面板是空的、連線或節點卻仍醒目標示的情況。在點一下選取之前,按 Delete 鍵不會刪除它,方向鍵也不會移動節點。', 'whatsNew.v0283.history': '復原和重做不再恢復先前某一步的選取狀態。', 'whatsNew.v0283.module': '插入模組是唯一的例外:復原會重新選取插入前選取的內容,重做會選取插入的節點。', + 'whatsNew.v0284.start': '開始繪製連線時的反應更快了,圖表越大,改善越明顯。', + 'whatsNew.v0284.release': '拖曳連線後在空白畫布上放開時,反應也更快了。', + 'whatsNew.v0284.same': '繪製連線時,每個節點每一邊上的連接點仍然可用。', } satisfies Record export default ui diff --git a/src/i18n/nlCopy.test.ts b/src/i18n/nlCopy.test.ts index e0cc8522..5a7acb40 100644 --- a/src/i18n/nlCopy.test.ts +++ b/src/i18n/nlCopy.test.ts @@ -264,15 +264,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(1145) // 0.28.3: +4 (the four release-note lines); 0.28.2: +3 (the three release-note lines); 0.28.1: +4 (the four release-note lines); 0.28.0: +3 (the three release-note lines); §ER19 Reverse: +13 (inspector.ends.reverse.*); §ER18 grips: +2 (inspector.ends.noGrip.source / target); 0.27.0: +3 (the three release-note lines); the connection Ends group (docs/edge-routing.md §ER18): +24 (inspector.ends.*, canvas.endGrip.source / target); 0.26.2: +4 (the four release-note lines); 0.26.0: +5 (the five release-note lines); the node structure: +1 (inspector.field.currentAmount, the Pool's read-only current amount); issue #344: +2 (the 0.25.0 release note is five lines, was three); issue #344 Step 3: +9 (the connection shape and bend-point strings and the keyboard bend notice); issue #344 Step 1: +3 (three 0.25.0 release-note lines); issue #344 Step 1: +1 (canvas.tidy, the Tidy to grid rail button); 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(KEYS).toHaveLength(1148) // 0.28.4: +3 (the three release-note lines); 0.28.3: +4 (the four release-note lines); 0.28.2: +3 (the three release-note lines); 0.28.1: +4 (the four release-note lines); 0.28.0: +3 (the three release-note lines); §ER19 Reverse: +13 (inspector.ends.reverse.*); §ER18 grips: +2 (inspector.ends.noGrip.source / target); 0.27.0: +3 (the three release-note lines); the connection Ends group (docs/edge-routing.md §ER18): +24 (inspector.ends.*, canvas.endGrip.source / target); 0.26.2: +4 (the four release-note lines); 0.26.0: +5 (the five release-note lines); the node structure: +1 (inspector.field.currentAmount, the Pool's read-only current amount); issue #344: +2 (the 0.25.0 release note is five lines, was three); issue #344 Step 3: +9 (the connection shape and bend-point strings and the keyboard bend notice); issue #344 Step 1: +3 (three 0.25.0 release-note lines); issue #344 Step 1: +1 (canvas.tidy, the Tidy to grid rail button); 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: 1145, template: 196, frame: 7, module: 19 }) - expect(RUNTIME).toHaveLength(1367) + expect(per).toEqual({ catalog: 1148, template: 196, frame: 7, module: 19 }) + expect(RUNTIME).toHaveLength(1370) }) 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 15b93e71..8eb2ae40 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(1145) // 0.28.3: +4 (the four release-note lines); 0.28.2: +3 (the three release-note lines); 0.28.1: +4 (the four release-note lines); 0.28.0: +3 (the three release-note lines); §ER19 Reverse: +13 (inspector.ends.reverse.*); §ER18 grips: +2 (inspector.ends.noGrip.source / target); 0.27.0: +3 (the three release-note lines); the connection Ends group (docs/edge-routing.md §ER18): +24 (inspector.ends.*, canvas.endGrip.source / target); 0.26.2: +4 (the four release-note lines); 0.26.0: +5 (the five release-note lines); the node structure: +1 (inspector.field.currentAmount, the Pool's read-only current amount); issue #344: +2 (the 0.25.0 release note is five lines, was three); issue #344 Step 3: +9 (the connection shape and bend-point strings and the keyboard bend notice); issue #344 Step 1: +3 (three 0.25.0 release-note lines); issue #344 Step 1: +1 (canvas.tidy, the Tidy to grid rail button); 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(KEYS).toHaveLength(1148) // 0.28.4: +3 (the three release-note lines); 0.28.3: +4 (the four release-note lines); 0.28.2: +3 (the three release-note lines); 0.28.1: +4 (the four release-note lines); 0.28.0: +3 (the three release-note lines); §ER19 Reverse: +13 (inspector.ends.reverse.*); §ER18 grips: +2 (inspector.ends.noGrip.source / target); 0.27.0: +3 (the three release-note lines); the connection Ends group (docs/edge-routing.md §ER18): +24 (inspector.ends.*, canvas.endGrip.source / target); 0.26.2: +4 (the four release-note lines); 0.26.0: +5 (the five release-note lines); the node structure: +1 (inspector.field.currentAmount, the Pool's read-only current amount); issue #344: +2 (the 0.25.0 release note is five lines, was three); issue #344 Step 3: +9 (the connection shape and bend-point strings and the keyboard bend notice); issue #344 Step 1: +3 (three 0.25.0 release-note lines); issue #344 Step 1: +1 (canvas.tidy, the Tidy to grid rail button); 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 1b2039ca..fde42f99 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(1145) // 0.28.3: +4 (the four release-note lines); 0.28.2: +3 (the three release-note lines); 0.28.1: +4 (the four release-note lines); 0.28.0: +3 (the three release-note lines); §ER19 Reverse: +13 (inspector.ends.reverse.*); §ER18 grips: +2 (inspector.ends.noGrip.source / target); 0.27.0: +3 (the three release-note lines); the connection Ends group (docs/edge-routing.md §ER18): +24 (inspector.ends.*, canvas.endGrip.source / target); 0.26.2: +4 (the four release-note lines); 0.26.0: +5 (the five release-note lines); the node structure: +1 (inspector.field.currentAmount, the Pool's read-only current amount); issue #344: +2 (the 0.25.0 release note is five lines, was three); issue #344 Step 3: +9 (the connection shape and bend-point strings and the keyboard bend notice); issue #344 Step 1: +3 (three 0.25.0 release-note lines); issue #344 Step 1: +1 (canvas.tidy, the Tidy to grid rail button); 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(KEYS).toHaveLength(1148) // 0.28.4: +3 (the three release-note lines); 0.28.3: +4 (the four release-note lines); 0.28.2: +3 (the three release-note lines); 0.28.1: +4 (the four release-note lines); 0.28.0: +3 (the three release-note lines); §ER19 Reverse: +13 (inspector.ends.reverse.*); §ER18 grips: +2 (inspector.ends.noGrip.source / target); 0.27.0: +3 (the three release-note lines); the connection Ends group (docs/edge-routing.md §ER18): +24 (inspector.ends.*, canvas.endGrip.source / target); 0.26.2: +4 (the four release-note lines); 0.26.0: +5 (the five release-note lines); the node structure: +1 (inspector.field.currentAmount, the Pool's read-only current amount); issue #344: +2 (the 0.25.0 release note is five lines, was three); issue #344 Step 3: +9 (the connection shape and bend-point strings and the keyboard bend notice); issue #344 Step 1: +3 (three 0.25.0 release-note lines); issue #344 Step 1: +1 (canvas.tidy, the Tidy to grid rail button); 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 2205a940..64630f5f 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(1145) // 0.28.3: +4 (the four release-note lines); 0.28.2: +3 (the three release-note lines); 0.28.1: +4 (the four release-note lines); 0.28.0: +3 (the three release-note lines); §ER19 Reverse: +13 (inspector.ends.reverse.*); §ER18 grips: +2 (inspector.ends.noGrip.source / target); 0.27.0: +3 (the three release-note lines); the connection Ends group (docs/edge-routing.md §ER18): +24 (inspector.ends.*, canvas.endGrip.source / target); 0.26.2: +4 (the four release-note lines); 0.26.0: +5 (the five release-note lines); the node structure: +1 (inspector.field.currentAmount, the Pool's read-only current amount); issue #344: +2 (the 0.25.0 release note is five lines, was three); issue #344 Step 3: +9 (the connection shape and bend-point strings and the keyboard bend notice); issue #344 Step 1: +3 (three 0.25.0 release-note lines); issue #344 Step 1: +1 (canvas.tidy, the Tidy to grid rail button); 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(KEYS).toHaveLength(1148) // 0.28.4: +3 (the three release-note lines); 0.28.3: +4 (the four release-note lines); 0.28.2: +3 (the three release-note lines); 0.28.1: +4 (the four release-note lines); 0.28.0: +3 (the three release-note lines); §ER19 Reverse: +13 (inspector.ends.reverse.*); §ER18 grips: +2 (inspector.ends.noGrip.source / target); 0.27.0: +3 (the three release-note lines); the connection Ends group (docs/edge-routing.md §ER18): +24 (inspector.ends.*, canvas.endGrip.source / target); 0.26.2: +4 (the four release-note lines); 0.26.0: +5 (the five release-note lines); the node structure: +1 (inspector.field.currentAmount, the Pool's read-only current amount); issue #344: +2 (the 0.25.0 release note is five lines, was three); issue #344 Step 3: +9 (the connection shape and bend-point strings and the keyboard bend notice); issue #344 Step 1: +3 (three 0.25.0 release-note lines); issue #344 Step 1: +1 (canvas.tidy, the Tidy to grid rail button); 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(1145) + expect(RUNTIME.filter(([s]) => s === 'catalog')).toHaveLength(1148) 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) diff --git a/src/i18n/trCopy.test.ts b/src/i18n/trCopy.test.ts index 6dea5131..b330f637 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(1145) // 0.28.3: +4 (the four release-note lines); 0.28.2: +3 (the three release-note lines); 0.28.1: +4 (the four release-note lines); 0.28.0: +3 (the three release-note lines); §ER19 Reverse: +13 (inspector.ends.reverse.*); §ER18 grips: +2 (inspector.ends.noGrip.source / target); 0.27.0: +3 (the three release-note lines); the connection Ends group (docs/edge-routing.md §ER18): +24 (inspector.ends.*, canvas.endGrip.source / target); 0.26.2: +4 (the four release-note lines); 0.26.0: +5 (the five release-note lines); the node structure: +1 (inspector.field.currentAmount, the Pool's read-only current amount); issue #344: +2 (the 0.25.0 release note is five lines, was three); issue #344 Step 3: +9 (the connection shape and bend-point strings and the keyboard bend notice); issue #344 Step 1: +3 (three 0.25.0 release-note lines); issue #344 Step 1: +1 (canvas.tidy, the Tidy to grid rail button); 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) + expect(KEYS).toHaveLength(1148) // 0.28.4: +3 (the three release-note lines); 0.28.3: +4 (the four release-note lines); 0.28.2: +3 (the three release-note lines); 0.28.1: +4 (the four release-note lines); 0.28.0: +3 (the three release-note lines); §ER19 Reverse: +13 (inspector.ends.reverse.*); §ER18 grips: +2 (inspector.ends.noGrip.source / target); 0.27.0: +3 (the three release-note lines); the connection Ends group (docs/edge-routing.md §ER18): +24 (inspector.ends.*, canvas.endGrip.source / target); 0.26.2: +4 (the four release-note lines); 0.26.0: +5 (the five release-note lines); the node structure: +1 (inspector.field.currentAmount, the Pool's read-only current amount); issue #344: +2 (the 0.25.0 release note is five lines, was three); issue #344 Step 3: +9 (the connection shape and bend-point strings and the keyboard bend notice); issue #344 Step 1: +3 (three 0.25.0 release-note lines); issue #344 Step 1: +1 (canvas.tidy, the Tidy to grid rail button); 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) }) }) diff --git a/src/i18n/viCopy.test.ts b/src/i18n/viCopy.test.ts index 0b181df5..1f7069c5 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(1145) // 0.28.3: +4 (the four release-note lines); 0.28.2: +3 (the three release-note lines); 0.28.1: +4 (the four release-note lines); 0.28.0: +3 (the three release-note lines); §ER19 Reverse: +13 (inspector.ends.reverse.*); §ER18 grips: +2 (inspector.ends.noGrip.source / target); 0.27.0: +3 (the three release-note lines); the connection Ends group (docs/edge-routing.md §ER18): +24 (inspector.ends.*, canvas.endGrip.source / target); 0.26.2: +4 (the four release-note lines); 0.26.0: +5 (the five release-note lines); the node structure: +1 (inspector.field.currentAmount, the Pool's read-only current amount); issue #344: +2 (the 0.25.0 release note is five lines, was three); issue #344 Step 3: +9 (the connection shape and bend-point strings and the keyboard bend notice); issue #344 Step 1: +3 (three 0.25.0 release-note lines); issue #344 Step 1: +1 (canvas.tidy, the Tidy to grid rail button); 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(KEYS).toHaveLength(1148) // 0.28.4: +3 (the three release-note lines); 0.28.3: +4 (the four release-note lines); 0.28.2: +3 (the three release-note lines); 0.28.1: +4 (the four release-note lines); 0.28.0: +3 (the three release-note lines); §ER19 Reverse: +13 (inspector.ends.reverse.*); §ER18 grips: +2 (inspector.ends.noGrip.source / target); 0.27.0: +3 (the three release-note lines); the connection Ends group (docs/edge-routing.md §ER18): +24 (inspector.ends.*, canvas.endGrip.source / target); 0.26.2: +4 (the four release-note lines); 0.26.0: +5 (the five release-note lines); the node structure: +1 (inspector.field.currentAmount, the Pool's read-only current amount); issue #344: +2 (the 0.25.0 release note is five lines, was three); issue #344 Step 3: +9 (the connection shape and bend-point strings and the keyboard bend notice); issue #344 Step 1: +3 (three 0.25.0 release-note lines); issue #344 Step 1: +1 (canvas.tidy, the Tidy to grid rail button); 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(1145) + expect(RUNTIME.filter(([s]) => s === 'catalog')).toHaveLength(1148) 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) diff --git a/src/releaseNotes/releaseNotes.ts b/src/releaseNotes/releaseNotes.ts index 73901aa7..c8d70026 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[] = [ + { + // a connection drag's start and end measure every node's face handles in + // one update (docs/edge-routing.md §ER17.1, share note 58); the date is + // the day it is deployed, set right before the merge + id: 'release:0.28.4', + version: '0.28.4', + date: '2026-10-17', + items: ['whatsNew.v0284.start', 'whatsNew.v0284.release', 'whatsNew.v0284.same'], + }, { // undo / redo end with nothing selected, a module insert restores its // own selection (docs/large-graph-readability.md LGR-D17,