From 678d0d7b35b983aeb52178673097c382935d0182 Mon Sep 17 00:00:00 2001 From: Hanrim <148833226+MerciHanrim@users.noreply.github.com> Date: Wed, 14 Oct 2026 11:27:59 +0900 Subject: [PATCH 1/7] fix(edges): a routed label keeps off its own connection's stored bend points MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Share note 50 (option A, Lumi 2026-10-14): a routed label took the first free slot of its own line clear of nodes, other labels and other lines, but not of its own bend points, so while the connection was selected its bend markers could sit under it (measured in the browser on the bundled examples with bends added: touching 23 / 202, 49 / 262 and 29 / 254 by placement). `placeLabelOn` (src/store/routeMap.ts) now also keeps the label off a BEND_LABEL_BOX (16 px: the 12 px marker plus 2 px) square on each STORED bend point (`waypoints`, collected per edge in the plan), in the first two passes. The last-resort passes ignore the bends, so a line with no slot clear of them still has its label: the overlap is avoided where a slot allows, not ruled out. The rule does not read the selection, so selecting or deselecting never moves a label; a connection without stored bends places its label as before. docs/edge-routing.md §ER14.3 says it. Remeasured in the browser on this code with the same probe and inputs as note 50: touching 1 / 202, 6 / 262 and 0 / 254 (label centre on a marker 1, 5, 0). The 7 left are labels on the 0.5 slot over their own bend, which by the code can only come from the last-resort passes (inferred, not checked one by one). Tests: unit routeMap.bendLabel.test.ts 4 (it fails on main: the label stays on the bend); e2e label-bends.spec.ts 2 (the label leaves a bend put under it; selected, the rendered label box and the bend marker do not meet; the place holds through select, deselect, zoom, undo / redo and a reopen; it fails on main). Full vitest 3575 passed, 1 skipped; lint 0 errors; tsc. No change declaration yet: it comes with the release. The fan key's missing face and React Flow's error 008 are separate units. --- docs/edge-routing.md | 8 +++ e2e/label-bends.spec.ts | 103 +++++++++++++++++++++++++++ src/store/routeMap.bendLabel.test.ts | 88 +++++++++++++++++++++++ src/store/routeMap.ts | 30 ++++++-- 4 files changed, 223 insertions(+), 6 deletions(-) create mode 100644 e2e/label-bends.spec.ts create mode 100644 src/store/routeMap.bendLabel.test.ts diff --git a/docs/edge-routing.md b/docs/edge-routing.md index f65ba034..42d19b53 100644 --- a/docs/edge-routing.md +++ b/docs/edge-routing.md @@ -781,6 +781,14 @@ pinned single-edge core (golden + differential tests); the app no longer calls i - A label takes the first free slot on its own route — the midpoint, then 0.4, 0.6, 0.3, 0.7, 0.25, 0.75, 0.2, 0.8 of its length — clear of every node box (2 px margin) and every label already placed; the midpoint when none is free. +- A label also keeps off its own connection's STORED bend points + (`waypoints`): a `BEND_LABEL_BOX` (16 px) square on each, the 12 px bend + marker plus 2 px, in the first two passes (clear of everything, then clear of + nodes and labels). The bend markers are drawn there while the connection is + selected; the rule holds whether it is selected or not, so a selection never + moves the label. The last-resort passes ignore the bends, so a line with no + slot clear of them still has its label, possibly over a bend: the overlap is + avoided where a slot allows, not ruled out (share notes 50 and 52). - Then up to `LABEL_ROUNDS` (2) guarded rounds: an edge whose route runs through other edges' labels is routed again with those labels as soft obstacles (`computeSoftReroute`: one rung of `SOFT_PADS` — 12, then 6 px — diff --git a/e2e/label-bends.spec.ts b/e2e/label-bends.spec.ts new file mode 100644 index 00000000..6dabc445 --- /dev/null +++ b/e2e/label-bends.spec.ts @@ -0,0 +1,103 @@ +import { readFileSync } from 'node:fs' +import type { Page } from '@playwright/test' +import { expect, openApp, routesSettled, test } from './support/loop' + +// docs/edge-routing.md §ER14.3 — a routed label keeps off its own connection's +// stored bend points, so the bend markers drawn while it is selected do not sit +// under it; and the place does not depend on the selection, the zoom, an undo / +// redo round trip or a reopen. On the small node-structure fixture: e_treat +// (Triage desk → Treated today) with one bend put where its label sits now. + +const FIXTURE = readFileSync(new URL('./fixtures/node-structure-small.json', import.meta.url), 'utf8') +type Pt = { x: number; y: number } +type L = { + graph: { getState: () => any } + ui: { getState: () => any } + rf: { setViewport: (v: object, o?: object) => void } + routeMap: { get: (id: string) => { label: Pt | null; points: Pt[] } | null } + autosave: { flush: () => void } +} +const loop = (page: Page) => page.evaluate.bind(page) + +async function open(page: Page) { + 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) +} +/** the label's centre in flow coordinates, from the route map the canvas draws */ +const labelAt = (page: Page) => page.evaluate(() => (window as unknown as { __loop: L }).__loop.routeMap.get('e_treat')?.label ?? null) +const near = (a: Pt | null, b: Pt | null) => !!a && !!b && Math.abs(a.x - b.x) < 0.5 && Math.abs(a.y - b.y) < 0.5 + +async function selectEdge(page: Page) { + const p = await page.locator('.react-flow__edge[data-id="e_treat"] path.react-flow__edge-path').evaluate((el) => { + const s = el as SVGPathElement + const q = s.getPointAtLength(s.getTotalLength() * 0.15) + 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) + await expect(page.locator('.inspector .ends')).toBeVisible() +} + +test.describe('a label keeps off its own bend points (§ER14.3)', () => { + test('the label leaves the bend put under it; selected, no bend marker is under it; the place holds through selection, zoom, undo / redo and a reopen', async ({ page }) => { + await open(page) + const before = await labelAt(page) + expect(before).not.toBeNull() + // one bend exactly where the label is now (one undo step, as a bend click) + const bend = { x: Math.round(before!.x), y: Math.round(before!.y) } + await loop(page)((b) => (window as unknown as { __loop: L }).__loop.graph.getState().setEdgeRouting('e_treat', { route: 'orthogonal', waypoints: [b] }), bend) + await routesSettled(page) + const bent = await labelAt(page) + expect(bent).not.toBeNull() + expect(near(bent, before), 'the label left the bend').toBe(false) + + // selected: the rendered label box and the rendered bend markers do not meet + await selectEdge(page) + const marker = page.locator('.route-bend') + await expect(marker).toHaveCount(1) + const lb = (await page.locator('.edge-label[data-edge-id="e_treat"]').boundingBox())! + const mb = (await marker.boundingBox())! + const meet = lb.x < mb.x + mb.width && mb.x < lb.x + lb.width && lb.y < mb.y + mb.height && mb.y < lb.y + lb.height + expect(meet, 'label box over the bend marker').toBe(false) + expect(near(await labelAt(page), bent), 'selecting does not move it').toBe(true) + + // deselected + const pane = (await page.locator('.react-flow__pane').boundingBox())! + await page.mouse.click(pane.x + 20, pane.y + 20) + await expect(page.locator('.route-bend')).toHaveCount(0) + expect(near(await labelAt(page), bent), 'deselecting does not move it').toBe(true) + + // zoom in and back + await page.evaluate(() => (window as unknown as { __loop: L }).__loop.rf.setViewport({ x: 40, y: 60, zoom: 1.6 }, { duration: 0 })) + await routesSettled(page) + expect(near(await labelAt(page), bent), 'zoom does not move it').toBe(true) + await page.evaluate(() => (window as unknown as { __loop: L }).__loop.rf.setViewport({ x: 40, y: 60, zoom: 1 }, { duration: 0 })) + await routesSettled(page) + expect(near(await labelAt(page), bent)).toBe(true) + + // undo the bend: back to the first place; redo: the bent place again + await page.keyboard.press('Control+z') + await routesSettled(page) + await expect.poll(() => labelAt(page).then((p) => near(p, before))).toBe(true) + await page.keyboard.press('Control+Shift+z') + await routesSettled(page) + await expect.poll(() => labelAt(page).then((p) => near(p, bent))).toBe(true) + + // reopen (autosave, reload): the same place + await page.evaluate(() => (window as unknown as { __loop: L }).__loop.autosave.flush()) + await page.reload() + await page.waitForFunction(() => Boolean((window as unknown as { __loop?: unknown }).__loop)) + await routesSettled(page) + await expect.poll(() => labelAt(page).then((p) => near(p, bent))).toBe(true) + }) + + test('a connection without bends keeps the place it had', async ({ page }) => { + await open(page) + const a = await labelAt(page) + await selectEdge(page) + expect(near(await labelAt(page), a)).toBe(true) + }) +}) diff --git a/src/store/routeMap.bendLabel.test.ts b/src/store/routeMap.bendLabel.test.ts new file mode 100644 index 00000000..794f809a --- /dev/null +++ b/src/store/routeMap.bendLabel.test.ts @@ -0,0 +1,88 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import type { LoopEdge, LoopNode } from '../model/types' +import { __resetRouteCache, __syncFullGeneration, setRouteLabelSize } from './routeMap' + +// docs/edge-routing.md §ER14.3 — a routed label keeps off its own connection's +// STORED bend points (where its bend markers sit while it is selected), in the +// first two placement passes; the last-resort passes do not, so a line with +// no free slot still has its label. Placement never depends on the selection. + +type Pt = { x: number; y: number } +const node = (id: string, x: number, y: number): LoopNode => + ({ id, type: 'pool', position: { x, y }, data: { kind: 'pool', label: id, activation: 'passive', initial: 0, capacity: null, mode: 'pullAny' } }) as unknown as LoopNode +const edge = (id: string, source: string, target: string, data: Record = {}, top: Record = {}): LoopEdge => + ({ id, type: 'loop', source, target, sourceHandle: 'out', targetHandle: 'in', data: { kind: 'resource', flow: '1', route: 'orthogonal', ...data }, ...top }) as unknown as LoopEdge + +const LS = { w: 24, h: 18 } +const labelBox = (c: Pt) => ({ x: c.x - LS.w / 2, y: c.y - LS.h / 2, w: LS.w, h: LS.h }) +/** the bend marker as drawn (`.route-bend`, 12 × 12 flow px) — not the rule's own constant, so a test of the rule cannot pass by sharing its number */ +const MARKER = 12 +const bendBox = (w: Pt) => ({ x: w.x - MARKER / 2, y: w.y - MARKER / 2, w: MARKER, h: MARKER }) +const hit = (a: { x: number; y: number; w: number; h: number }, b: { x: number; y: number; w: number; h: number }) => + a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h +/** the distance from `p` to the polyline */ +function distToLine(points: Pt[], p: Pt): number { + let best = Infinity + for (let i = 1; i < points.length; i++) { + const a = points[i - 1] + const b = points[i] + const dx = b.x - a.x + const dy = b.y - a.y + const l2 = dx * dx + dy * dy + const t = l2 > 0 ? Math.max(0, Math.min(1, ((p.x - a.x) * dx + (p.y - a.y) * dy) / l2)) : 0 + best = Math.min(best, Math.hypot(p.x - (a.x + t * dx), p.y - (a.y + t * dy))) + } + return best +} + +/** two Pools set apart diagonally, so the automatic route is a Z whose middle + * (the first label slot) is on its vertical stretch */ +const nodes = () => [node('s', 0, 0), node('t', 480, 320)] + +beforeEach(() => { + __resetRouteCache() + setRouteLabelSize('e', LS.w, LS.h) +}) + +describe('a label keeps off its own stored bend points (§ER14.3)', () => { + it('a bend placed on the slot the label took moves the label to another slot of its own line', () => { + const auto = __syncFullGeneration(nodes(), [edge('e', 's', 't')]).get('e')! + expect(auto.label).not.toBeNull() + const bend = { x: Math.round(auto.label!.x), y: Math.round(auto.label!.y) } + __resetRouteCache(); setRouteLabelSize('e', LS.w, LS.h) + const bent = __syncFullGeneration(nodes(), [edge('e', 's', 't', { waypoints: [bend] })]).get('e')! + // the route still passes the bend (so the marker is on the line), and the label is off it + expect(distToLine(bent.points, bend)).toBeLessThan(1) + expect(bent.label).not.toBeNull() + expect(hit(labelBox(bent.label!), bendBox(bend)), 'label over its own bend').toBe(false) + expect(distToLine(bent.points, bent.label!), 'still on its own line').toBeLessThan(1) + }) + + it('a connection without bends keeps its label place', () => { + const a = __syncFullGeneration(nodes(), [edge('e', 's', 't')]).get('e')! + __resetRouteCache(); setRouteLabelSize('e', LS.w, LS.h) + const b = __syncFullGeneration(nodes(), [edge('e', 's', 't', { waypoints: [] })]).get('e')! + expect(b.label).toEqual(a.label) + }) + + it('selection does not move a label: the same place selected or not', () => { + const auto = __syncFullGeneration(nodes(), [edge('e', 's', 't')]).get('e')! + const bend = { x: Math.round(auto.label!.x), y: Math.round(auto.label!.y) } + __resetRouteCache(); setRouteLabelSize('e', LS.w, LS.h) + const off = __syncFullGeneration(nodes(), [edge('e', 's', 't', { waypoints: [bend] })]).get('e')! + __resetRouteCache(); setRouteLabelSize('e', LS.w, LS.h) + const on = __syncFullGeneration(nodes(), [edge('e', 's', 't', { waypoints: [bend] }, { selected: true })]).get('e')! + expect(on.label).toEqual(off.label) + }) + + it('a line too short for any slot clear of its bends still gets its label (the last resort keeps it)', () => { + // two Pools nearly touching, a bend on the short stretch between them + const near = [node('s', 0, 0), node('t', 150, 0)] + const auto = __syncFullGeneration(near, [edge('e', 's', 't')]).get('e')! + const bends = auto.points.slice(1, -1).length ? auto.points.slice(1, -1) : [auto.mid] + __resetRouteCache(); setRouteLabelSize('e', LS.w, LS.h) + setRouteLabelSize('e', 120, 18) // wider than the line: no slot is clear of the bends + const r = __syncFullGeneration(near, [edge('e', 's', 't', { waypoints: bends.map((p) => ({ x: Math.round(p.x), y: Math.round(p.y) })) })]).get('e')! + expect(r.label, 'a label is always placed').not.toBeNull() + }) +}) diff --git a/src/store/routeMap.ts b/src/store/routeMap.ts index f5c9ab23..3dfc88fa 100644 --- a/src/store/routeMap.ts +++ b/src/store/routeMap.ts @@ -65,6 +65,9 @@ export const LABEL_ROUNDS = 2 export const LABEL_SLOTS = [0.5, 0.4, 0.6, 0.3, 0.7, 0.25, 0.75, 0.2, 0.8] as const /** the margin kept between a label and a node box */ const LABEL_NODE_MARGIN = 2 +/** §ER14.3 — the square a label keeps off around each of its connection's own + * stored bend points, flow px: the 12 px bend marker plus 2 px on each side */ +export const BEND_LABEL_BOX = 16 /** a full generation's work per animation frame, ms */ export const SLICE_MS = 8 /** the provisional map's routing budget, ms (a new edge over it is not drawn @@ -364,6 +367,10 @@ type Plan = { reserved: { edgeId: string; port: string; stub: Pt[]; leg: Pt[] | null }[] /** each fan member's branch at its source / target port (§ER14.3) */ fans: Map + /** §ER14.3 — each routed edge's own STORED bend points (its waypoints), the + * points its bend markers sit on while it is selected; its label keeps off + * them whether it is selected or not */ + bends: Map } /** §ER14.3 — the branch points of one port's fan. Each member's far end is @@ -470,7 +477,12 @@ function plan(nodes: LoopNode[], edges: LoopEdge[]): Plan { } // the outside names are obstacles like the nodes (they carry their own ids, // so a route's own end nodes are still the only boxes it may touch) - return { ortho, free, lines, byId, boxes: [...nodes.map(boxOf), ...nameBoxesOf(nodes)], groups, portUse, fans, reserved } + const bends = new Map() + for (const e of ortho) { + const wps = (e.data as { waypoints?: unknown } | undefined)?.waypoints + if (Array.isArray(wps) && wps.length > 0) bends.set(e.id, wps as Pt[]) + } + return { ortho, free, lines, byId, boxes: [...nodes.map(boxOf), ...nameBoxesOf(nodes)], groups, portUse, fans, reserved, bends } } /** one edge, routed against the current layout and the given placed routes: @@ -570,9 +582,12 @@ function sharedStubsOf(p: Plan, routes: ReadonlyMap): Pt[ } /** the first free slot on the edge's own line: never on a shared port stub; - * then clear of every node box, every label already placed and every other - * line (routed, Curved or Straight); failing that, clear of nodes and labels - * only; the midpoint last. Returns the slot's centre and its fraction. */ + * then clear of every node box, every label already placed, every other + * line (routed, Curved or Straight) and the edge's own stored bend points; + * failing that, clear of nodes, labels and its own bends only; then off the + * shared stubs alone; the midpoint last (so a short line with no free slot + * still has its label, possibly over a bend). Returns the slot's centre and + * its fraction. */ function placeLabelOn( p: Plan, id: string, @@ -587,6 +602,9 @@ function placeLabelOn( const nodes = p.boxes.map((b) => ({ ...b, x: b.x - LABEL_NODE_MARGIN, y: b.y - LABEL_NODE_MARGIN, w: b.w + 2 * LABEL_NODE_MARGIN, h: b.h + 2 * LABEL_NODE_MARGIN })) const candidates = LABEL_SLOTS.map((f) => ({ c: f === 0.5 ? mid : pointAt(points, f), f })) const offStub = (box: Box) => !stubs.some((s) => polyHits(s, box)) + const half = BEND_LABEL_BOX / 2 + const bendBoxes: Box[] = (p.bends.get(id) ?? []).map((w) => ({ id: `bend:${id}`, x: w.x - half, y: w.y - half, w: BEND_LABEL_BOX, h: BEND_LABEL_BOX })) + const offBends = (box: Box) => !bendBoxes.some((b) => hit(b, box)) const clear = (box: Box): boolean => { if (nodes.some((n) => hit(n, box))) return false for (const [oid, ob] of placed) if (oid !== id && hit(ob, box)) return false @@ -599,11 +617,11 @@ function placeLabelOn( } for (const s of candidates) { const box = labelBoxAt(s.c, size, id) - if (offStub(box) && clear(box) && offLines(box)) return s + if (offStub(box) && clear(box) && offLines(box) && offBends(box)) return s } for (const s of candidates) { const box = labelBoxAt(s.c, size, id) - if (offStub(box) && clear(box)) return s + if (offStub(box) && clear(box) && offBends(box)) return s } for (const s of candidates) if (offStub(labelBoxAt(s.c, size, id))) return s return { c: mid, f: 0.5 } From cab6c6757f6e783b8426e35ee0f29872a8a57959 Mon Sep 17 00:00:00 2001 From: Hanrim <148833226+MerciHanrim@users.noreply.github.com> Date: Wed, 14 Oct 2026 12:07:55 +0900 Subject: [PATCH 2/7] =?UTF-8?q?chore(release):=200.28.1=20=E2=80=94=20a=20?= =?UTF-8?q?label=20keeps=20off=20the=20bend=20points=20you=20placed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - package.json / package-lock.json 0.28.1; `.changes/label-avoids-own-bends.json` (user-facing, `release:0.28.1`), date 2026-10-14, to be checked on the merge day. - What's new 0.28.1, three lines in 18 languages: a label keeps off the bend points you placed wherever its line has room, and can still sit on one on a short line with close bends (no line promises the overlap is gone); its place does not depend on the selection or the zoom; in a dense diagram a label moving off a bend can also move a nearby connection's label or line (measured: another label moved in 18 of 934 cases, share note 52). es-ES = es-419 and pt-BR = pt-PT, written neutrally; French avoids the punctuation its spacing rule governs. The locale tests' counts follow (catalog 1137, runtime 1359); the e2e release list pins 0.28.1. - CHANGELOG v0.28.1; README Latest v0.28.1 (v0.28.0 kept as a section, v0.26.0 moved into the CHANGELOG list). The previous commit's message says a connection without stored bends places its label as before: true of ITS placement rule, not of the whole drawing — another label moved in 18 of 934 measured cases (note 52); not rewritten. The translation contract tests pass: evidence of the catalogs' structure and terms, not a review of how the lines read; no native-speaker review. Tests: locale and release 713; whats-new.spec.ts, descriptive-copy-wrapping and label-bends.spec.ts 123 passed; full vitest 3575 passed, 1 skipped; lint 0 errors; tsc; change-declaration and the i18n, ICU, isolate, control-byte, glyph and locale-list checks pass. --- .changes/label-avoids-own-bends.json | 1 + CHANGELOG.md | 10 +++++++ README.md | 39 +++++++++++----------------- e2e/whats-new.spec.ts | 2 +- package-lock.json | 4 +-- package.json | 2 +- src/i18n/itCopy.test.ts | 6 ++--- src/i18n/locales/ar/ui.ts | 3 +++ src/i18n/locales/de/ui.ts | 3 +++ src/i18n/locales/en/ui.ts | 3 +++ src/i18n/locales/es-419/ui.ts | 3 +++ src/i18n/locales/es-ES/ui.ts | 3 +++ src/i18n/locales/fr/ui.ts | 3 +++ src/i18n/locales/it/ui.ts | 3 +++ src/i18n/locales/ja/ui.ts | 3 +++ src/i18n/locales/ko/ui.ts | 3 +++ src/i18n/locales/nl/ui.ts | 3 +++ src/i18n/locales/pt-BR/ui.ts | 3 +++ src/i18n/locales/pt-PT/ui.ts | 3 +++ src/i18n/locales/ru/ui.ts | 3 +++ src/i18n/locales/th/ui.ts | 3 +++ src/i18n/locales/tr/ui.ts | 3 +++ src/i18n/locales/vi/ui.ts | 3 +++ src/i18n/locales/zh-Hans/ui.ts | 3 +++ src/i18n/locales/zh-Hant/ui.ts | 3 +++ src/i18n/nlCopy.test.ts | 6 ++--- src/i18n/ptPtCopy.test.ts | 2 +- src/i18n/ruCopy.test.ts | 2 +- src/i18n/thCopy.test.ts | 4 +-- src/i18n/trCopy.test.ts | 2 +- src/i18n/viCopy.test.ts | 4 +-- src/releaseNotes/releaseNotes.ts | 9 +++++++ 32 files changed, 106 insertions(+), 41 deletions(-) create mode 100644 .changes/label-avoids-own-bends.json diff --git a/.changes/label-avoids-own-bends.json b/.changes/label-avoids-own-bends.json new file mode 100644 index 00000000..608869e1 --- /dev/null +++ b/.changes/label-avoids-own-bends.json @@ -0,0 +1 @@ +{ "type": "user-facing", "releaseNoteId": "release:0.28.1" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bbe2c0b..8194a4d2 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.1 — 2026-10-14 + +A connection's label keeps off the bend points you placed on it, where its line has room ([`docs/edge-routing.md`](docs/edge-routing.md) §ER14.3). + +- **The rule.** A routed label takes the first free slot of its own line; free now also means clear of a 16 px square on each of the connection's own STORED bend points (the 12 px bend marker plus 2 px), in the first two placement passes. The last-resort passes ignore the bends, so a line with no slot clear of them still has its label, possibly over a bend: the overlap is avoided where a slot allows, not ruled out. The rule reads stored data only, so selecting or deselecting a connection, or zooming, never moves a label. +- **Measured on the screen** (the bundled examples with bends added to one connection at a time, its rendered label compared with its rendered bend markers): a label touching one of its own markers went from 23 / 202, 49 / 262 and 29 / 254 (three ways to place the bends) to 1, 6 and 0. Most of the 7 left are short lines with bends close together. +- **Other connections.** Labels are placed in order and lines are rerouted around labels, so a label that moves off a bend can move another one: in the same measurement another label moved in 18 of 934 cases and another line changed in 20, in Early MMO, the gacha banner and the playback choreography; none in the other examples. In one MMO case a neighbouring line now runs along a different line for longer and its label sits on that line. + +**Notes.** Three What's new lines in 18 languages: the translation contract tests pass, and none had native-speaker review. The router's port grouping by face and React Flow's one-render gap after a change of face are separate, later work. + ## v0.28.0 — 2026-10-14 A connection can be turned around without drawing it again — the second line-editing release ([`docs/edge-routing.md`](docs/edge-routing.md) §ER19). diff --git a/README.md b/README.md index b47f69f1..2d1bdbc9 100644 --- a/README.md +++ b/README.md @@ -140,7 +140,18 @@ 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.0 +## Latest — v0.28.1 + +A connection's label keeps off the bend points you placed on it, where its line has room. + +- **Labels clear of your bends**: a label no longer sits under the square handles of the bends + you placed when the connection is selected, wherever its line has a free spot; on a short + line with bends close together it can still sit on one +- **Stays put**: selecting a connection, deselecting it or zooming does not move its label +- **Nearby connections**: in a dense diagram, a label that moves off a bend can also move the + label or the line of a nearby connection + +## v0.28.0 A connection can be turned around without drawing it again. @@ -193,29 +204,9 @@ between. The badges of two connections that leave a node together can still meet briefly when they start at different moments, and amounts at the middle zoom level are not yet easier to read. -## v0.26.0 - -A new node: a compact body with its role and value inside and its name just outside, so the -size of a node no longer depends on the length of a translation. - -- **New node design**: the name sits outside the body and stays visible at the middle zoom - level; a value too long for the body ends in an ellipsis and stays complete in its tooltip - and in the Inspector -- **Connections from any side**: a connection can leave or enter a node at its top, bottom, - left or right, and a new connection remembers the side it was drawn from; existing documents - are not re-placed, their values and the meaning of their connections are kept, and they look - different with the new node -- **Notices under the toolbar**: the update notice and the first-time hints sit in a strip - under the toolbar, and the diagram does not move when they appear or close -- **A Pool's current amount**: a read-only field in the Inspector, beside the starting - amount, also on phones -- **Re-placed Templates**: the Coffee roastery (money in $/day) and the 3-zone gacha banner - comparison are laid out again for the new node - -A document with chosen sides, saved again by v0.25.0 or earlier, loses those sides. v0.26.1 -corrects the wording of two lines of this release's What's new note. - -See [`CHANGELOG.md`](CHANGELOG.md) for the full notes of these releases, v0.25.0 (nodes +See [`CHANGELOG.md`](CHANGELOG.md) for the full notes of these releases, v0.26.0 (the +compact node with its name outside, connections from any side, notices under the toolbar), +v0.25.0 (nodes on a grid, orthogonal connections around them, editable connection shapes and text inside nodes), v0.24.0 (three playback speed tiers, and a speed choice and fewer moving markers on the phone), v0.23.0 (a Pool diff --git a/e2e/whats-new.spec.ts b/e2e/whats-new.spec.ts index 877b66c7..859d7864 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.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.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 eb39ff26..2eb53454 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "loop-studio", - "version": "0.28.0", + "version": "0.28.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "loop-studio", - "version": "0.28.0", + "version": "0.28.1", "dependencies": { "@fontsource/ibm-plex-mono": "^5.3.0", "@fontsource/ibm-plex-sans": "^5.3.0", diff --git a/package.json b/package.json index b684bb90..d5b51f07 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "loop-studio", "private": true, - "version": "0.28.0", + "version": "0.28.1", "type": "module", "engines": { "node": ">=22.12.0" diff --git a/src/i18n/itCopy.test.ts b/src/i18n/itCopy.test.ts index 2004fbeb..4ff7b97d 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(1134) // 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(1137) // 0.28.1: +3 (the three 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: 1134, template: 196, frame: 7, module: 19 }) - expect(RUNTIME).toHaveLength(1356) + expect(per).toEqual({ catalog: 1137, template: 196, frame: 7, module: 19 }) + expect(RUNTIME).toHaveLength(1359) }) 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 f327c8a9..ad296535 100644 --- a/src/i18n/locales/ar/ui.ts +++ b/src/i18n/locales/ar/ui.ts @@ -645,6 +645,9 @@ const ui = { 'whatsNew.v0280.reverse': 'يمكنك الآن عكس اتجاه رابط دون إعادة رسمه: حدّد الرابط واضغط «عكس» بجوار «الطرفان» في المفتّش. تبقى الكمية والشرط واللون وشكل الخط كما كانت وتُقرأ وفق الاتجاه الجديد، وتبقى نقاط الانعطاف التي وضعتها في أماكنها.', 'whatsNew.v0280.drawn': 'بعد العكس قد يتغيّر المسار التلقائي والخط قرب كل طرف ومكان تسمية الرابط.', 'whatsNew.v0280.refused': 'العكس الذي لا يمكن تنفيذه، كأن ينتهي الرابط عند عقدة بلا مدخل أو يفقد رابط حالة أثره، لا يُطبَّق، ويُعرض السبب.', + 'whatsNew.v0281.labelBends': 'صارت تسمية الرابط الآن تبتعد عن نقاط الانعطاف التي وضعتها حيثما كان في الخط متّسع، فلا تغطيها مقابض نقاط الانعطاف عند تحديد الرابط. وعلى خط قصير متقارب نقاط الانعطاف قد تبقى فوق إحداها.', + 'whatsNew.v0281.stable': 'يتحدّد مكان التسمية بخطها ونقاط الانعطاف عليه لا بالتحديد: تحديد الرابط أو إلغاء تحديده أو التكبير والتصغير يُبقي التسمية في مكانها.', + 'whatsNew.v0281.nearby': 'في مخطط مزدحم، قد تُحرّك تسميةٌ ابتعدت عن نقطة انعطاف تسميةَ رابط مجاور أو خطَّه أيضًا.', } as const export default ui diff --git a/src/i18n/locales/de/ui.ts b/src/i18n/locales/de/ui.ts index b6fb4f64..22d3438d 100644 --- a/src/i18n/locales/de/ui.ts +++ b/src/i18n/locales/de/ui.ts @@ -680,6 +680,9 @@ const ui = { 'whatsNew.v0280.reverse': 'Eine Verbindung lässt sich jetzt umkehren, ohne sie neu zu zeichnen: Wählen Sie sie aus und drücken Sie „Umkehren“ neben „Enden“ in den Eigenschaften. Menge, Bedingung, Farbe und Linienform bleiben, wie sie waren, und gelten in der neuen Richtung; Ihre Knickpunkte bleiben an ihrem Platz.', 'whatsNew.v0280.drawn': 'Nach dem Umkehren können die automatische Linienführung, die Linie an jedem Ende und der Platz der Beschriftung anders aussehen.', 'whatsNew.v0280.refused': 'Eine Umkehrung, die nicht möglich ist, etwa weil die Verbindung an einem Knoten ohne Eingang enden oder eine Zustandsverbindung ihre Wirkung verlieren würde, wird nicht ausgeführt, und der Grund wird angezeigt.', + 'whatsNew.v0281.labelBends': 'Die Beschriftung einer Verbindung hält jetzt Abstand zu den Knickpunkten, die Sie gesetzt haben, wo die Linie Platz bietet, sodass die Knickpunkt-Griffe sie beim Auswählen nicht mehr verdecken. Auf einer kurzen Linie mit dicht beieinander liegenden Knickpunkten kann sie weiterhin auf einem liegen.', + 'whatsNew.v0281.stable': 'Wo eine Beschriftung steht, hängt von ihrer Linie und deren Knickpunkten ab, nicht von der Auswahl: Auswählen, Abwählen oder Zoomen lässt die Beschriftung, wo sie ist.', + 'whatsNew.v0281.nearby': 'In einem dichten Diagramm kann eine Beschriftung, die einem Knickpunkt ausweicht, auch die Beschriftung oder die Linie einer benachbarten Verbindung verschieben.', } satisfies Record export default ui diff --git a/src/i18n/locales/en/ui.ts b/src/i18n/locales/en/ui.ts index 0222bd00..3b7598b7 100644 --- a/src/i18n/locales/en/ui.ts +++ b/src/i18n/locales/en/ui.ts @@ -590,6 +590,9 @@ const ui = { 'whatsNew.v0280.reverse': 'A connection can now be turned around without drawing it again: 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.', 'whatsNew.v0280.drawn': 'After a reversal the automatic route, the line next to each end and the label’s place may be drawn differently.', 'whatsNew.v0280.refused': 'A reversal that cannot be made, such as one that would end the connection at a node with no input or leave a state connection with no effect, is not applied, and the reason is shown.', + 'whatsNew.v0281.labelBends': 'A connection’s label now keeps off the bend points you placed on it wherever its line has room, so the bend handles no longer cover it when you select the connection. On a short line with bends close together it can still sit on one.', + 'whatsNew.v0281.stable': 'Where a label goes depends on its line and the bends on it, not on the selection: selecting a connection, deselecting it or zooming leaves its label where it is.', + 'whatsNew.v0281.nearby': 'In a dense diagram, a label that moves off a bend can also move the label or the line of a nearby connection.', } 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 97d50575..860ea6fe 100644 --- a/src/i18n/locales/es-419/ui.ts +++ b/src/i18n/locales/es-419/ui.ts @@ -604,6 +604,9 @@ const ui = { 'whatsNew.v0280.reverse': 'Ahora una conexión se puede invertir sin volver a dibujarla: selecciónela y use Invertir, junto a Extremos en Propiedades. La cantidad, la condición, el color y la forma de la línea se mantienen y se interpretan en la nueva dirección; los codos que colocó siguen en su lugar.', 'whatsNew.v0280.drawn': 'Después de invertirla, el trazado automático, la línea junto a cada extremo y el lugar de la etiqueta pueden cambiar.', 'whatsNew.v0280.refused': 'Una inversión que no se puede hacer, como una que haría terminar la conexión en un nodo sin entrada o dejaría sin efecto una conexión de estado, no se aplica, y se muestra el motivo.', + 'whatsNew.v0281.labelBends': 'La etiqueta de una conexión ahora se aparta de los codos que colocó, donde la línea tiene espacio, así que las asas de los codos ya no la tapan al seleccionar la conexión. En una línea corta con codos muy juntos todavía puede quedar sobre uno.', + 'whatsNew.v0281.stable': 'El lugar de una etiqueta depende de su línea y de los codos que tiene, no de la selección: seleccionar una conexión, deseleccionarla o hacer zoom deja la etiqueta donde está.', + 'whatsNew.v0281.nearby': 'En un diagrama denso, una etiqueta que se aparta de un codo también puede mover la etiqueta o la línea de una conexión cercana.', } 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 f12c8e40..77d4862b 100644 --- a/src/i18n/locales/es-ES/ui.ts +++ b/src/i18n/locales/es-ES/ui.ts @@ -618,6 +618,9 @@ const ui = { 'whatsNew.v0280.reverse': 'Ahora una conexión se puede invertir sin volver a dibujarla: selecciónela y use Invertir, junto a Extremos en Propiedades. La cantidad, la condición, el color y la forma de la línea se mantienen y se interpretan en la nueva dirección; los codos que colocó siguen en su lugar.', 'whatsNew.v0280.drawn': 'Después de invertirla, el trazado automático, la línea junto a cada extremo y el lugar de la etiqueta pueden cambiar.', 'whatsNew.v0280.refused': 'Una inversión que no se puede hacer, como una que haría terminar la conexión en un nodo sin entrada o dejaría sin efecto una conexión de estado, no se aplica, y se muestra el motivo.', + 'whatsNew.v0281.labelBends': 'La etiqueta de una conexión ahora se aparta de los codos que colocó, donde la línea tiene espacio, así que las asas de los codos ya no la tapan al seleccionar la conexión. En una línea corta con codos muy juntos todavía puede quedar sobre uno.', + 'whatsNew.v0281.stable': 'El lugar de una etiqueta depende de su línea y de los codos que tiene, no de la selección: seleccionar una conexión, deseleccionarla o hacer zoom deja la etiqueta donde está.', + 'whatsNew.v0281.nearby': 'En un diagrama denso, una etiqueta que se aparta de un codo también puede mover la etiqueta o la línea de una conexión cercana.', } 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 44a1d3e4..f416157d 100644 --- a/src/i18n/locales/fr/ui.ts +++ b/src/i18n/locales/fr/ui.ts @@ -674,6 +674,9 @@ const ui = { 'whatsNew.v0280.reverse': 'Une connexion peut désormais être inversée sans la redessiner. Sélectionnez-la et appuyez sur Inverser, à côté d’Extrémités dans les Propriétés. Sa quantité, sa condition, sa couleur et la forme de sa ligne restent telles quelles et se lisent dans le nouveau sens, et les coudes que vous avez placés gardent leur place.', 'whatsNew.v0280.drawn': 'Après une inversion, le tracé automatique, la ligne près de chaque extrémité et la place de l’étiquette peuvent changer.', 'whatsNew.v0280.refused': 'Une inversion impossible, par exemple si la connexion arrivait à un nœud sans entrée ou si une connexion d’état perdait son effet, n’est pas appliquée, et la raison est affichée.', + 'whatsNew.v0281.labelBends': 'L’étiquette d’une connexion évite désormais les coudes que vous avez placés, là où la ligne a de la place, et les poignées des coudes ne la couvrent plus quand vous sélectionnez la connexion. Sur une ligne courte aux coudes rapprochés, elle peut encore se trouver sur l’un d’eux.', + 'whatsNew.v0281.stable': 'La place d’une étiquette dépend de sa ligne et de ses coudes, pas de la sélection. Sélectionner une connexion, la désélectionner ou zoomer laisse son étiquette où elle est.', + 'whatsNew.v0281.nearby': 'Dans un diagramme dense, une étiquette qui s’écarte d’un coude peut aussi déplacer l’étiquette ou la ligne d’une connexion voisine.', } satisfies Record export default ui diff --git a/src/i18n/locales/it/ui.ts b/src/i18n/locales/it/ui.ts index 2b8c81aa..02804f41 100644 --- a/src/i18n/locales/it/ui.ts +++ b/src/i18n/locales/it/ui.ts @@ -620,6 +620,9 @@ const ui = { 'whatsNew.v0280.reverse': 'Ora un collegamento si può invertire senza ridisegnarlo: selezionalo e premi Inverti accanto a Estremi nell’Ispettore. Quantità, condizione, colore e forma della linea restano com’erano e si leggono nella nuova direzione; gli angoli che hai messo restano al loro posto.', 'whatsNew.v0280.drawn': 'Dopo un’inversione, il percorso automatico, la linea vicino a ciascun estremo e la posizione dell’etichetta possono cambiare.', 'whatsNew.v0280.refused': 'Un’inversione che non si può fare, per esempio una che farebbe arrivare il collegamento a un nodo senza ingressi o lascerebbe senza effetto un collegamento di stato, non viene applicata, e il motivo viene mostrato.', + 'whatsNew.v0281.labelBends': 'L’etichetta di un collegamento ora sta lontana dagli angoli che hai messo, dove la linea ha spazio, così le maniglie degli angoli non la coprono più quando selezioni il collegamento. Su una linea corta con angoli vicini può ancora finire sopra uno di essi.', + 'whatsNew.v0281.stable': 'La posizione di un’etichetta dipende dalla sua linea e dagli angoli che ha, non dalla selezione: selezionare un collegamento, deselezionarlo o fare zoom lascia l’etichetta dov’è.', + 'whatsNew.v0281.nearby': 'In un diagramma fitto, un’etichetta che si allontana da un angolo può spostare anche l’etichetta o la linea di un collegamento vicino.', } satisfies Record export default ui diff --git a/src/i18n/locales/ja/ui.ts b/src/i18n/locales/ja/ui.ts index 399fee9b..719ed480 100644 --- a/src/i18n/locales/ja/ui.ts +++ b/src/i18n/locales/ja/ui.ts @@ -625,6 +625,9 @@ const ui = { 'whatsNew.v0280.reverse': '接続を描き直さずに向きを反転できるようになりました。接続を選んで、インスペクターの「端点」の横にある「反転」を押します。量、条件、色、線の形はそのままで新しい向きで解釈され、自分で置いた折れ点は元の位置に残ります。', 'whatsNew.v0280.drawn': '反転すると、自動の経路、両端付近の線、ラベルの位置は変わることがあります。', 'whatsNew.v0280.refused': '入力のないノードが終点になる場合や、状態の接続が効果を失う場合など、できない反転は適用されず、その理由が表示されます。', + 'whatsNew.v0281.labelBends': '接続のラベルは、線に余裕があれば自分で置いた折れ点を避けて置かれるようになり、接続を選んでも折れ点のハンドルがラベルを隠しません。折れ点が近くに集まった短い線では、折れ点の上に置かれることがあります。', + 'whatsNew.v0281.stable': 'ラベルの位置は選択ではなく、線とその上の折れ点で決まります。接続を選んでも、選択を解除しても、ズームしても、ラベルはその場所のままです。', + 'whatsNew.v0281.nearby': '接続が密集した図では、折れ点を避けて移動したラベルにつられて、近くの接続のラベルや線も動くことがあります。', } satisfies Record export default ui diff --git a/src/i18n/locales/ko/ui.ts b/src/i18n/locales/ko/ui.ts index 5501c58e..71a93fff 100644 --- a/src/i18n/locales/ko/ui.ts +++ b/src/i18n/locales/ko/ui.ts @@ -593,6 +593,9 @@ const ui = { 'whatsNew.v0280.reverse': '이제 연결을 다시 그리지 않고 방향을 뒤집을 수 있습니다. 연결을 선택하고 인스펙터의 ‘끝점’ 옆 ‘뒤집기’를 누르세요. 수량, 조건, 색, 선 모양은 그대로이며 새 방향 기준으로 해석되고, 직접 둔 꺾임점은 제자리에 남습니다.', 'whatsNew.v0280.drawn': '뒤집은 뒤에는 자동 경로, 양 끝 가까이의 선, 라벨 위치가 달라질 수 있습니다.', 'whatsNew.v0280.refused': '입력이 없는 노드에 연결이 도착하게 되거나 상태 연결이 효과를 잃게 되는 것처럼 할 수 없는 뒤집기는 적용되지 않고, 그 이유가 표시됩니다.', + 'whatsNew.v0281.labelBends': '이제 연결의 라벨은 선에 여유가 있으면 직접 둔 꺾임점을 피해 놓이므로, 연결을 선택해도 꺾임점 조절점이 라벨을 가리지 않습니다. 꺾임점이 가까이 모인 짧은 선에서는 여전히 꺾임점 위에 놓일 수 있습니다.', + 'whatsNew.v0281.stable': '라벨의 자리는 선택이 아니라 선과 그 위의 꺾임점으로 정해집니다. 연결을 선택하거나 선택을 해제하거나 확대·축소해도 라벨은 제자리에 있습니다.', + 'whatsNew.v0281.nearby': '연결이 촘촘한 다이어그램에서는 라벨이 꺾임점을 비키면서 가까운 다른 연결의 라벨이나 선도 함께 옮겨질 수 있습니다.', } satisfies Record export default ui diff --git a/src/i18n/locales/nl/ui.ts b/src/i18n/locales/nl/ui.ts index b5e8a08c..5ce6148e 100644 --- a/src/i18n/locales/nl/ui.ts +++ b/src/i18n/locales/nl/ui.ts @@ -601,6 +601,9 @@ const ui = { 'whatsNew.v0280.reverse': 'Een verbinding kun je nu omkeren zonder haar opnieuw te tekenen: selecteer haar en druk op Omkeren naast Uiteinden in de Inspector. Hoeveelheid, voorwaarde, kleur en lijnvorm blijven zoals ze waren en gelden in de nieuwe richting; de knikken die je plaatste blijven op hun plek.', 'whatsNew.v0280.drawn': 'Na het omkeren kunnen de automatische route, de lijn bij elk uiteinde en de plek van het etiket anders getekend worden.', 'whatsNew.v0280.refused': 'Een omkering die niet kan, zoals een die de verbinding zou laten eindigen bij een knooppunt zonder ingang of een toestandsverbinding zonder effect zou laten, wordt niet toegepast, en de reden wordt getoond.', + 'whatsNew.v0281.labelBends': 'Het etiket van een verbinding blijft nu weg van de knikken die je plaatste, waar de lijn ruimte heeft, zodat de knikgrepen het niet meer bedekken als je de verbinding selecteert. Op een korte lijn met knikken dicht bij elkaar kan het nog op een knik staan.', + 'whatsNew.v0281.stable': 'Waar een etiket staat, hangt af van de lijn en de knikken erop, niet van de selectie: een verbinding selecteren, deselecteren of zoomen laat het etiket staan waar het staat.', + 'whatsNew.v0281.nearby': 'In een vol diagram kan een etiket dat van een knik wegschuift ook het etiket of de lijn van een verbinding ernaast verplaatsen.', } as const export default ui diff --git a/src/i18n/locales/pt-BR/ui.ts b/src/i18n/locales/pt-BR/ui.ts index 27e3b8e2..cb87ed64 100644 --- a/src/i18n/locales/pt-BR/ui.ts +++ b/src/i18n/locales/pt-BR/ui.ts @@ -606,6 +606,9 @@ const ui = { 'whatsNew.v0280.reverse': 'Agora uma ligação pode ser invertida sem desenhar a ligação de novo: selecione-a e use Inverter, ao lado de Extremidades no Inspetor. A quantidade, a condição, a cor e a forma da linha ficam como estavam e passam a ser lidas na nova direção; as dobras que colocou ficam no lugar.', 'whatsNew.v0280.drawn': 'Depois de uma inversão, a rota automática, a linha perto de cada extremidade e o lugar do rótulo podem mudar.', 'whatsNew.v0280.refused': 'Uma inversão que não pode ser feita, como uma que faria a ligação terminar em um nó sem entrada ou deixaria uma ligação de estado sem efeito, não é aplicada, e o motivo é mostrado.', + 'whatsNew.v0281.labelBends': 'O rótulo de uma ligação agora fica longe das dobras que colocou, onde a linha tem espaço, e as alças das dobras deixam de cobri-lo quando a ligação é selecionada. Em uma linha curta com dobras muito próximas, ele ainda pode ficar sobre uma delas.', + 'whatsNew.v0281.stable': 'O lugar de um rótulo depende da linha e das dobras que ela tem, não da seleção: selecionar uma ligação, desmarcá-la ou fazer zoom deixa o rótulo onde está.', + 'whatsNew.v0281.nearby': 'Em um diagrama denso, um rótulo que se afasta de uma dobra também pode mover o rótulo ou a linha de uma ligação próxima.', } 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 0b29647c..a82e0344 100644 --- a/src/i18n/locales/pt-PT/ui.ts +++ b/src/i18n/locales/pt-PT/ui.ts @@ -620,6 +620,9 @@ const ui = { 'whatsNew.v0280.reverse': 'Agora uma ligação pode ser invertida sem desenhar a ligação de novo: selecione-a e use Inverter, ao lado de Extremidades no Inspetor. A quantidade, a condição, a cor e a forma da linha ficam como estavam e passam a ser lidas na nova direção; as dobras que colocou ficam no lugar.', 'whatsNew.v0280.drawn': 'Depois de uma inversão, a rota automática, a linha perto de cada extremidade e o lugar do rótulo podem mudar.', 'whatsNew.v0280.refused': 'Uma inversão que não pode ser feita, como uma que faria a ligação terminar em um nó sem entrada ou deixaria uma ligação de estado sem efeito, não é aplicada, e o motivo é mostrado.', + 'whatsNew.v0281.labelBends': 'O rótulo de uma ligação agora fica longe das dobras que colocou, onde a linha tem espaço, e as alças das dobras deixam de cobri-lo quando a ligação é selecionada. Em uma linha curta com dobras muito próximas, ele ainda pode ficar sobre uma delas.', + 'whatsNew.v0281.stable': 'O lugar de um rótulo depende da linha e das dobras que ela tem, não da seleção: selecionar uma ligação, desmarcá-la ou fazer zoom deixa o rótulo onde está.', + 'whatsNew.v0281.nearby': 'Em um diagrama denso, um rótulo que se afasta de uma dobra também pode mover o rótulo ou a linha de uma ligação próxima.', } 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 9f22e19c..d8b4b44f 100644 --- a/src/i18n/locales/ru/ui.ts +++ b/src/i18n/locales/ru/ui.ts @@ -673,6 +673,9 @@ const ui = { 'whatsNew.v0280.reverse': 'Теперь направление связи можно развернуть, не рисуя её заново: выделите связь и нажмите «Развернуть» рядом с разделом «Концы» в инспекторе. Количество, условие, цвет и форма линии остаются прежними и читаются в новом направлении, а поставленные вами изломы остаются на своих местах.', 'whatsNew.v0280.drawn': 'После разворота автоматический маршрут, линия у каждого конца и место метки могут измениться.', 'whatsNew.v0280.refused': 'Разворот, который невозможен, например если связь закончилась бы в узле без входа или связь состояния потеряла бы действие, не применяется, и показывается причина.', + 'whatsNew.v0281.labelBends': 'Метка связи теперь держится в стороне от поставленных вами изломов, где на линии есть место, поэтому маркеры изломов больше не закрывают её, когда связь выделена. На короткой линии с близко расположенными изломами она всё ещё может оказаться на одном из них.', + 'whatsNew.v0281.stable': 'Место метки зависит от её линии и изломов на ней, а не от выделения: выделение связи, снятие выделения или масштабирование не сдвигают метку.', + 'whatsNew.v0281.nearby': 'На плотной схеме метка, которая отходит от излома, может сдвинуть и метку или линию соседней связи.', } 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 7f255aa9..e4759c70 100644 --- a/src/i18n/locales/th/ui.ts +++ b/src/i18n/locales/th/ui.ts @@ -665,6 +665,9 @@ const ui = { 'whatsNew.v0280.reverse': 'ตอนนี้กลับทิศเส้นเชื่อมได้โดยไม่ต้องวาดใหม่ เลือกเส้นเชื่อมแล้วกด “กลับทิศ” ข้าง “ปลายเส้น” ในตัวตรวจสอบ จำนวน เงื่อนไข สี และรูปเส้นยังคงเดิมและอ่านตามทิศทางใหม่ ส่วนจุดหักมุมที่คุณวางไว้ยังอยู่ที่เดิม', 'whatsNew.v0280.drawn': 'หลังกลับทิศ เส้นทางอัตโนมัติ เส้นช่วงใกล้ปลายแต่ละด้าน และตำแหน่งของป้ายกำกับอาจเปลี่ยนไป', 'whatsNew.v0280.refused': 'การกลับทิศที่ทำไม่ได้ เช่น ทำให้เส้นเชื่อมไปสิ้นสุดที่โหนดที่ไม่มีขาเข้า หรือทำให้เส้นเชื่อมสถานะไม่มีผล จะไม่ถูกนำไปใช้ และจะแสดงเหตุผล', + 'whatsNew.v0281.labelBends': 'ตอนนี้ป้ายกำกับของเส้นเชื่อมจะหลบจุดหักมุมที่คุณวางไว้เมื่อเส้นมีที่ว่าง จุดจับของจุดหักมุมจึงไม่บังป้ายกำกับเมื่อเลือกเส้นเชื่อม บนเส้นสั้นที่จุดหักมุมอยู่ใกล้กัน ป้ายกำกับยังอาจอยู่บนจุดหักมุมได้', + 'whatsNew.v0281.stable': 'ตำแหน่งของป้ายกำกับขึ้นกับเส้นและจุดหักมุมบนเส้น ไม่ใช่การเลือก การเลือกเส้นเชื่อม การยกเลิกเลือก หรือการซูม จะไม่ทำให้ป้ายกำกับย้าย', + 'whatsNew.v0281.nearby': 'ในแผนภาพที่หนาแน่น ป้ายกำกับที่ขยับหลบจุดหักมุมอาจทำให้ป้ายกำกับหรือเส้นของเส้นเชื่อมที่อยู่ใกล้ขยับตามด้วย', } 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 66d15d22..9e785789 100644 --- a/src/i18n/locales/tr/ui.ts +++ b/src/i18n/locales/tr/ui.ts @@ -659,6 +659,9 @@ const ui = { 'whatsNew.v0280.reverse': 'Bir bağlantı artık yeniden çizilmeden ters çevrilebilir: bağlantıyı seçin ve İnceleyici’de «Uçlar»ın yanındaki «Ters çevir»e basın. Miktar, koşul, renk ve çizgi biçimi olduğu gibi kalır ve yeni yöne göre okunur; yerleştirdiğiniz kırılma noktaları yerinde kalır.', 'whatsNew.v0280.drawn': 'Ters çevirdikten sonra otomatik güzergâh, her ucun yanındaki çizgi ve etiketin yeri değişebilir.', 'whatsNew.v0280.refused': 'Yapılamayan bir ters çevirme, örneğin bağlantıyı girişi olmayan bir düğümde bitirecek ya da bir durum bağlantısını etkisiz bırakacaksa, uygulanmaz ve nedeni gösterilir.', + 'whatsNew.v0281.labelBends': 'Bir bağlantının etiketi artık çizgide yer olduğunda yerleştirdiğiniz kırılma noktalarından uzak durur, böylece bağlantıyı seçtiğinizde kırılma noktası tutamaçları onu örtmez. Kırılma noktaları birbirine yakın kısa bir çizgide yine de birinin üzerinde kalabilir.', + 'whatsNew.v0281.stable': 'Bir etiketin yeri seçime değil, çizgisine ve üzerindeki kırılma noktalarına bağlıdır: bağlantıyı seçmek, seçimi kaldırmak ya da yakınlaştırmak etiketi yerinden oynatmaz.', + 'whatsNew.v0281.nearby': 'Yoğun bir diyagramda, bir kırılma noktasından uzaklaşan etiket yakındaki bir bağlantının etiketini ya da çizgisini de kaydırabilir.', } 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 11b8576e..570b4d96 100644 --- a/src/i18n/locales/vi/ui.ts +++ b/src/i18n/locales/vi/ui.ts @@ -606,6 +606,9 @@ const ui = { 'whatsNew.v0280.reverse': 'Giờ có thể đảo chiều một liên kết mà không phải vẽ lại: chọn liên kết rồi nhấn Đảo chiều bên cạnh mục Đầu mút trong Bảng thuộc tính. Lượng, điều kiện, màu và dạng đường vẫn giữ nguyên và được hiểu theo chiều mới; các điểm gấp bạn đã đặt vẫn ở chỗ cũ.', 'whatsNew.v0280.drawn': 'Sau khi đảo chiều, đường đi tự động, đoạn gần mỗi đầu và vị trí của nhãn có thể thay đổi.', 'whatsNew.v0280.refused': 'Một lần đảo chiều không thể thực hiện, chẳng hạn khi liên kết sẽ kết thúc ở một nút không có đầu vào hoặc một liên kết trạng thái sẽ mất tác dụng, sẽ không được áp dụng và lý do sẽ được hiển thị.', + 'whatsNew.v0281.labelBends': 'Giờ nhãn của một liên kết tránh các điểm gấp bạn đã đặt ở những chỗ đường còn chỗ, nên tay nắm của điểm gấp không còn che nhãn khi bạn chọn liên kết. Trên một đường ngắn có các điểm gấp sát nhau, nhãn vẫn có thể nằm trên một điểm gấp.', + 'whatsNew.v0281.stable': 'Vị trí của nhãn phụ thuộc vào đường và các điểm gấp trên đường, không phụ thuộc vào việc chọn: chọn liên kết, bỏ chọn hay thu phóng đều giữ nhãn ở chỗ cũ.', + 'whatsNew.v0281.nearby': 'Trong một sơ đồ dày đặc, một nhãn dịch ra khỏi điểm gấp cũng có thể làm dịch nhãn hoặc đường của một liên kết gần đó.', } satisfies Record export default ui diff --git a/src/i18n/locales/zh-Hans/ui.ts b/src/i18n/locales/zh-Hans/ui.ts index cccce789..de4c62b6 100644 --- a/src/i18n/locales/zh-Hans/ui.ts +++ b/src/i18n/locales/zh-Hans/ui.ts @@ -598,6 +598,9 @@ const ui = { 'whatsNew.v0280.reverse': '现在无需重画连线即可反转它的方向:选中连线,在属性面板中按“端点”旁边的“反转”。数量、条件、颜色和线形保持不变,并按新的方向解读;你放置的拐点留在原处。', 'whatsNew.v0280.drawn': '反转后,自动走线、两端附近的线段和标签位置可能会变化。', 'whatsNew.v0280.refused': '无法进行的反转,例如会让连线终止于没有输入的节点,或会让状态连线失去效果,不会被应用,并会显示原因。', + 'whatsNew.v0281.labelBends': '现在只要线上有空间,连线的标签就会避开你放置的拐点,选中连线时拐点手柄不再遮住它。在拐点挨得很近的短线上,标签仍可能落在拐点上。', + 'whatsNew.v0281.stable': '标签的位置取决于连线和线上的拐点,而不是选择状态:选中连线、取消选择或缩放都不会让标签移动。', + 'whatsNew.v0281.nearby': '在连线密集的图中,避开拐点而移动的标签也可能让附近连线的标签或线段一起移动。', } satisfies Record export default ui diff --git a/src/i18n/locales/zh-Hant/ui.ts b/src/i18n/locales/zh-Hant/ui.ts index 4ec9a62a..00406e2c 100644 --- a/src/i18n/locales/zh-Hant/ui.ts +++ b/src/i18n/locales/zh-Hant/ui.ts @@ -623,6 +623,9 @@ const ui = { 'whatsNew.v0280.reverse': '現在不必重畫連線就能反轉它的方向:選取連線,在屬性面板中按「端點」旁的「反轉」。數量、條件、顏色和線形維持不變,並依新的方向解讀;你放置的拐點留在原處。', 'whatsNew.v0280.drawn': '反轉後,自動走線、兩端附近的線段和標籤位置可能會改變。', 'whatsNew.v0280.refused': '無法進行的反轉,例如會讓連線終止於沒有輸入的節點,或會讓狀態連線失去效果,不會被套用,並會顯示原因。', + 'whatsNew.v0281.labelBends': '現在只要線上有空間,連線的標籤就會避開你放置的拐點,選取連線時拐點把手不再遮住它。在拐點靠得很近的短線上,標籤仍可能落在拐點上。', + 'whatsNew.v0281.stable': '標籤的位置取決於連線和線上的拐點,而不是選取狀態:選取連線、取消選取或縮放都不會讓標籤移動。', + 'whatsNew.v0281.nearby': '在連線密集的圖中,避開拐點而移動的標籤也可能讓附近連線的標籤或線段一起移動。', } satisfies Record export default ui diff --git a/src/i18n/nlCopy.test.ts b/src/i18n/nlCopy.test.ts index cb4a0619..2054aebb 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(1134) // 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(1137) // 0.28.1: +3 (the three 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: 1134, template: 196, frame: 7, module: 19 }) - expect(RUNTIME).toHaveLength(1356) + expect(per).toEqual({ catalog: 1137, template: 196, frame: 7, module: 19 }) + expect(RUNTIME).toHaveLength(1359) }) 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 5de0eeeb..bd96743b 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(1134) // 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(1137) // 0.28.1: +3 (the three 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 24ea6762..23a2a778 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(1134) // 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(1137) // 0.28.1: +3 (the three 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 6216b1d3..04c98f25 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(1134) // 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(1137) // 0.28.1: +3 (the three 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(1134) + expect(RUNTIME.filter(([s]) => s === 'catalog')).toHaveLength(1137) 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 8a821f23..086d5671 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(1134) // 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(1137) // 0.28.1: +3 (the three 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 8a2ebcda..3fad6028 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(1134) // 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(1137) // 0.28.1: +3 (the three 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(1134) + expect(RUNTIME.filter(([s]) => s === 'catalog')).toHaveLength(1137) 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 ae89511f..0935cb1e 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 routed label keeps off its own connection's stored bend points where + // its line has room (docs/edge-routing.md §ER14.3, share notes 50 and 52); + // the date is the day it is deployed, set right before the merge + id: 'release:0.28.1', + version: '0.28.1', + date: '2026-10-14', + items: ['whatsNew.v0281.labelBends', 'whatsNew.v0281.stable', 'whatsNew.v0281.nearby'], + }, { // line editing, unit 2 (docs/edge-routing.md §ER19, share // connection-editing-2 notes 48–49): reverse a connection from the Ends From 3b7f96ac182db3c9be207cd424417f5a77214267 Mon Sep 17 00:00:00 2001 From: Hanrim <148833226+MerciHanrim@users.noreply.github.com> Date: Wed, 14 Oct 2026 13:33:19 +0900 Subject: [PATCH 3/7] feat(templates): Early MMO leaves the Templates menu and stays a developer stress fixture The Early MMO progression entry keeps its place in TEMPLATES with a new `menu: false` field; the desktop Templates menu and the phone's More -> Templates sheet list MENU_TEMPLATES, so they no longer offer it. Its file, fixture, scripts, translations, the official-label locale switch and its tests stay. A document made from it still opens, by Import, autosave or a share link. Tests keep their purpose: - the menu's first screen and language checks (desktop and phone) moved to Coffee and the gacha banner, including the overlay, re-seed, rename, reload, undo, Timeline legend and boot-locale cases, and the de / fr / es-ES localized Template checks; - the menu checks now also say MMO is not offered, in EN and KO, desktop and phone; - MMO's own behaviour and performance (recommended config, edit lock, Timeline series, deterministic run, identities, round trip, the bulk locale-switch geometry, the production locale-switch alignment) are tested by opening its file through the real file input; - the colour-view visual check drops the MMO case and its light and dark baselines and their policy entries; Coffee and Gacha stay. Docs: the examples table and section, the MMO design and layout docs, the product-direction note and diagram-layout DL5.5 say MMO is no longer in the menu; past CHANGELOG and release records are unchanged. --- docs/diagram-layout.md | 2 +- docs/example-mmo-progression.md | 11 ++ docs/mmo-multilingual-layout.md | 7 + docs/product-direction.md | 5 + e2e/dense-graph-pan.spec.ts | 3 +- e2e/dist.spec.ts | 58 +++--- e2e/document-boundary.spec.ts | 8 +- e2e/flow-colour-views-visual.spec.ts | 2 +- ...views-template-mmo-dark-chromium-win32.png | Bin 38218 -> 0 bytes ...iews-template-mmo-light-chromium-win32.png | Bin 40327 -> 0 bytes e2e/flow-colour-views.spec.ts | 2 +- e2e/gacha-banner-zones.spec.ts | 9 +- e2e/help-menu-readability.spec.ts | 4 +- e2e/i18n-de.spec.ts | 18 +- e2e/i18n-es-es.spec.ts | 10 +- e2e/i18n-fr.spec.ts | 12 +- e2e/i18n-lazy.spec.ts | 31 +-- e2e/minimap-responsive.spec.ts | 49 ++--- e2e/mmo-progression.spec.ts | 119 +++++++----- e2e/mobile.spec.ts | 3 +- e2e/pwa.spec.ts | 5 +- e2e/support/snapshot-policy.ts | 2 - e2e/template-label-overlay.spec.ts | 182 +++++++++++------- e2e/template-load-viewport.spec.ts | 63 +++--- examples/README.md | 26 ++- src/components/Templates.tsx | 4 +- src/components/mobile/MobileMoreMenu.tsx | 4 +- src/model/templates.menu.test.ts | 20 ++ src/model/templates.ts | 17 ++ 29 files changed, 412 insertions(+), 264 deletions(-) delete mode 100644 e2e/flow-colour-views-visual.spec.ts-snapshots/flow-views-template-mmo-dark-chromium-win32.png delete mode 100644 e2e/flow-colour-views-visual.spec.ts-snapshots/flow-views-template-mmo-light-chromium-win32.png create mode 100644 src/model/templates.menu.test.ts diff --git a/docs/diagram-layout.md b/docs/diagram-layout.md index 1ef04b1b..c6c16697 100644 --- a/docs/diagram-layout.md +++ b/docs/diagram-layout.md @@ -94,7 +94,7 @@ The Controls rail's **Tidy to grid** re-places the current document by the DL2.2 - DL5.3 A saved frame travels with the nodes it held (centre inside it before the move): it is rebuilt around them, keeping its own padding on each side and at least 24 px (§CR17), snapped outward. No frame cuts a node, by the widest boxes. Two frames that stood apart keep at least 32 px between them (`FRAME_GAP`; a frame's title sits in that gap, and two margins alone would make neighbouring frames touch): space is inserted as in §DL2 — every node beyond the nearer frame's far edge moves by the same grid step — so no order is reversed and no clearance shrinks. - DL5.4 Every connection is Auto orthogonal. A Manual exception (bend points) is used only where placement cannot resolve a case, each recorded with its connection id and reason; none is needed: in all 18 languages no route is blocked or on the outer corridor and no fan overlaps past its stub (measured 2026-10-10). - DL5.5 The menu-open framings (`initialView`): - - MMO puts readability before the whole start path, and the core start nodes before the zoom: its rect is sized for about 1.05 at 1600 x 1000, inside the Controls rail's reserved column, and its `keep` rect (§DL5.6) caps the zoom so every core start node opens whole in every language — Character creation, Active character, Starter encounters, Starter Lv 1–5. At 1600 x 1000 it opens at 1.040 (node text 12.5 px smallest, 14.6 px median; Thai Starter Lv 1–5, the widest, ends 8 px inside the pane, above the minimap); at 1280 x 800 at 0.725, or 0.772 with the minimap collapsed. Later paths may start off screen; Reset view and the minimap show the whole graph. + - MMO puts readability before the whole start path, and the core start nodes before the zoom: its rect is sized for about 1.05 at 1600 x 1000, inside the Controls rail's reserved column, and its `keep` rect (§DL5.6) caps the zoom so every core start node opens whole in every language — Character creation, Active character, Starter encounters, Starter Lv 1–5. At 1600 x 1000 it opens at 1.040 (node text 12.5 px smallest, 14.6 px median; Thai Starter Lv 1–5, the widest, ends 8 px inside the pane, above the minimap); at 1280 x 800 at 0.725, or 0.772 with the minimap collapsed. Later paths may start off screen; Reset view and the minimap show the whole graph. Since 0.28.1 MMO is not in the Templates menu (a developer stress fixture opened by Import), so this view, applied on a menu open only, no longer applies in the product; it stays in the entry. - Coffee opens on its operating flow — the five daily levers, the supply and the roasting & sales frames — at about 0.5 in a 1280 x 800 window (L1 with room, never at the 0.45 edge; floor 0.46); the forecast metrics may start off screen. - Gacha keeps its overview: the comparison row and the Free zone, at its 0.45 floor. - DL5.6 `keep` — the nodes that must open whole (`InitialView.keep`, `viewportForRect` in `src/components/canvasFit.ts`). An `initialView` MAY carry a `keep` rect: the core start nodes at their widest box over the 18 languages (`templateBoxes.json`), the tight union of those boxes (pinned by `src/model/templates.initialView.test.ts`). The zoom is the rect's fit, capped at the largest zoom that shows `keep` inside the pane (8 px from its right and bottom edges, clear of the Controls rail); the minimap's corner (224 x 176 px with its margin) counts only when `keep` would overlap it on BOTH axes, and then `keep` clears it by whichever side keeps the larger zoom. So a top row may run past the minimap's column above it, and a collapsed minimap never lets the zoom grow past what `keep` allows. The readability floor (`minZoom`) still wins on a pane too small for `keep` (a phone opens at the floor, as before). Only MMO carries `keep`; the left alignment, the vertical centring and the other Templates' views are unchanged. diff --git a/docs/example-mmo-progression.md b/docs/example-mmo-progression.md index 03230ef4..c7e4aeea 100644 --- a/docs/example-mmo-progression.md +++ b/docs/example-mmo-progression.md @@ -10,6 +10,17 @@ invariants** in the acceptance conditions (§EM10.1). This is a **non-frozen** d marker — and merges as *settled design, implementation pending*, like [`docs/localization.md`](localization.md) and [`docs/guided-tour.md`](guided-tour.md). +> **0.28.1 (2026-10-14) — no longer in the Templates menu.** The example is now +> a **developer stress fixture**: it stays a Template entry in +> `src/model/templates.ts` with `menu: false` (so its translations and the +> §TLO11 locale switch still apply), keeps `examples/mmo-progression.json`, its +> fixture, its scripts and its tests, and is opened by **Import**. Templates ▾ +> and the phone's More → Templates no longer list it; a document made from it +> still opens. A change that only affects it does not block a release; a +> readability problem it shows is fixed only when a small case reproduces it. +> The design below, including the menu registration (§EM10), is the record of +> how it shipped. + The example is the **second follow-up** to the localization base (after the guided tour, before contextual inline help). It is a **product demo / Templates entry**, not a precision instrument and not a test oracle: it exists to show diff --git a/docs/mmo-multilingual-layout.md b/docs/mmo-multilingual-layout.md index a8d7be38..89a6512b 100644 --- a/docs/mmo-multilingual-layout.md +++ b/docs/mmo-multilingual-layout.md @@ -6,6 +6,13 @@ toolbar responsive PR). The bundled **MMO progression** template (97 nodes / 144 edges) has node-box overlaps and an unreadable initial camera; the problem is worst in JA but is **not JA-only**. +> **0.28.1 (2026-10-14).** MMO left the Templates menu and is a developer stress +> fixture opened by Import ([`docs/example-mmo-progression.md`](example-mmo-progression.md)). +> Its `initialView` (§MML3) is applied on a fresh menu open only, so it no +> longer applies in the product; it stays in the Template entry, and the +> menu-framing contract is checked on Coffee and the gacha banner instead. The +> layout measurements below are the record of the MMO pass. + ## MML0. What the measurement pass found All figures are **flow coordinates** (measured at zoom = 1, so screen px = flow diff --git a/docs/product-direction.md b/docs/product-direction.md index e6ed882e..5045aee0 100644 --- a/docs/product-direction.md +++ b/docs/product-direction.md @@ -143,6 +143,11 @@ own default open-state and its own messaging in the Templates menu.** That is the single messaging bug this table exists to prevent. - *Early MMO progression* stays an **Example**, stays edit-locked, is **not** "fixed" or demoted. It did its job: it verified the ceiling. + **Superseded 0.28.1 (2026-10-14):** it left the Templates menu and is now a + developer stress fixture — still a Template entry (`menu: false`) and still + edit-locked when opened, opened by Import, its documents still open. A change + that only affects it does not block a release + ([`docs/example-mmo-progression.md`](example-mmo-progression.md)). --- diff --git a/e2e/dense-graph-pan.spec.ts b/e2e/dense-graph-pan.spec.ts index a54f8ec6..583d60a5 100644 --- a/e2e/dense-graph-pan.spec.ts +++ b/e2e/dense-graph-pan.spec.ts @@ -2,7 +2,8 @@ import { readFileSync } from 'node:fs' import type { Page } from '@playwright/test' import { expect, importGraph, openApp, resetAll, test } from './support/loop' -/** the bundled Early-MMO Template — 97 nodes / 144 edges, real Bézier + +/** the Early-MMO document (a developer stress fixture since 0.28.1, read from + * its file) — 97 nodes / 144 edges, real Bézier + * orthogonal routing, real crossings. Used only for the dense-graph * edge-tap performance regression test below. */ const readMmoProgression = (): string => diff --git a/e2e/dist.spec.ts b/e2e/dist.spec.ts index 75b9dff9..7cd820dd 100644 --- a/e2e/dist.spec.ts +++ b/e2e/dist.spec.ts @@ -130,31 +130,38 @@ test.describe('production build (Cloudflare Pages shape)', () => { expect(bad, 'no failed or cross-origin requests').toEqual([]) }) - test('the "Early MMO progression" Template loads its bundled graph + recommended MC config', async ({ page }) => { + // The largest Template the menu offers (Early MMO, the previous subject here, + // left the menu in 0.28.1 and stays a developer stress fixture). + test('the "3-zone gacha banner comparison" Template loads its bundled graph + recommended MC config', async ({ page }) => { const { bad } = await openProd(page) + const GACHA = JSON.parse(readFileSync(new URL('../examples/gacha-banner-zones.json', import.meta.url), 'utf8')) as { + nodes: unknown[] + recommendedRunConfig: { runs: number; steps: number; baseSeed: number } + } // Templates ▾ is the first `.menu` in the toolbar actions await page.locator('.toolbar__actions .menu').first().locator('> button').click() await page .locator('.toolbar__actions .menu') .first() - .locator('.menu__pop [role="menuitem"]', { hasText: 'Early MMO progression' }) + .locator('.menu__pop [role="menuitem"]', { hasText: '3-zone gacha banner comparison' }) .click() const confirm = page.locator('.mcdlg--confirm').getByRole('button', { name: /load template/i }) if (await confirm.isVisible().catch(() => false)) await confirm.click() - // the canonical examples/mmo-progression.json is 97 nodes - await expect(page.locator('.react-flow__node')).toHaveCount(97) + // every node of the canonical examples/gacha-banner-zones.json + await expect(page.locator('.react-flow__node')).toHaveCount(GACHA.nodes.length) // the file's recommendedRunConfig pre-fills the Monte-Carlo dialog + const rc = GACHA.recommendedRunConfig await page.locator('.pstrip__mc button', { hasText: 'Monte Carlo' }).click() const dlg = page.locator('.mcdlg[aria-labelledby="mcdlg-title"]') await expect(dlg).toBeVisible() const nums = dlg.locator('.mcdlg__field input[type="number"]') - await expect(nums.nth(0)).toHaveValue('200') - await expect(nums.nth(1)).toHaveValue('150') - await expect(nums.nth(2)).toHaveValue('1') - await expect(dlg.locator('.mcdlg__foot .btn--primary')).toHaveText('Run 200 runs') + await expect(nums.nth(0)).toHaveValue(String(rc.runs)) + await expect(nums.nth(1)).toHaveValue(String(rc.steps)) + await expect(nums.nth(2)).toHaveValue(String(rc.baseSeed)) + await expect(dlg.locator('.mcdlg__foot .btn--primary')).toHaveText(new RegExp(`^Run ${rc.runs.toLocaleString('en-US').replace(',', ',?')} runs$`)) await page.keyboard.press('Escape') expect(bad, 'no failed or cross-origin requests').toEqual([]) @@ -239,7 +246,11 @@ test.describe('production build (Cloudflare Pages shape)', () => { expect(bad, 'no failed or cross-origin requests').toEqual([]) }) - test('a bulk locale switch on the MMO Template keeps edge endpoints aligned with their real handle positions (no window.__loop)', async ({ + // 0.28.1 — Early MMO left the Templates menu (a developer stress fixture now): + // the document is opened from its FILE, as a user's own file is. It is still + // a Template entry, so its node names still follow a locale switch; the + // largest graph with parallel sets, it stays the subject of this check. + test('a bulk locale switch on the MMO document keeps edge endpoints aligned with their real handle positions (no window.__loop)', async ({ page, }) => { // The node structure (v0.26.0): the name sits OUTSIDE a fixed-height body, @@ -261,24 +272,27 @@ test.describe('production build (Cloudflare Pages shape)', () => { // shipped-language spellings (same pattern as e2e/i18n.spec.ts's // openSettings), not just the English one. const settingsBtn = page.locator('.toolbar__actions .menu > button', { hasText: /^(Settings|설정|設定)$/ }) - await settingsBtn.click() - await page.locator('.toolbar .lang-switch').click() - await page.locator('.lang-menu__item[data-locale="ko"]').click() - await expect.poll(() => page.evaluate(() => document.documentElement.lang)).toBe('ko') + const switchTo = async (locale: string) => { + await settingsBtn.click() + await page.locator('.toolbar .lang-switch').click() + await page.locator(`.lang-menu__item[data-locale="${locale}"]`).click() + await expect.poll(() => page.evaluate(() => document.documentElement.lang)).toBe(locale) + } + await switchTo('en') - await page.locator('.toolbar__actions .menu').first().locator('> button').click() - await page - .locator('.toolbar__actions .menu') - .first() - .locator('.menu__pop [role="menuitem"]', { hasText: '초반 MMO 성장' }) - .click() - // locale-independent: the confirm dialog's primary action button by - // class, not by text (the label is Korean here, since locale is 'ko') + // open the MMO document from its file (the real file input) + const MMO_TEXT = readFileSync(new URL('../examples/mmo-progression.json', import.meta.url), 'utf8') + await page.locator('input[type=file][accept*="json"]').first().setInputFiles({ name: 'mmo-progression.json', mimeType: 'application/json', buffer: Buffer.from(MMO_TEXT, 'utf8') }) + // locale-independent: the confirm dialog's primary action button by class const confirm = page.locator('.mcdlg--confirm .btn--primary') if (await confirm.isVisible().catch(() => false)) await confirm.click() await expect(page.locator('.react-flow__node')).toHaveCount(97) + await expect(page.locator('.react-flow__node', { hasText: 'Level' }).first()).toBeVisible() + + // into Korean: the names follow (the document is still a Template entry's) + await switchTo('ko') await page.evaluate(() => document.fonts.ready) - // representative Korean node label, confirming the KO Template actually loaded + // representative Korean node label, confirming the KO names are drawn await expect(page.locator('.react-flow__node', { hasText: '레벨' }).first()).toBeVisible() // issue #344 §ER14.5 — the routed connections are drawn once their first // generation commits; the canvas is aria-busy until then (the DOM side of diff --git a/e2e/document-boundary.spec.ts b/e2e/document-boundary.spec.ts index 910acafc..6762b18b 100644 --- a/e2e/document-boundary.spec.ts +++ b/e2e/document-boundary.spec.ts @@ -93,10 +93,10 @@ async function storageAction(page: Page, action: 'to-temporary' | 'delete-work') } test.describe('#334 — a new document starts unlocked, with no history', () => { - test('File → New from the locked MMO template: unlocked, empty, Undo and Redo disabled; a palette click edits; a reload keeps it unlocked', async ({ page }) => { + test('File → New from the locked gacha template (Early MMO left the menu in 0.28.1): unlocked, empty, Undo and Redo disabled; a palette click edits; a reload keeps it unlocked', async ({ page }) => { await openApp(page) await resetAll(page) - await openTemplate(page, 'Early MMO progression') + await openTemplate(page, '3-zone gacha banner comparison') await expect(lockBtn(page)).toHaveAttribute('aria-pressed', 'true') await fileNew(page) @@ -112,7 +112,7 @@ test.describe('#334 — a new document starts unlocked, with no history', () => test('a temporary session without the document, from a locked template: unlocked, no history', async ({ page }) => { await openApp(page) await resetAll(page) - await openTemplate(page, 'Early MMO progression') + await openTemplate(page, '3-zone gacha banner comparison') await expect(lockBtn(page)).toHaveAttribute('aria-pressed', 'true') await storageAction(page, 'to-temporary') expect(await nodeCount(page)).toBe(0) @@ -122,7 +122,7 @@ test.describe('#334 — a new document starts unlocked, with no history', () => test('Delete work data, from a locked template: unlocked, no history', async ({ page }) => { await openApp(page) await resetAll(page) - await openTemplate(page, 'Early MMO progression') + await openTemplate(page, '3-zone gacha banner comparison') await expect(lockBtn(page)).toHaveAttribute('aria-pressed', 'true') await storageAction(page, 'delete-work') expect(await nodeCount(page)).toBe(0) diff --git a/e2e/flow-colour-views-visual.spec.ts b/e2e/flow-colour-views-visual.spec.ts index 91df2a54..1b437129 100644 --- a/e2e/flow-colour-views-visual.spec.ts +++ b/e2e/flow-colour-views-visual.spec.ts @@ -66,10 +66,10 @@ test('forced colours: the minimap and the timeline keep their kind and series co await expect(page.locator('.timeline__panel')).toHaveScreenshot(...snap(page, 'flow-views-forced-timeline')) }) +// the Templates the menu offers; Early MMO left the menu in 0.28.1 (Lumi: its two baselines removed) const TEMPLATES = [ { id: 'coffee', name: 'Coffee roastery operations flow', waitFor: 'cafe_retail_demand_kg' }, { id: 'gacha', name: '3-zone gacha banner comparison', waitFor: 'cmp1_hit_rate_free' }, - { id: 'mmo', name: 'Early MMO progression (levels 1–15)', waitFor: 'char_creation' }, ] // the stored colours are drawn unchanged in both themes (D-1), so the dark // shots hold them against the dark structure lines, labels and frames diff --git a/e2e/flow-colour-views-visual.spec.ts-snapshots/flow-views-template-mmo-dark-chromium-win32.png b/e2e/flow-colour-views-visual.spec.ts-snapshots/flow-views-template-mmo-dark-chromium-win32.png deleted file mode 100644 index 07c40ed66cdc76ae10b6165445a9da19b253ce7f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 38218 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 HfB!EKij9PcIz95^HQ|OzefgUJ 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 diff --git a/e2e/flow-colour-views-visual.spec.ts-snapshots/flow-views-template-mmo-light-chromium-win32.png b/e2e/flow-colour-views-visual.spec.ts-snapshots/flow-views-template-mmo-light-chromium-win32.png deleted file mode 100644 index 5a4dee662710fccf09d56e5a3e79d9b6d2901109..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 40327 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 bXPZMHh5H39+~~A@8v^O;m}r;YaC-J1nvZD; diff --git a/e2e/flow-colour-views.spec.ts b/e2e/flow-colour-views.spec.ts index 3ea23ef3..fac5ab20 100644 --- a/e2e/flow-colour-views.spec.ts +++ b/e2e/flow-colour-views.spec.ts @@ -220,10 +220,10 @@ test.describe('flow colour — the Inspector offers each colour once (FC-5)', () }) test.describe('flow colour — the bundled Templates (FC-6)', () => { + // the Templates the menu offers; Early MMO left the menu in 0.28.1 (its colours stay checked from the file, src/engine/templateFlowColours.test.ts) const TEMPLATES = [ { name: 'Coffee roastery operations flow', waitFor: 'cafe_retail_demand_kg', file: 'coffee-roastery.json' }, { name: '3-zone gacha banner comparison', waitFor: 'cmp1_hit_rate_free', file: 'gacha-banner-zones.json' }, - { name: 'Early MMO progression (levels 1–15)', waitFor: 'char_creation', file: 'mmo-progression.json' }, ] for (const t of TEMPLATES) { test(`${t.name}: opens with its three flow colours, on the canvas and the minimap`, async ({ page }) => { diff --git a/e2e/gacha-banner-zones.spec.ts b/e2e/gacha-banner-zones.spec.ts index b27907ce..1c9297de 100644 --- a/e2e/gacha-banner-zones.spec.ts +++ b/e2e/gacha-banner-zones.spec.ts @@ -120,7 +120,7 @@ test.describe('3-zone gacha banner comparison Template', () => { await expect(page.locator('.canvas.canvas--locked')).toBeVisible() }) - test('menu order: Coffee, then MMO, then Gacha (Hanrim, 2026-09-13 reorder)', async ({ page }) => { + test('menu order: Coffee, then Gacha, last (Hanrim, 2026-09-13 reorder); Early MMO is not offered (0.28.1)', async ({ page }) => { await openApp(page) await resetAll(page) await templatesBtn(page).click() @@ -129,11 +129,12 @@ test.describe('3-zone gacha banner comparison Template', () => { .locator('.menu__pop [role="menuitem"] .menu__name') .allInnerTexts() const coffeeIdx = names.findIndex((n) => n.includes('Coffee roastery')) - const mmoIdx = names.findIndex((n) => n.includes('Early MMO progression')) const gachaIdx = names.findIndex((n) => n === EN_NAME) expect(coffeeIdx).toBeGreaterThanOrEqual(0) - expect(mmoIdx).toBeGreaterThan(coffeeIdx) - expect(gachaIdx).toBeGreaterThan(mmoIdx) + expect(gachaIdx).toBeGreaterThan(coffeeIdx) + expect(gachaIdx).toBe(names.length - 1) + // a developer stress fixture now, not a user Template (Hanrim / Lumi, 2026-10-14) + expect(names.some((n) => n.includes('Early MMO progression'))).toBe(false) }) test('runs exactly pulls_per_zone + 1 pull-steps, not yet ended: conserves per zone', async ({ page }) => { diff --git a/e2e/help-menu-readability.spec.ts b/e2e/help-menu-readability.spec.ts index d39eaffd..3f187ce2 100644 --- a/e2e/help-menu-readability.spec.ts +++ b/e2e/help-menu-readability.spec.ts @@ -150,7 +150,7 @@ test('KO Templates menu: 340px, every description whole and inside the 2-line cl expect(width, 'Templates opts into the wide popover').toBe(300) const blurbs = await measureOpenMenu(page, '.menu__blurb') - expect(blurbs.length, 'one description per template').toBe(5) + expect(blurbs.length, 'one description per template (0.28.1: Early MMO is not in the menu)').toBe(4) for (const b of blurbs) { expect(b.fontPx, `"${b.text}": font size`).toBeGreaterThanOrEqual(BODY_MIN_PX) expect(b.contrast, `"${b.text}": contrast`).toBeGreaterThanOrEqual(BODY_MIN_CONTRAST) @@ -220,7 +220,7 @@ test('a widened popover still fits inside a narrow desktop viewport', async ({ p test.describe('menu keyboard — Templates, Insert module and File', () => { for (const [label, name, count] of [ - ['Templates', /템플릿/, 5], + ['Templates', /템플릿/, 4], // 0.28.1: Early MMO is not in the menu ['Insert module', /모듈/, 4], ['File', /파일/, 7], ] as const) { diff --git a/e2e/i18n-de.spec.ts b/e2e/i18n-de.spec.ts index 7d6272e3..cfdb8a52 100644 --- a/e2e/i18n-de.spec.ts +++ b/e2e/i18n-de.spec.ts @@ -180,24 +180,26 @@ test('a bundled Template opens with German node labels', async ({ page }) => { const templates = page.locator('.toolbar__actions .menu').first() await templates.locator('> button').click() await expect(templates.locator('.menu__pop')).toBeVisible() - // by NAME, not position — the German name also proves the Templates menu - // itself is translated - await templates.locator('.menu__pop [role="menuitem"]', { hasText: 'MMO' }).click() + // by its German NAME, not position — which also proves the Templates menu + // itself is translated (Coffee: Early MMO, the previous subject, left the + // menu in 0.28.1) + await templates.locator('.menu__pop [role="menuitem"]', { hasText: 'Betriebsablauf einer Kaffeerösterei' }).click() const confirm = page.locator('.mcdlg--confirm .btn--primary') if (await confirm.isVisible().catch(() => false)) await confirm.click() + await expect + .poll(() => page.evaluate(() => (window as unknown as { __loop: Loop }).__loop.graph.getState().nodes.length)) + .toBeGreaterThan(20) const labels = await page.evaluate(() => (window as unknown as { __loop: Loop }).__loop.graph .getState() .nodes.map((n: any) => n.data.label as string), ) - expect(labels.length).toBeGreaterThan(50) - // `Gold` and `XP` are deliberately absent from this list: they are the - // same word in German, so finding them proves nothing either way - for (const no of ['Level', 'Elapsed steps', 'Reward', 'Rare loot', 'Clock']) + for (const no of ['Green bean stock', 'Roasted bean stock', 'Green bean delivery', 'Projected daily revenue', 'End-of-day leftover']) expect(labels, `"${no}" must not survive into a German document`).not.toContain(no) // and the overlay actually supplied German - expect(labels.some((l: string) => /[äöüÄÖÜß]/.test(l) || /ung\b|keit\b|punkte/i.test(l))).toBe(true) + for (const want of ['Bestand Rohkaffee', 'Bestand gerösteter Kaffee', 'Reste am Tagesende']) + expect(labels, `the German overlay must supply "${want}"`).toContain(want) }) // ------------------------------------------------------------------ 7 diff --git a/e2e/i18n-es-es.spec.ts b/e2e/i18n-es-es.spec.ts index 7c75792b..6e65463b 100644 --- a/e2e/i18n-es-es.spec.ts +++ b/e2e/i18n-es-es.spec.ts @@ -191,19 +191,21 @@ test('the labels that differ from es-419 differ in the product', async ({ page } .locator('.toolbar__actions .menu', { has: page.getByRole('button', { name: 'Plantillas' }) }) .getByRole('button', { name: 'Plantillas' }) .click() - await page.locator('.menu__name', { hasText: 'Progresión temprana de MMO' }).click() + // Coffee (Early MMO, the previous subject, left the menu in 0.28.1): its one + // node name that differs from es-419 is the roasted supply margin + await page.locator('.menu__name', { hasText: 'Flujo de operaciones de un tostador de café' }).click() await expect .poll(() => page.evaluate(() => [...document.querySelectorAll('.nodef__name')].map((t) => t.textContent?.trim()), ), ) - .toContain('Puntuación de equipo') + .toContain('Margen del suministro de tostado') const titles = await page.evaluate(() => [...document.querySelectorAll('.nodef__name')].map((t) => t.textContent?.trim()), ) - expect(titles, 'the Latin American score word must not appear here').not.toContain( - 'Puntaje de equipo', + expect(titles, 'the Latin American supply word must not appear here').not.toContain( + 'Margen del abasto de tostado', ) }) diff --git a/e2e/i18n-fr.spec.ts b/e2e/i18n-fr.spec.ts index 04ec7aa5..51f41023 100644 --- a/e2e/i18n-fr.spec.ts +++ b/e2e/i18n-fr.spec.ts @@ -401,22 +401,26 @@ test('a bundled Template opens with French node labels', async ({ page }) => { const templates = page.locator('.toolbar__actions .menu').first() await templates.locator('> button').click() await expect(templates.locator('.menu__pop')).toBeVisible() + // Coffee, by its French name (Early MMO, the previous subject, left the menu + // in 0.28.1) await templates - .locator('.menu__pop [role="menuitem"]', { hasText: 'Progression MMO' }) + .locator('.menu__pop [role="menuitem"]', { hasText: 'Flux d’exploitation d’une torréfaction' }) .click() const confirm = page.locator('.mcdlg--confirm .btn--primary') if (await confirm.isVisible().catch(() => false)) await confirm.click() + await expect + .poll(() => page.evaluate(() => (window as unknown as { __loop: Loop }).__loop.graph.getState().nodes.length)) + .toBeGreaterThan(20) const labels = await page.evaluate(() => (window as unknown as { __loop: Loop }).__loop.graph .getState() .nodes.map((n: any) => n.data.label as string), ) - expect(labels.length).toBeGreaterThan(50) - for (const want of ['Niveau', 'Or', 'Pas écoulés', 'Récompense', 'Butin rare']) { + for (const want of ['Stock de café vert', 'Stock de café torréfié', 'Livraison de café vert', 'Chiffre d’affaires prévu du jour', 'Invendus de fin de journée']) { expect(labels, `the French overlay must supply "${want}"`).toContain(want) } - for (const no of ['Level', 'Gold', 'Elapsed steps', 'Reward']) { + for (const no of ['Green bean stock', 'Roasted bean stock', 'Green bean delivery', 'Projected daily revenue']) { expect(labels, `"${no}" must not survive into a French document`).not.toContain(no) } }) diff --git a/e2e/i18n-lazy.spec.ts b/e2e/i18n-lazy.spec.ts index 0bfa585d..7f9e8354 100644 --- a/e2e/i18n-lazy.spec.ts +++ b/e2e/i18n-lazy.spec.ts @@ -21,18 +21,19 @@ const i18nState = (page: Page) => const htmlLang = (page: Page) => page.evaluate(() => document.documentElement.lang) const stored = (page: Page) => page.evaluate(() => localStorage.getItem('loop-studio/ui-locale/1')) -const MMO = 'Early MMO progression (levels 1–15)' -async function openMmo(page: Page) { +// a Template the menu offers (Early MMO, the previous subject, left the menu in 0.28.1) +const COFFEE = 'Coffee roastery operations flow' +async function openCoffee(page: Page) { await page.locator('.toolbar__actions .menu').first().locator('> button').click() await page .locator('.toolbar__actions .menu') .first() - .locator('.menu__pop [role="menuitem"]', { hasText: MMO }) + .locator('.menu__pop [role="menuitem"]', { hasText: COFFEE }) .click() const confirm = page.locator('.mcdlg--confirm').getByRole('button', { name: /load template/i }) await confirm.waitFor({ state: 'visible', timeout: 1200 }).catch(() => {}) if (await confirm.count()) await confirm.click() - await expect(page.locator('.react-flow__node[data-id="char_creation"]')).toBeVisible() + await expect(page.locator('.react-flow__node[data-id="green_stock"]')).toBeVisible() } const nodeLabel = (page: Page, id: string) => page.locator(`.react-flow__node[data-id="${id}"] .nodef__name`).innerText() @@ -44,15 +45,15 @@ test.describe('locale lazy-loading — behaviour', () => { await openApp(page) await resetAll(page) expect(await loop(page)).toBe(true) - await openMmo(page) - expect(await nodeLabel(page, 'char_creation')).toBe('Character creation') + await openCoffee(page) + expect(await nodeLabel(page, 'green_stock')).toBe('Green bean stock') // drive the switch and sample lang + a node label the moment it settles await setLocale(page, 'ko') await expect.poll(() => htmlLang(page)).toBe('ko') // by the time is 'ko', the node label is already Korean — // never an intermediate English frame - expect(await nodeLabel(page, 'char_creation')).toBe('캐릭터 생성') + expect(await nodeLabel(page, 'green_stock')).toBe('생두 재고') expect((await i18nState(page)).loadError).toBeNull() }) @@ -96,7 +97,7 @@ test.describe('locale lazy-loading — behaviour', () => { }) => { await openApp(page) await resetAll(page) - await openMmo(page) + await openCoffee(page) // block BOTH halves of the ja load (dev module URLs) await page.route('**/i18n/locales/ja/**', (r) => r.abort()) await page.route('**/i18n/templateLabels/ja*', (r) => r.abort()) @@ -107,7 +108,7 @@ test.describe('locale lazy-loading — behaviour', () => { expect(st.activeLocale).toBe('en') expect(st.loadError?.code).toBe('ja') expect(await htmlLang(page)).toBe('en') - expect(await nodeLabel(page, 'char_creation')).toBe('Character creation') + expect(await nodeLabel(page, 'green_stock')).toBe('Green bean stock') // a successful switch clears the notice await page.unroute('**/i18n/locales/ja/**') @@ -127,22 +128,22 @@ test.describe('locale lazy-loading — behaviour', () => { }) => { await openApp(page) await resetAll(page) - await openMmo(page) - // #334 — MMO opens edit-locked; the rename below is a user edit + await openCoffee(page) + // the rename below is a user edit (the Template opens editable; unlock in case) await page.evaluate(() => (window as any).__loop.ui.getState().setCanvasLocked(false)) // rename one node via the store bridge await page.evaluate(() => { const g = (window as unknown as { __loop: L }).__loop.graph.getState() - const n = g.nodes.find((x: any) => x.id === 'char_creation') + const n = g.nodes.find((x: any) => x.id === 'green_stock') g.updateNodeData(n.id, { label: 'My start' }) }) await setLocale(page, 'ko') await expect.poll(() => htmlLang(page)).toBe('ko') - expect(await nodeLabel(page, 'char_creation')).toBe('My start') // user rename kept - expect(await nodeLabel(page, 'active_char')).toBe('활성 캐릭터') // official switched + expect(await nodeLabel(page, 'green_stock')).toBe('My start') // user rename kept + expect(await nodeLabel(page, 'green_delivery')).toBe('생두 입고') // official switched // one undo removes the rename, not "Korean → English half-state" await page.evaluate(() => (window as unknown as { __loop: L }).__loop.graph.getState().undo()) - expect(await nodeLabel(page, 'char_creation')).toBe('캐릭터 생성') + expect(await nodeLabel(page, 'green_stock')).toBe('생두 재고') }) }) diff --git a/e2e/minimap-responsive.spec.ts b/e2e/minimap-responsive.spec.ts index 3eaf1767..cb77dd55 100644 --- a/e2e/minimap-responsive.spec.ts +++ b/e2e/minimap-responsive.spec.ts @@ -158,27 +158,29 @@ test.describe('minimap — visible only when the pane can host it', () => { // `< 640 × 380` auto-hide. Collapsing / expanding never moves the camera; the // NEXT Template open frames against whatever the minimap state is then. -// §MML3 — MMO's Template.initialView.minZoom (src/model/templates.ts); a stable -// value also pinned by e2e/template-load-viewport.spec.ts. -const MMO_MIN_ZOOM = 0.6 -const MMO_NAME = 'Early MMO progression (levels 1–15)' +// §MML3 — the gacha Template's initialView.minZoom (src/model/templates.ts). Early +// MMO, the previous subject here, left the Templates menu in 0.28.1; the gacha +// banner comparison is the menu's large Template with a framed start, a stable +// value. +const TPL_MIN_ZOOM = 0.45 +const TPL_NAME = '3-zone gacha banner comparison' const uiState = (p: Page) => p.evaluate(() => (window as unknown as { __loop: { ui: { getState: () => { minimapCollapsed: boolean } } } }).__loop.ui.getState()) const toggleBtn = (p: Page) => p.locator('.minimap-toggle') -/** open MMO through the real Templates menu (the §MML3 framing path) */ -async function openMmoFromMenu(page: Page): Promise { +/** open the gacha Template through the real Templates menu (the §MML3 framing path) */ +async function openTemplateFromMenu(page: Page): Promise { await page.locator('.toolbar__actions .menu').first().locator('> button').click() await page .locator('.toolbar__actions .menu') .first() - .locator('.menu__pop [role="menuitem"]', { hasText: MMO_NAME }) + .locator('.menu__pop [role="menuitem"]', { hasText: TPL_NAME }) .click() const confirm = page.locator('.mcdlg--confirm').getByRole('button', { name: /load template/i }) await confirm.waitFor({ state: 'visible', timeout: 1200 }).catch(() => {}) if (await confirm.count()) await confirm.click() - await expect(page.locator('.react-flow__node[data-id="char_creation"]')).toBeVisible() + await expect(page.locator('.react-flow__node[data-id="cmp1_hit_rate_free"]')).toBeVisible() await page.waitForTimeout(500) // let the fit / measurement-settle effect land } @@ -202,10 +204,11 @@ const onScreen = (p: Page, id: string) => return b.right > a.left + 2 && b.left < a.right - 2 && b.bottom > a.top + 2 && b.top < a.bottom - 2 }, id) -// issue #344 (docs/diagram-layout.md §DL5.6) — the MMO opening view's core -// start nodes open WHOLE (inside the pane, not merely touching it); returns the +// issue #344 (docs/diagram-layout.md §DL5.6) — the opening view's core +// start nodes (the comparison row and the Free zone's front) open WHOLE (inside +// the pane, not merely touching it); returns the // ones that do not -const MMO_CORE = ['char_creation', 'active_char', 'z1_enc_src', 'z1_enc'] +const START_CORE = ['cmp1_hit_rate_free', 'fund_free', 'ticket_free', 'roll_gate_free'] const notWhole = (p: Page) => p.evaluate((ids) => { const a = document.querySelector('.react-flow')!.getBoundingClientRect() @@ -215,7 +218,7 @@ const notWhole = (p: Page) => const b = el.getBoundingClientRect() return !(b.left >= a.left - 0.5 && b.right <= a.right + 0.5 && b.top >= a.top - 0.5 && b.bottom <= a.bottom + 0.5) }) - }, MMO_CORE) + }, START_CORE) test.describe('minimap — the collapse / restore control', () => { test.beforeEach(async ({ page }) => { @@ -346,22 +349,22 @@ test.describe('minimap — the collapse / restore control', () => { test('a Template opened while collapsed reserves NO minimap gap; expanded it does', async ({ page }) => { // expanded run - await openMmoFromMenu(page) + await openTemplateFromMenu(page) const ivExpanded = await lastInitialView(page) expect(ivExpanded, 'applyInitialView ran').not.toBeNull() expect(ivExpanded!.insetR).toBe(224) expect(ivExpanded!.insetB).toBe(176) - expect(ivExpanded!.zoom).toBeGreaterThanOrEqual(MMO_MIN_ZOOM - 1e-6) - expect(await onScreen(page, 'char_creation')).toBe(true) - expect(await onScreen(page, 'z1_enc')).toBe(true) - expect(await onScreen(page, 'end15')).toBe(false) + expect(ivExpanded!.zoom).toBeGreaterThanOrEqual(TPL_MIN_ZOOM - 1e-6) + expect(await onScreen(page, 'cmp1_hit_rate_free')).toBe(true) + expect(await onScreen(page, 'roll_gate_free')).toBe(true) + expect(await onScreen(page, 'pulls_made_pickup')).toBe(false) expect(await notWhole(page)).toEqual([]) // sanity: the framed early band does not sit under the minimap — the two // screen rects collide only when they overlap on BOTH axes (issue #344: // the opening view lets the top row run past the minimap's column, above it) const underMinimap = await page.evaluate(() => { const m = document.querySelector('.react-flow__minimap')!.getBoundingClientRect() - return ['char_creation', 'active_char', 'z1_enc'].filter((id) => { + return ['cmp1_hit_rate_free', 'fund_free', 'roll_gate_free'].filter((id) => { const el = document.querySelector(`.react-flow__node[data-id="${id}"]`) if (!el) return false const r = el.getBoundingClientRect() @@ -373,17 +376,17 @@ test.describe('minimap — the collapse / restore control', () => { // collapse, re-open the same Template at the same pane size await toggleBtn(page).click() await expect(page.locator('.react-flow__minimap')).toHaveCount(0) - await openMmoFromMenu(page) + await openTemplateFromMenu(page) const ivCollapsed = await lastInitialView(page) expect(ivCollapsed!.insetR).toBe(0) // no gap for an invisible minimap expect(ivCollapsed!.insetB).toBe(0) - expect(ivCollapsed!.zoom).toBeGreaterThanOrEqual(MMO_MIN_ZOOM - 1e-6) + expect(ivCollapsed!.zoom).toBeGreaterThanOrEqual(TPL_MIN_ZOOM - 1e-6) // a wider usable width ⇒ same-or-larger fit zoom for the same rect, capped // so the core start nodes stay whole (the `keep` rect) expect(ivCollapsed!.zoom).toBeGreaterThanOrEqual(ivExpanded!.zoom - 1e-6) - expect(await onScreen(page, 'char_creation')).toBe(true) - expect(await onScreen(page, 'z1_enc')).toBe(true) - expect(await onScreen(page, 'end15')).toBe(false) + expect(await onScreen(page, 'cmp1_hit_rate_free')).toBe(true) + expect(await onScreen(page, 'roll_gate_free')).toBe(true) + expect(await onScreen(page, 'pulls_made_pickup')).toBe(false) expect(await notWhole(page)).toEqual([]) }) diff --git a/e2e/mmo-progression.spec.ts b/e2e/mmo-progression.spec.ts index 07c09d84..a376db79 100644 --- a/e2e/mmo-progression.spec.ts +++ b/e2e/mmo-progression.spec.ts @@ -3,17 +3,19 @@ import type { Page } from '@playwright/test' import { ensureTimelineOpen, expect, openApp, resetAll, seedPersonalBrowser, test } from './support/loop' // docs/example-mmo-progression.md §EM10 — the "Early MMO progression (levels -// 1–15)" Templates demo, exercised through the app: pick it from Templates ▾ -// (desktop) and the mobile More → Templates sheet, in EN and KO; run it to -// completion deterministically; check it round-trips; and check the §EM10.1 -// accounting identities from the app's own engine state. +// 1–15)" document, exercised through the app. Since 0.28.1 it is a developer +// stress fixture: no longer offered by Templates ▾ (desktop) or the mobile +// More → Templates sheet, so it is opened from its FILE, as a user's own file +// is, and both menus are checked not to list it. Its own behaviour is unchanged: +// the recommended config applies, it opens edit-locked, its node names follow a +// locale switch, it runs to completion deterministically, it round-trips, and +// the §EM10.1 accounting identities hold in the app's own engine state. // // The canonical graph is examples/mmo-progression.json — the fixture regen guard // (src/engine/mmo-progression.test.ts) owns the value-level acceptance. -const DOC = JSON.parse( - readFileSync(new URL('../examples/mmo-progression.json', import.meta.url), 'utf8'), -) as { +const DOC_TEXT = readFileSync(new URL('../examples/mmo-progression.json', import.meta.url), 'utf8') +const DOC = JSON.parse(DOC_TEXT) as { nodes: { id: string }[] edges: { id: string }[] recommendedRunConfig: { @@ -72,21 +74,34 @@ const runToEnd = (page: Page, seed: number, cap = 200) => // Templates is the first `.menu` in the toolbar actions (locale-agnostic). const templatesBtn = (page: Page) => page.locator('.toolbar__actions .menu').first().locator('> button') -async function pickDesktopTemplate(page: Page, name: string) { - await templatesBtn(page).click() +/** Open examples/mmo-progression.json through the app's real file input (the + * toolbar's Open), confirming a replace if the session asks; wait for the + * whole graph. */ +async function openMmoFile(page: Page) { await page - .locator('.toolbar__actions .menu').first() - .locator('.menu__pop [role="menuitem"]', { hasText: name }) - .click() + .locator('input[type=file][accept*="json"]') + .first() + .setInputFiles({ name: 'mmo-progression.json', mimeType: 'application/json', buffer: Buffer.from(DOC_TEXT, 'utf8') }) + // a modified session asks to replace (the dialog may arrive a moment after + // the file is read); a pristine one loads straight away. Locale-independent: + // the confirm dialog's primary action, by class. + const confirm = page.locator('.mcdlg--confirm .btn--primary') + const count = () => page.evaluate(() => (window as unknown as { __loop: Loop }).__loop.graph.getState().nodes.length) + await expect + .poll(async () => { + if (await confirm.isVisible().catch(() => false)) await confirm.click() + return count() + }) + .toBe(DOC.nodes.length) } test.describe('Early MMO progression example', () => { - test('desktop: Templates ▾ loads the canonical graph + its recommended MC config', async ({ page }) => { + test('desktop: opening its file loads the canonical graph + its recommended MC config', async ({ page }) => { await openApp(page) await resetAll(page) expect(await loop(page)).toBe(true) - await pickDesktopTemplate(page, EN_NAME) + await openMmoFile(page) const g = await graphCounts(page) expect(g.nodes).toBe(DOC.nodes.length) @@ -116,7 +131,7 @@ test.describe('Early MMO progression example', () => { }) => { await openApp(page) await resetAll(page) - await pickDesktopTemplate(page, EN_NAME) + await openMmoFile(page) await ensureTimelineOpen(page) // docs/timeline-series-contract.md §7 — collapsed until the first run const legend = page.locator('.timeline__legend') @@ -159,14 +174,14 @@ test.describe('Early MMO progression example', () => { expect(ts2).toContain('elapsed') expect(ts2.length).toBe(6) // (a fresh template open resetting the legend to the recommended 5 is covered - // by the "Templates ▾ loads the canonical graph + its recommended MC config" + // by the "opening its file loads the canonical graph + its recommended MC config" // test above, and the reload path by timeline-series.spec.ts.) }) test('opens edit-locked — a node still selects into a read-only Inspector; unlock → editable', async ({ page }) => { await openApp(page) await resetAll(page) - await pickDesktopTemplate(page, EN_NAME) + await openMmoFile(page) const isLocked = () => page.evaluate(() => (window as unknown as { __loop: Loop }).__loop.ui.getState().canvasLocked) @@ -194,23 +209,25 @@ test.describe('Early MMO progression example', () => { await expect(aside.locator('input').first()).toBeEnabled() }) - test('the Templates menu name + blurb render in EN and KO; the graph opens with the current locale\'s node labels (docs/template-label-overlay.md)', async ({ page }) => { + test('the Templates menu does not offer it in EN or KO (0.28.1); opened from its file, its node names follow a locale switch (docs/template-label-overlay.md)', async ({ page }) => { await openApp(page) await resetAll(page) const menuPop = page.locator('.toolbar__actions .menu').first().locator('.menu__pop') + // the menu is open and lists the Templates it offers (Coffee), but not this one await templatesBtn(page).click() - const enItem = menuPop.locator('[role="menuitem"]', { hasText: EN_NAME }) - await expect(enItem).toBeVisible() - await expect(enItem.locator('.menu__blurb')).toContainText('quests, hunts and rewards') + await expect(menuPop.locator('[role="menuitem"]', { hasText: 'Coffee roastery' })).toBeVisible() + await expect(menuPop.locator('[role="menuitem"]', { hasText: EN_NAME })).toHaveCount(0) + await expect(menuPop.locator('.menu__blurb', { hasText: 'quests, hunts and rewards' })).toHaveCount(0) await page.keyboard.press('Escape') await setLocale(page, 'ko') + await expect.poll(() => page.evaluate(() => document.documentElement.lang)).toBe('ko') await templatesBtn(page).click() - const koItem = menuPop.locator('[role="menuitem"]', { hasText: KO_NAME }) - await expect(koItem).toBeVisible() - await expect(koItem.locator('.menu__blurb')).toContainText('퀘스트, 사냥, 보상') + await expect(menuPop.locator('[role="menuitem"]', { hasText: '커피' }).first()).toBeVisible() + await expect(menuPop.locator('[role="menuitem"]', { hasText: KO_NAME })).toHaveCount(0) + await expect(menuPop.locator('.menu__blurb', { hasText: '퀘스트, 사냥, 보상' })).toHaveCount(0) await page.keyboard.press('Escape') const labels = () => @@ -224,22 +241,28 @@ test.describe('Early MMO progression example', () => { await expect.poll(() => page.evaluate(() => document.documentElement.lang)).toBe(code) } - // opened in KO ⇒ Korean node labels (from the ko dictionary) - await useLocale('ko') - await pickDesktopTemplate(page, KO_NAME) + // opened from its (English) file in EN ⇒ the canonical English labels + await useLocale('en') + await openMmoFile(page) let l = await labels() - expect(l).toContain('레벨') + expect(l).toContain('Level') + expect(l).toContain('Gold') + expect(l).toContain('Reached level 15') + expect(l).not.toContain('레벨') + + // switched to KO ⇒ Korean node labels (it is still a Template entry's + // document, so the ko dictionary re-seeds its official names) + await useLocale('ko') + await expect.poll(labels).toContain('레벨') + l = await labels() expect(l).toContain('골드') expect(l).toContain('15레벨 도달') expect(l).not.toContain('Level') - // opened in EN ⇒ the canonical English labels (reset first so the second - // pick loads without a replace-confirm) - await resetAll(page) + // and back to EN ⇒ the English labels again await useLocale('en') - await pickDesktopTemplate(page, EN_NAME) + await expect.poll(labels).toContain('Level') l = await labels() - expect(l).toContain('Level') expect(l).toContain('Gold') expect(l).toContain('Reached level 15') expect(l).not.toContain('레벨') @@ -249,7 +272,7 @@ test.describe('Early MMO progression example', () => { await page.emulateMedia({ reducedMotion: 'reduce' }) // each Step settles synchronously await openApp(page) await resetAll(page) - await pickDesktopTemplate(page, EN_NAME) + await openMmoFile(page) const a = await runToEnd(page, 1) expect(a.endedAt).toBeGreaterThan(15) @@ -289,7 +312,7 @@ test.describe('Early MMO progression example', () => { test('Export → re-import round-trips the graph and the recommended config', async ({ page }) => { await openApp(page) await resetAll(page) - await pickDesktopTemplate(page, EN_NAME) + await openMmoFile(page) const before = await graphCounts(page) const roundTripped = await page.evaluate(() => { @@ -321,7 +344,7 @@ test.describe('Early MMO progression example', () => { ) }) - test('mobile: the More → Templates sheet lists and loads it', async ({ browser }) => { + test('mobile: the More → Templates sheet does not list it (0.28.1); its file opens on a phone', async ({ browser }) => { const page = await browser.newPage({ viewport: { width: 390, height: 844 }, isMobile: true, @@ -347,21 +370,15 @@ test.describe('Early MMO progression example', () => { .click() const templates = page.locator('.sheet[aria-label="Templates"]') await expect(templates).toBeVisible() - const row = templates.locator('.sheet__row', { hasText: EN_NAME }) - await expect(row).toBeVisible() - await expect(row.locator('.sheet__row-sub')).toContainText('quests, hunts and rewards') - - await row.click() - // a modified session confirms first; a pristine one loads straight away - const confirm = page.locator('.mcdlg--confirm').getByRole('button', { name: /load template/i }) - if (await confirm.isVisible().catch(() => false)) await confirm.click() - await expect - .poll(() => - page.evaluate( - () => (window as unknown as { __loop: Loop }).__loop.graph.getState().nodes.length, - ), - ) - .toBe(DOC.nodes.length) + // the sheet lists the Templates it offers (Coffee), but not this one + await expect(templates.locator('.sheet__row', { hasText: 'Coffee roastery' })).toBeVisible() + await expect(templates.locator('.sheet__row', { hasText: EN_NAME })).toHaveCount(0) + await expect(templates.locator('.sheet__row-sub', { hasText: 'quests, hunts and rewards' })).toHaveCount(0) + await page.keyboard.press('Escape') + + // the phone's own file input (the top bar's Open) still opens the document + await openMmoFile(page) + expect((await graphCounts(page)).nodeIds).toEqual([...DOC.nodes.map((n) => n.id)].sort()) } finally { await page.close() } diff --git a/e2e/mobile.spec.ts b/e2e/mobile.spec.ts index 5d6b6e4b..8a4641f2 100644 --- a/e2e/mobile.spec.ts +++ b/e2e/mobile.spec.ts @@ -1784,7 +1784,8 @@ test.describe('sheet row secondary label contrast (§MV5 / WCAG 1.4.3)', () => { await escapeSheet(page, which) } // issue #330 PR 3 — More gained the Playback speed row (its sub names the speed) - expect(checked, 'the walk must reach every text sub-label: 6 More + 5 Templates + 4 enabled Export').toBe(15) + // 0.28.1 — Early MMO left the Templates sheet + expect(checked, 'the walk must reach every text sub-label: 6 More + 4 Templates + 4 enabled Export').toBe(14) expect(bad, 'hovered secondary labels below 4.5:1').toEqual([]) }) diff --git a/e2e/pwa.spec.ts b/e2e/pwa.spec.ts index 30b02a35..f3e9c7f3 100644 --- a/e2e/pwa.spec.ts +++ b/e2e/pwa.spec.ts @@ -421,11 +421,12 @@ test('applying a waiting update keeps a locked Template locked after reload', as await installAndControl(page) await page.getByRole('button', { name: 'Templates' }).click() - await page.getByRole('menuitem', { name: /Early MMO progression/ }).click() + // a locked Template the menu offers (Early MMO, locked too, left the menu in 0.28.1) + await page.getByRole('menuitem', { name: /3-zone gacha banner comparison/ }).click() const replaceDlg = page.locator('.mcdlg--confirm') await expect(replaceDlg).toBeVisible() await replaceDlg.getByRole('button', { name: 'Load template' }).click() - await expect(page.locator('.canvas.canvas--locked')).toBeVisible() // examples/mmo-progression.json ships canvasLocked: true + await expect(page.locator('.canvas.canvas--locked')).toBeVisible() // examples/gacha-banner-zones.json ships canvasLocked: true await stageUpdate(page, 'b') await expect(page.locator('.pwa-update')).toBeVisible() diff --git a/e2e/support/snapshot-policy.ts b/e2e/support/snapshot-policy.ts index ec22041d..e4d88a97 100644 --- a/e2e/support/snapshot-policy.ts +++ b/e2e/support/snapshot-policy.ts @@ -97,8 +97,6 @@ export const SNAPSHOTS: Record any }> const g = (page: Page) => page.evaluate(() => (window as unknown as { __loop: Loop }).__loop.graph.getState()) @@ -33,9 +39,9 @@ async function setLocale(page: Page, code: string) { // Templates ▾ is the first `.menu` in the toolbar actions (locale-agnostic). const templatesBtn = (page: Page) => page.locator('.toolbar__actions .menu').first().locator('> button') -/** #334 — the MMO template opens edit-locked, and the lock now refuses every - * user edit at the store too; a test that edits it lifts the lock first, the - * way a user has to. */ +/** #334 — the MMO document opens edit-locked, and the lock now refuses every + * user edit at the store too; a test that edits a document lifts the lock + * first (if it is on), the way a user has to. */ async function unlockCanvas(page: Page) { const lock = page.locator('.react-flow__controls-button.rf-lock') if ((await lock.getAttribute('aria-pressed')) === 'true') await lock.click() @@ -57,8 +63,28 @@ async function pickTemplate(page: Page, hasText: string) { await routesSettled(page) } -const MMO_EN = 'Early MMO progression' -const MMO_KO = '초반 MMO 성장' +const COFFEE_EN = 'Coffee roastery operations flow' +const COFFEE_KO = '커피 로스터리 운영 흐름' + +const MMO_TEXT = readFileSync(new URL('../examples/mmo-progression.json', import.meta.url), 'utf8') +/** Open examples/mmo-progression.json through the app's real file input (its + * file's own English names), confirming a replace if the session asks (the + * dialog may arrive a moment after the file is read), then wait for its + * routes. */ +async function openMmoFile(page: Page) { + await page + .locator('input[type=file][accept*="json"]') + .first() + .setInputFiles({ name: 'mmo-progression.json', mimeType: 'application/json', buffer: Buffer.from(MMO_TEXT, 'utf8') }) + const confirm = page.locator('.mcdlg--confirm .btn--primary') + await expect + .poll(async () => { + if (await confirm.isVisible().catch(() => false)) await confirm.click() + return page.evaluate(() => (window as unknown as { __loop: Loop }).__loop.graph.getState().nodes.length) + }) + .toBeGreaterThan(90) + await routesSettled(page) +} const GRAPH_STORAGE_KEY = 'loop-studio:graph:v1' const catValue = (page: Page, key: string) => @@ -68,41 +94,41 @@ const catValue = (page: Page, key: string) => ) test.describe('template label overlay', () => { - test('MMO opens with the current locale node labels; a later language switch re-seeds the OFFICIAL labels but keeps a user rename', async ({ page }) => { + test('Coffee opens with the current locale node labels; a later language switch re-seeds the OFFICIAL labels but keeps a user rename', async ({ page }) => { await openApp(page) await resetAll(page) await setLocale(page, 'ko') - await pickTemplate(page, MMO_KO) + await pickTemplate(page, COFFEE_KO) let l = await labels(page) - expect(l).toContain('레벨') - expect(l).toContain('골드') - expect(l).not.toContain('Level') + expect(l).toContain('생두 재고') + expect(l).toContain('로스팅 원두 재고') + expect(l).not.toContain('Green bean stock') - // the user renames the `gold` node to something of their own + // the user renames the `roasted_stock` node to something of their own await unlockCanvas(page) await page.evaluate(() => { const gs = (window as unknown as { __loop: Loop }).__loop.graph.getState() - const gold = gs.nodes.find((n: any) => n.id === 'gold') - gs.updateNodeData(gold.id, { label: '내 금고' }) + const roasted = gs.nodes.find((n: any) => n.id === 'roasted_stock') + gs.updateNodeData(roasted.id, { label: '내 원두 창고' }) }) // switch the app language while the KO document is open — the OFFICIAL // template labels follow the new language (§TLO11); the user rename does not await setLocale(page, 'en') l = await labels(page) - expect(l).toContain('Level') // 레벨 → Level - expect(l).not.toContain('레벨') - expect(l).toContain('내 금고') // the user rename is preserved - expect(l).not.toContain('Gold') // (that node is now '내 금고') + expect(l).toContain('Green bean stock') // 생두 재고 → Green bean stock + expect(l).not.toContain('생두 재고') + expect(l).toContain('내 원두 창고') // the user rename is preserved + expect(l).not.toContain('Roasted bean stock') // (that node is now '내 원두 창고') // …and a fresh EN open is the canonical English await resetAll(page) - await pickTemplate(page, MMO_EN) + await pickTemplate(page, COFFEE_EN) l = await labels(page) - expect(l).toContain('Level') - expect(l).toContain('Gold') - expect(l).not.toContain('레벨') + expect(l).toContain('Green bean stock') + expect(l).toContain('Roasted bean stock') + expect(l).not.toContain('생두 재고') }) test('the equilibrium sample opens with KO node labels under a KO locale, EN canonical under EN', async ({ page }) => { @@ -154,12 +180,12 @@ test.describe('template label overlay', () => { expect(l.sort()).toEqual(['Ore Stock', 'Out']) }) - test('re-open isolation: opening MMO in KO then EN yields the pristine English canonical', async ({ page }) => { + test('re-open isolation: opening Coffee in KO then EN yields the pristine English canonical', async ({ page }) => { await openApp(page) await resetAll(page) await setLocale(page, 'ko') - await pickTemplate(page, MMO_KO) + await pickTemplate(page, COFFEE_KO) // mutate the open document await page.evaluate(() => { const gs = (window as unknown as { __loop: Loop }).__loop.graph.getState() @@ -168,16 +194,16 @@ test.describe('template label overlay', () => { await resetAll(page) await setLocale(page, 'en') - await pickTemplate(page, MMO_EN) + await pickTemplate(page, COFFEE_EN) const s = await g(page) - expect(s.nodes.map((n: any) => n.data.label)).toContain('Level') - expect(s.nodes.map((n: any) => n.data.label)).not.toContain('레벨') + expect(s.nodes.map((n: any) => n.data.label)).toContain('Green bean stock') + expect(s.nodes.map((n: any) => n.data.label)).not.toContain('생두 재고') expect(s.selectedNodeId).toBeNull() }) // Desktop and mobile edit two different `doLoadTemplate` paths — the mobile // More → Templates sheet needs its own functional check that the overlay ran. - test('mobile More → Templates opens MMO with the current-locale (KO) node labels', async ({ + test('mobile More → Templates opens Coffee with the current-locale (KO) node labels', async ({ browser, }: { browser: Browser @@ -204,14 +230,14 @@ test.describe('template label overlay', () => { const moreLabel = await catValue(page, 'mobile.more') // '더 보기' const tplLabel = await catValue(page, 'templates.menuLabel') // '템플릿' - const mmoName = await catValue(page, 'templates.mmoProgression.name') // '초반 MMO 성장 (1–15레벨)' + const coffeeName = await catValue(page, 'templates.coffeeRoastery.name') // '커피 로스터리 운영 흐름' await page.locator('.mob-more').click() await page .locator(`.sheet[aria-label="${moreLabel}"] .sheet__row`, { hasText: tplLabel }) .click() await page - .locator(`.sheet[aria-label="${tplLabel}"] .sheet__row`, { hasText: mmoName }) + .locator(`.sheet[aria-label="${tplLabel}"] .sheet__row`, { hasText: coffeeName }) .click() // a non-pristine session confirms the replace first; a pristine one loads // straight away @@ -219,12 +245,12 @@ test.describe('template label overlay', () => { const confirm = page.getByRole('button', { name: confirmLabel }) if (await confirm.isVisible().catch(() => false)) await confirm.click() - await expect.poll(() => labels(page).then((l) => l.length)).toBeGreaterThan(50) + await expect.poll(() => labels(page).then((l) => l.length)).toBeGreaterThan(20) const l = await labels(page) - expect(l).toContain('레벨') - expect(l).toContain('골드') - expect(l).toContain('15레벨 도달') - expect(l).not.toContain('Level') + expect(l).toContain('생두 재고') + expect(l).toContain('로스팅 원두 재고') + expect(l).toContain('생두 입고') + expect(l).not.toContain('Green bean stock') } finally { await page.close() } @@ -239,26 +265,26 @@ test.describe('template label overlay', () => { // KO menu open ⇒ Korean labels; then the user renames a node await setLocale(page, 'ko') - await pickTemplate(page, MMO_KO) - expect(await labels(page)).toContain('레벨') + await pickTemplate(page, COFFEE_KO) + expect(await labels(page)).toContain('생두 재고') await unlockCanvas(page) await page.evaluate(() => { const gs = (window as unknown as { __loop: Loop }).__loop.graph.getState() - const gold = gs.nodes.find((n: any) => n.id === 'gold') - gs.updateNodeData(gold.id, { label: '내 금고' }) + const roasted = gs.nodes.find((n: any) => n.id === 'roasted_stock') + gs.updateNodeData(roasted.id, { label: '내 원두 창고' }) }) // switch the app language to EN ⇒ OFFICIAL labels follow, the rename stays await setLocale(page, 'en') - expect(await labels(page)).toContain('Level') - expect(await labels(page)).not.toContain('레벨') - expect(await labels(page)).toContain('내 금고') + expect(await labels(page)).toContain('Green bean stock') + expect(await labels(page)).not.toContain('생두 재고') + expect(await labels(page)).toContain('내 원두 창고') // let the autosave debounce persist, then a plain reload await expect .poll(() => page.evaluate( - (k) => localStorage.getItem(k)?.includes('내 금고') ?? false, + (k) => localStorage.getItem(k)?.includes('내 원두 창고') ?? false, GRAPH_STORAGE_KEY, ), ) @@ -271,17 +297,17 @@ test.describe('template label overlay', () => { // change so §TLO11 does nothing either expect(await htmlLang(page)).toBe('en') const restored = await labels(page) - expect(restored).toContain('Level') - expect(restored).toContain('Reached level 15') // official label followed the switch - expect(restored).toContain('내 금고') // the user rename survived the reload - expect(restored).not.toContain('레벨') + expect(restored).toContain('Green bean stock') + expect(restored).toContain('Green bean delivery') // official label followed the switch + expect(restored).toContain('내 원두 창고') // the user rename survived the reload + expect(restored).not.toContain('생두 재고') // and opening the English Template fresh from the menu is the canonical await resetAll(page) - await pickTemplate(page, MMO_EN) + await pickTemplate(page, COFFEE_EN) const en = await labels(page) - expect(en).toContain('Level') - expect(en).not.toContain('레벨') + expect(en).toContain('Roasted bean stock') + expect(en).not.toContain('생두 재고') }) }) @@ -303,7 +329,7 @@ test.describe('official template label — locale switch (§TLO11)', () => { test('KO → JA → EN round-trips the official labels of the bundled templates', async ({ page }) => { await openApp(page) const cases = [ - { pick: MMO_KO, ko: '레벨', ja: 'レベル', en: 'Level' }, + { pick: COFFEE_KO, ko: '생두 재고', ja: '生豆在庫', en: 'Green bean stock' }, { pick: '용량 교착', ko: '가공', ja: '加工', en: 'Processing' }, ] for (const c of cases) { @@ -325,11 +351,11 @@ test.describe('official template label — locale switch (§TLO11)', () => { await openApp(page) await resetAll(page) await setLocale(page, 'en') - await pickTemplate(page, MMO_EN) + await pickTemplate(page, COFFEE_EN) // rename one official node to exactly "