diff --git a/.changes/edit-lock-boundaries.json b/.changes/edit-lock-boundaries.json new file mode 100644 index 00000000..3cdb28cc --- /dev/null +++ b/.changes/edit-lock-boundaries.json @@ -0,0 +1 @@ +{ "type": "user-facing", "releaseNoteId": "release:0.21.3" } diff --git a/CHANGELOG.md b/CHANGELOG.md index f1d672ad..f488ea18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ 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.21.3 — 2026-10-08 + +A fix release (issues #334 and #335): the Canvas edit lock is exact at every document boundary and refuses every edit, and its button shows the state at a glance, as [`docs/canvas-edit-lock.md`](docs/canvas-edit-lock.md) describes. + +- **A new document starts unlocked.** File → New, starting a temporary session without the open diagram, and Delete work data leave an unlocked, empty document. Before, a locked example or file kept the new document locked, even after a reload. +- **Every other document brings its own lock and no history.** A Template, a file, a share link and Open proposal as document open with their own lock (a locked example stays locked) and with an empty undo history: Undo can no longer go back into the previous document. The confirmation before a replacement is the safety net. +- **While locked, nothing edits the document.** The palette, Insert module, Undo and Redo, the data import wizard, a data refresh, renaming a bound table and a revision Apply are disabled, and the stores refuse them too. Selecting, panning, zooming, Focus and the other view settings, Run and Step, export and sharing stay available. +- **The lock button shows its state.** Unlocked is an open padlock with its shackle swung clear of the body; locked is the closed padlock with the same pressed tell as Focus (the system highlight in forced colours). Its name stays "Edit lock" and `aria-pressed` carries the state; the tooltip names the next action. + +**No migration.** Files, share links, digests and simulation results are unchanged. One new string (the button's name) and three release-note lines in 18 languages, 16 of them without native review. The informational `meta.tool` string is now `loop-studio/0.21.3`. + ## v0.21.2 — 2026-10-07 A fix release (issue #332): a Pool's, a Parameter's and a Register's value and detail rows sit inside the node, as [`docs/node-shell-content-in-vessel.md`](docs/node-shell-content-in-vessel.md) "Follow-up — value and detail rows" describes. diff --git a/README.md b/README.md index 77cf2ee7..482e48f7 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.21.2 +## Latest — v0.21.3 + +A fix release: the edit lock is exact, and its button shows the state. + +- **A new document starts unlocked**, and every other one brings its own lock and an empty + undo history, so Undo never goes back into the previous document +- **While locked, nothing edits the document**: the palette, Insert module, Undo, Redo and + the data import wait; selecting, viewing, running and exporting stay available +- **The lock button reads at a glance**: an open padlock when you can edit, a closed, + highlighted one when editing is locked + +## v0.21.2 A fix release: values and detail rows sit inside their node. @@ -170,19 +181,8 @@ Compact nodes: more of a large graph fits in view. - **Nothing in a file changes**: positions, saved diagrams and simulation results stay the same; connections attach a few pixels higher -## v0.20.0 - -Flow colours beyond the canvas. - -- **The minimap and the timeline** show a coloured node in its colour; a coloured Pool or - Register draws its timeline line in it, and every other series keeps its own -- **Three templates in colour**: Coffee roastery, the gacha banner and early MMO open with - three colours on their main flows; their results are unchanged -- **Each colour once** in the Inspector: Recent and In this document leave out the colours - already shown above them -- **On a phone**, the read-only Inspector shows a colour as one line: a dot, its name and hex - -See [`CHANGELOG.md`](CHANGELOG.md) for the full notes of these releases, v0.19.0 (flow +See [`CHANGELOG.md`](CHANGELOG.md) for the full notes of these releases, v0.20.0 (flow +colours in the minimap and the timeline), v0.19.0 (flow colours on nodes and connections), v0.18.2 (the guided tour says each step once), v0.18.1 (one keyboard contract for every menu), v0.18.0 (the third-party open-source licenses in the About dialog), v0.17.2 (the diff --git a/docs/bundled-module-label-localization.md b/docs/bundled-module-label-localization.md index 5c59a765..6ec965aa 100644 --- a/docs/bundled-module-label-localization.md +++ b/docs/bundled-module-label-localization.md @@ -123,9 +123,11 @@ revision 2, it IS captured and restored alongside `nodes`/`edges` on every `SidecarBundle` gains one more key, `m`). This is the load-bearing correction from revision 1: a bare global map, cleared wholesale by `newGraph`/`loadGraph`/`loadDoc`, cannot be right — those three actions -push the pre-reset document into `past` FIRST, so an Undo back past one of +pushed the pre-reset document into `past` FIRST, so an Undo back past one of them must restore that point's tracking along with its nodes, not land on -an empty map. +an empty map. (Since #334 / v0.21.3, `docs/canvas-edit-lock.md` §4, only a +revision Apply still does so; New, a Template and another document start +with an empty history.) - **`lastAppliedLabel`** is the exact string THIS FEATURE itself last wrote at that node — set at insert time to whatever `cloneModuleDoc` actually @@ -446,11 +448,17 @@ contract this implementation is checked against) against EACH ENTRY's own provenance snapshot, never the live one (§MLS4.3, revision 2) — so neither can resurrect a stale-language label, AND a still-managed-at-that-point instance correctly resumes syncing if - Undo lands there, even past a New/Template-load/file-load. + Undo lands there, even past an in-place whole-graph load (a revision + Apply). 11. `newGraph` / `loadGraph` / `loadDoc` all clear the LIVE provenance map - (§MLS4.2) — but the outgoing document's own tracking is preserved in - history via the same `commit()` sidecar mechanism as `nodes`/`edges` - themselves, so an Undo past the reset restores it too (revision 2). + (§MLS4.2). Since v0.21.3 (issue #334, `docs/canvas-edit-lock.md` §4) New, + a Template, a file, a share link and Open proposal as document are + document boundaries that start with an EMPTY history, so the outgoing + document's tracking leaves with it and no Undo can reach it. The one + whole-graph load that stays undoable, a revision Apply (`loadDoc` + `revision-apply`), preserves the outgoing tracking in history via the + same `commit()` sidecar mechanism as `nodes`/`edges` themselves, so an + Undo past it restores it too (revision 2). 12. No new wire field, no new file-format version; provenance does not survive a save/reload — scoped explicitly to "bundled instances inserted in the current session, from this feature onward" (§MLS3), never smuggled @@ -481,10 +489,12 @@ contract this implementation is checked against) `bundledModuleId` registers provenance (with the real applied label) for exactly the inserted node ids; a `needs-v2-consent` refusal registers nothing; a file-based insert (no `bundledModuleId`) registers nothing; - `newGraph`/`loadGraph`/`loadDoc` clear the LIVE provenance; **[P1]** - New/loadGraph/loadDoc followed by Undo restores the module instance's - provenance along with its nodes; Redo past a reset restores the empty - (new-document) provenance, not the pre-reset instance's; a loaded document + `newGraph`/`loadGraph`/`loadDoc` clear the LIVE provenance; **[P1]** a + revision Apply (`loadDoc` `revision-apply`) followed by Undo restores the + module instance's provenance along with its nodes, and Redo the empty + provenance, not the pre-load instance's; since #334, New / loadGraph / + a `document-boundary` loadDoc leave an empty history, so Undo cannot bring + the instance or its provenance back; a loaded document that reuses a former host node id is never treated as provenanced; **[P1, revision 3]** `updateNodeData` detaches provenance immediately on a real label edit with no switch involved; a same-value patch does not @@ -503,8 +513,9 @@ contract this implementation is checked against) same-locale reselect a no-op, no regression to Template label-switch behavior) PLUS, from revision 2's review round: **[P1]** a rename to another locale's official string preserved through EN/KO/JA cycling; - insert → New/loadDoc/loadGraph → Undo → switch still syncs the restored - instance; a loaded document reusing a former host node id is never + insert → a revision Apply → Undo → switch still syncs the restored + instance (since #334 the New / file / Template-style variants check + instead that Undo cannot bring the instance back); a loaded document reusing a former host node id is never synced; a rename followed by Undo then Redo restores the managed state matching each history point (pre-rename still syncs, post-rename stays preserved) — PLUS, from revision 3's review round: **[P1]** @@ -520,7 +531,7 @@ contract this implementation is checked against) | **MLS-D2** | where does the EN canonical label come from? | **`BUNDLED_MODULES[i].doc`** directly (the same source `cloneModuleDoc` already reads for an EN insert) — not a third overlay table, so there is exactly one place each canonical id's English text is authored. | | **MLS-D3** | prune provenance entries for deleted/detached nodes? | **No.** Left in whatever snapshot they're in — harmless (never looked up for a node that no longer exists in that snapshot's own `nodes` array) and every live-map entry is fully cleared at the next `newGraph`/`loadGraph`/`loadDoc` regardless; pruning per-delete would be extra wiring for no observable benefit. | | **MLS-D4** | reuse `known.generated.ts` / the Template relabel machinery? | **No** — a static id table is structurally impossible for modules (§MLS2); a small, synchronous, always-resident map is enough here (two modules, under a dozen nodes apiece), so none of the Template path's lazy-dictionary/CI-drift-check machinery is needed. | -| **MLS-D5** | (revision 2) how does provenance survive Undo/Redo past a New/Template-load/file-load? | **A history-aware sidecar**, riding on the exact mechanism `frameSidecar`/`dataImportSidecar` already use — `SidecarBundle` gains a `m` key, captured by `commit()`'s `sidecarNow()` and restored by `undo()`/`redo()`'s `restoreSidecar()`. Rejected: a bare global map cleared by the three reset actions (revision 1's approach) — provably wrong, since it discarded the outgoing document's tracking the instant a reset committed, with no way for Undo to bring it back. | +| **MLS-D5** | (revision 2) how does provenance survive Undo/Redo past a New/Template-load/file-load? (Since #334 / v0.21.3 those three are document boundaries with an empty history; the question now applies only to a revision Apply.) | **A history-aware sidecar**, riding on the exact mechanism `frameSidecar`/`dataImportSidecar` already use — `SidecarBundle` gains a `m` key, captured by `commit()`'s `sidecarNow()` and restored by `undo()`/`redo()`'s `restoreSidecar()`. Rejected: a bare global map cleared by the three reset actions (revision 1's approach) — provably wrong, since it discarded the outgoing document's tracking the instant a reset committed, with no way for Undo to bring it back. | | **MLS-D6** | (revision 3) when does a real label edit detach provenance — lazily at the next switch, or eagerly at the edit? | **Eagerly**, in `updateNodeData` itself (§MLS4.4). Rejected: lazy-only detection (revision 2's approach) — it cannot distinguish "never edited" from "edited, then edited back to the exact same text" before any switch happens, silently erasing a genuine edit. The lazy check (§MLS3.1 rule 1) still exists as a correct fallback for a label that arrives some OTHER way (e.g. via Undo/Redo restoring an earlier snapshot) — it is not made redundant, just no longer the only path. | ## MLS8. Order this feeds into diff --git a/docs/canvas-edit-lock.md b/docs/canvas-edit-lock.md new file mode 100644 index 00000000..10b0d4fa --- /dev/null +++ b/docs/canvas-edit-lock.md @@ -0,0 +1,92 @@ +# Canvas edit lock and document boundaries + +Status: shipped in v0.21.3 (issues #334 and #335). + +The Canvas edit lock (`uiStore.canvasLocked`) keeps a document from being changed by accident while it is read, explained or run. This page is the one place its contract lives; the code points here. + +## 1. What the lock blocks + +While the lock is on, every **user edit** of the document is refused: + +- adding a node from the palette (a click or a drag onto the canvas); +- Insert module (a bundled module or a module file); +- moving, connecting, reconnecting and deleting nodes and connections, with the mouse or the keyboard (the arrow keys, Delete, Backspace); +- every Inspector field and button, the flow colour, the Inputs panel values, the Register expression; +- saved frames: drawing, moving, resizing, renaming, colouring, deleting, keeping a suggested frame; +- Undo and Redo (the buttons and the keys); +- the data import wizard, a data refresh and renaming a bound table; +- a revision Apply (Review → Apply). + +Copy, cut, paste and duplicate of nodes do not exist today. If they are added, copy stays available and the others follow the lock. + +## 2. What stays available + +- Selection, region select, the read-only Inspector, pan and zoom, the minimap. +- Focus, Filters, the Activity overlay, Pan mode and every other view setting. +- Run, Step, Play, Monte Carlo, the seed, the speed, the Timeline series: they change the run, not the document. +- Export (Graph JSON, Workspace JSON, CSV, a project revision, Make a proposal, Save selection as a module) and creating a share link. +- Opening another document (section 4). +- Unlocking. + +## 3. Where it is enforced + +Twice, on purpose: + +- **The UI** disables each entry point while locked (`disabled`, not hidden), so nothing invites an edit that will not happen. +- **The stores** refuse it anyway. `src/store/editPolicy.ts` registers one guard on `graphStore` (`setEditGuard`); every user-edit action of `graphStore`, the saved-frame changes of `frameStore` and `projectStore.applyProposal` start with it and return without any change: no state, no undo entry, no autosave, no `simulationRev`. Actions that report a result return `{ ok: false, reason: 'locked' }`, which their callers drop silently (the control was disabled first). `onNodesChange` / `onEdgesChange` keep `select` and `dimensions` changes and drop the rest, so selection and layout keep working. + +The guard is registered rather than imported because `graphStore` cannot import `uiStore` (`uiStore` → `mcStore` → `graphStore` would be a cycle); `startApp` imports `editPolicy.ts` once for every build (web, portable, PWA). A bare store in a unit test has no guard. + +The runtime is outside the guard by construction: `simStore` and `mcStore` never call a document-changing action. + +## 4. Document boundaries + +Opening another document is not an edit of the open one, so it is allowed while locked. Each whole-document replacement is a **document boundary**: + +| Path | Code | +|---|---| +| File → New | `graphStore.newGraph` | +| A temporary session started without the open document | `switchToTemporary(false)` → `newGraph` | +| Delete work data (personal browser) | `deleteWorkData` → `newGraph` | +| A Template (desktop menu, the phone's ⋯ menu) | `loadGraph` | +| A Graph / Workspace / Project revision file | `workspaceIO` → `loadDoc` `document-boundary` | +| A share link | `shareApply` → `loadDoc` `document-boundary` | +| Open proposal as document | `projectStore.openProposalAsDocument` → `loadDoc` `document-boundary` | + +At a boundary: + +1. **The undo history starts empty.** Undo and Redo are disabled; Undo can never go back into the previous document. The confirmation the app asks before replacing a diagram (unless it is the untouched first-run sample) is the safety net. +2. **The lock is the new document's.** A document whose `recommendedRunConfig.canvasLocked` is `true` opens locked; every other one, a new empty document included, opens unlocked. The value is written once, as part of the swap and before the new graph is set, so a locked Template or file never shows, renders or autosaves an unlocked moment, and the `applyRecommended` the caller runs afterwards finds it already set. + +`loadDoc` takes a required `mode` with no default, so a new caller has to say which load it is: + +- `{ mode: 'document-boundary', canvasLocked }` — another document, as above; +- `{ mode: 'revision-apply' }` — the open document edited in place by a revision Apply: one undo entry (`SEMANTICS-R.md` R-INV-8), refused while locked. + +`loadGraph` (a Template) is always a boundary and takes `canvasLocked` the same way. + +A temporary session that takes the open diagram along, and Reset all Loop Studio data (which reloads the app), are not boundaries of this kind. + +## 5. The lock control (issue #335) + +The Controls rail's lock button reads at a glance, without colour: + +- **Unlocked:** an open padlock, its shackle swung to the side; the free end stands clear of the body by a measurable gap at the real 1× size (2 px for the 14 px icon). +- **Locked:** the closed padlock (both legs meet the body) with the rail's pressed tell, the same as the Focus toggle: the soft signal tint, an inset 2 px ring and the signal colour, at least 3 : 1 against the unlocked button. In forced colours: the system `Highlight` / `HighlightText` pair, like every rail toggle. +- **Name and state:** the accessible name is fixed, "Edit lock" (`canvas.lock.name`), and `aria-pressed` carries the state; the tooltip names the next action ("Lock editing — …" / "Unlock editing — …"). +- The keyboard focus ring (the global `:focus-visible` outline) stays visible on the pressed button. +- The phone has no lock button (section 6). + +## 6. Persistence + +The lock is kept in `localStorage` (`loop-studio:canvas-locked`) so a plain reload or a PWA update keeps it. It is never part of the GraphDoc content, the digest, undo or `simulationRev`; a file carries it only as `recommendedRunConfig.canvasLocked`, written by an export while the lock is on. + +The phone is view-only (`docs/mobile.md` §MV3a) and has no lock control; a Template opened there still sets the lock a later desktop visit sees. + +## 7. Tests + +- `src/store/editPolicy.test.ts` — every refused action leaves the document, the frames, the history and the digest unchanged; selection, a Step, export and unlocking still work. +- `src/store/graphStore.boundary.test.ts` — each boundary empties the history; a revision Apply keeps one entry; the lock takes only its final value (no unlocked moment for a locked document). +- `e2e/document-boundary.spec.ts` — the boundaries through the real UI: File → New, a temporary session, Delete work data, a Template, a file and a share link. +- `e2e/canvas-lock.spec.ts` — the controls are disabled and change nothing while locked, the keyboard neither reaches nor runs them; the allowed ones work. +- `e2e/lock-control.spec.ts` — the 1× gap of the open icon and none for the closed one, the fixed name, `aria-pressed` and the tooltip, the pressed tell in light and dark (the same as Focus, at least 3 : 1) and `Highlight` in forced colours, and the focus ring on the pressed button. diff --git a/e2e/canvas-lock.spec.ts b/e2e/canvas-lock.spec.ts index f6d29fe6..955d9600 100644 --- a/e2e/canvas-lock.spec.ts +++ b/e2e/canvas-lock.spec.ts @@ -29,7 +29,7 @@ async function seed(page: Page, canvasLocked?: boolean) { { id: 'e1', source: 'src', target: 'p1', sourceHandle: 'out', targetHandle: 'in', type: 'loop', data: { kind: 'resource', flow: '1' } }, { id: 'e2', source: 'p1', target: 'snk', sourceHandle: 'out', targetHandle: 'in', type: 'loop', data: { kind: 'resource', flow: '1' } }, ], - }) + }, { mode: 'document-boundary', canvasLocked: locked === true }) l.mc.getState().applyRecommended(locked === undefined ? {} : { canvasLocked: locked }) }, canvasLocked) } @@ -185,7 +185,7 @@ test.describe('Canvas edit-lock', () => { const round = serM.deserialize(await shareM.decodeShareText(payload)) g().newGraph() l.ui.getState().setCanvasLocked(false) - g().loadDoc({ nodes: round.nodes, edges: round.edges }) + g().loadDoc({ nodes: round.nodes, edges: round.edges }, { mode: 'document-boundary', canvasLocked: round.recommendedRunConfig?.canvasLocked === true }) l.mc.getState().applyRecommended(round.recommendedRunConfig) const afterShare = l.ui.getState().canvasLocked @@ -221,6 +221,162 @@ test.describe('Canvas edit-lock', () => { expect(await locked(page)).toBe(false) }) + // Issue #334 — while locked, every user edit is refused, through the real + // controls: they are disabled, and pressing them anyway changes nothing. + test('locked: the palette, Undo / Redo, Insert module and the data import wizard are disabled and change nothing', async ({ page }) => { + await openApp(page) + await resetAll(page) + await seed(page) + await page.getByRole('button', { name: 'Pool', exact: true }).click() // an edit, so Undo has something + await expect(page.locator('.react-flow__node')).toHaveCount(4) + await lockBtn(page).click() + expect(await locked(page)).toBe(true) + const digest0 = await graphDigest(page) + const pos0 = await nodePos(page, 'p1') + + const pool = page.getByRole('button', { name: 'Pool', exact: true }) + await expect(pool).toBeDisabled() + await expect(pool).toHaveAttribute('draggable', 'false') + await pool.click({ force: true }) + + const undo = page.locator('.toolbar button[title^="Undo"]') + await expect(undo).toBeDisabled() + await expect(page.locator('.toolbar button[title^="Redo"]')).toBeDisabled() + await page.locator('.react-flow__pane').click({ position: { x: 5, y: 5 } }) + await page.keyboard.press('Control+z') + + // the arrow keys on a selected node + await page.locator('.react-flow__node[data-id="p1"]').click() + await page.keyboard.press('ArrowRight') + await page.keyboard.press('ArrowDown') + + await page.getByRole('button', { name: 'Insert module', exact: true }).click() + const items = page.locator('.menu__pop[role="menu"] [role="menuitem"]') + const n = await items.count() + for (let i = 0; i < n - 1; i++) await expect(items.nth(i)).toBeDisabled() // every bundled module + From file + await expect(items.nth(n - 1)).toBeEnabled() // saving the selection as a module is an export + await page.keyboard.press('Escape') + + await page.getByRole('button', { name: 'Data', exact: true }).click() + const data = page.locator('.menu__pop[role="menu"] [role="menuitem"]') + await expect(data.nth(0)).toBeDisabled() // Import + await expect(data.nth(1)).toBeEnabled() // Manage bindings (its refresh / rename are disabled inside) + await expect(data.nth(2)).toBeDisabled() // the in-app guide opens the same wizard + await page.keyboard.press('Escape') + + expect(await graphDigest(page)).toBe(digest0) + expect(await nodePos(page, 'p1')).toEqual(pos0) + await expect(page.locator('.react-flow__node')).toHaveCount(4) + + // unlocking lifts it: the palette adds again + await lockBtn(page).click() + await expect(pool).toBeEnabled() + await pool.click() + await expect(page.locator('.react-flow__node')).toHaveCount(5) + }) + + test('locked: the turned-off controls are really disabled, and the keyboard neither reaches nor runs them', async ({ page }) => { + await openApp(page) + await resetAll(page) + await seed(page) + await page.getByRole('button', { name: 'Pool', exact: true }).click() // so Undo would have something + await lockBtn(page).click() + expect(await locked(page)).toBe(true) + const digest0 = await graphDigest(page) + const count0 = await page.locator('.react-flow__node').count() + + // the native `disabled` state, which is also what the accessibility tree reports + const palette = page.locator('.toolbar__palette button.chip') + const n = await palette.count() + expect(n).toBeGreaterThan(0) + for (let i = 0; i < n; i++) { + await expect(palette.nth(i)).toBeDisabled() + expect(await palette.nth(i).evaluate((b) => (b as HTMLButtonElement).disabled)).toBe(true) + } + for (const title of ['Undo', 'Redo']) { + const b = page.locator(`.toolbar button[title^="${title}"]`) + await expect(b).toBeDisabled() + expect(await b.evaluate((x) => (x as HTMLButtonElement).disabled)).toBe(true) + } + + // Tab walks the whole page from the top and never lands on one of them + const landed = await page.evaluate(() => { + const off = new Set([ + ...document.querySelectorAll('.toolbar__palette button.chip'), + ...document.querySelectorAll('.toolbar button[title^="Undo"], .toolbar button[title^="Redo"]'), + ]) + return { off: off.size } + }) + expect(landed.off).toBe(n + 2) + await page.locator('body').focus() + for (let i = 0; i < 60; i++) { + await page.keyboard.press('Tab') + const hit = await page.evaluate(() => { + const a = document.activeElement + return !!a && (a.matches('.toolbar__palette button.chip') || a.matches('.toolbar button[title^="Undo"], .toolbar button[title^="Redo"]')) + }) + expect(hit, `Tab #${i + 1} reached a disabled control`).toBe(false) + } + + // the menus' keyboard skips the disabled rows: from the Insert module + // trigger, Arrow keys + Enter land only on the enabled row (Save selection + // as a module, an export) and never insert anything + const insert = page.getByRole('button', { name: 'Insert module', exact: true }) + await insert.focus() + await page.keyboard.press('Enter') + const rows = page.locator('.menu__pop[role="menu"] [role="menuitem"]') + await expect(rows.first()).toBeVisible() + for (let i = 0; i < 6; i++) { + await page.keyboard.press('ArrowDown') + const onDisabled = await page.evaluate(() => (document.activeElement as HTMLButtonElement | null)?.disabled === true) + expect(onDisabled).toBe(false) + } + await page.keyboard.press('Escape') + + // and a keyboard press on a disabled palette chip does nothing + await palette.first().evaluate((b) => (b as HTMLButtonElement).focus()) + await page.keyboard.press('Enter') + await page.keyboard.press('Space') + + expect(await graphDigest(page)).toBe(digest0) + await expect(page.locator('.react-flow__node')).toHaveCount(count0) + }) + + test('locked: selecting, zooming, Focus, Step and export still work, and leave the document as it was', async ({ page }) => { + await openApp(page) + await resetAll(page) + await seed(page, true) + const digest0 = await graphDigest(page) + + await page.locator('.react-flow__node[data-id="p1"]').click() + await expect + .poll(() => page.evaluate(() => (window as unknown as Bridge).__loop.graph.getState().selectedNodeId)) + .toBe('p1') + const zoom0 = await page.evaluate(() => (window as unknown as Bridge).__loop.rf.getViewport().zoom) + await page.locator('.react-flow__controls-button.react-flow__controls-zoomin').click() + await expect + .poll(() => page.evaluate(() => (window as unknown as Bridge).__loop.rf.getViewport().zoom)) + .toBeGreaterThan(zoom0) + const focus = page.locator('.react-flow__controls-button.rf-focus') + await focus.click() + await expect(focus).toHaveAttribute('aria-pressed', 'true') + await focus.click() + + await page.getByRole('button', { name: 'Advance one step' }).click() + await expect + .poll(() => page.evaluate(() => (window as unknown as Bridge).__loop.sim.getState().stepIndex)) + .toBeGreaterThan(0) + + await page.getByRole('button', { name: 'File', exact: true }).click() + const download = page.waitForEvent('download') + await page.locator('[role="menuitem"]').filter({ hasText: 'Graph JSON' }).first().click() + const file = JSON.parse(await (await (await download).createReadStream()).toArray().then((b) => Buffer.concat(b).toString('utf8'))) + expect(file.recommendedRunConfig.canvasLocked).toBe(true) + + expect(await graphDigest(page)).toBe(digest0) + expect(await locked(page)).toBe(true) + }) + test('a fresh document load always re-seeds canvasLocked, even over a persisted reload', async ({ page }) => { await openApp(page) await resetAll(page) diff --git a/e2e/canvas-refresh-visual.spec.ts-snapshots/forced-colors-L2-chromium-win32.png b/e2e/canvas-refresh-visual.spec.ts-snapshots/forced-colors-L2-chromium-win32.png index e628dfac..6f0d745f 100644 Binary files a/e2e/canvas-refresh-visual.spec.ts-snapshots/forced-colors-L2-chromium-win32.png and b/e2e/canvas-refresh-visual.spec.ts-snapshots/forced-colors-L2-chromium-win32.png differ diff --git a/e2e/canvas-refresh-visual.spec.ts-snapshots/matrix-dark-L0-chromium-win32.png b/e2e/canvas-refresh-visual.spec.ts-snapshots/matrix-dark-L0-chromium-win32.png index 584404d1..64121261 100644 Binary files a/e2e/canvas-refresh-visual.spec.ts-snapshots/matrix-dark-L0-chromium-win32.png and b/e2e/canvas-refresh-visual.spec.ts-snapshots/matrix-dark-L0-chromium-win32.png differ diff --git a/e2e/canvas-refresh-visual.spec.ts-snapshots/matrix-dark-L2-chromium-win32.png b/e2e/canvas-refresh-visual.spec.ts-snapshots/matrix-dark-L2-chromium-win32.png index feec37e1..ef162c6a 100644 Binary files a/e2e/canvas-refresh-visual.spec.ts-snapshots/matrix-dark-L2-chromium-win32.png and b/e2e/canvas-refresh-visual.spec.ts-snapshots/matrix-dark-L2-chromium-win32.png differ diff --git a/e2e/canvas-refresh-visual.spec.ts-snapshots/matrix-light-L2-chromium-win32.png b/e2e/canvas-refresh-visual.spec.ts-snapshots/matrix-light-L2-chromium-win32.png index b0d1dc3d..d9d67dd6 100644 Binary files a/e2e/canvas-refresh-visual.spec.ts-snapshots/matrix-light-L2-chromium-win32.png and b/e2e/canvas-refresh-visual.spec.ts-snapshots/matrix-light-L2-chromium-win32.png differ diff --git a/e2e/document-boundary.spec.ts b/e2e/document-boundary.spec.ts new file mode 100644 index 00000000..910acafc --- /dev/null +++ b/e2e/document-boundary.spec.ts @@ -0,0 +1,182 @@ +import type { Page } from '@playwright/test' +import { readFileSync } from 'node:fs' +import { expect, openApp, resetAll, test } from './support/loop' + +// Issue #334 — every whole-document replacement is a document BOUNDARY, driven +// through the real UI (the dev bridge only reads state, it never makes the +// swap): File → New, a temporary session without the document, Delete work +// data, a Template, an imported file and a share link. +// - the undo history starts empty: Undo and Redo are disabled, and Ctrl+Z +// cannot bring the previous document back (the confirmation before the +// swap is the safety net); +// - the lock is the NEW document's: New / a temporary session / Delete work +// data start unlocked, a locked Template or file opens locked, also after a +// reload. + +type Bridge = { __loop: Record any }> } + +const MMO = readFileSync(new URL('../examples/mmo-progression.json', import.meta.url), 'utf8') // canvasLocked: true +const COFFEE = readFileSync(new URL('../examples/coffee-roastery.json', import.meta.url), 'utf8') // no lock + +const lockBtn = (page: Page) => page.locator('.react-flow__controls-button.rf-lock') +const undoBtn = (page: Page) => page.locator('.toolbar button[title^="Undo"]') +const redoBtn = (page: Page) => page.locator('.toolbar button[title^="Redo"]') +const nodeCount = (page: Page) => page.locator('.react-flow__node').count() +const history = (page: Page) => + page.evaluate(() => { + const g = (window as unknown as Bridge).__loop.graph.getState() + return { past: g.past.length, future: g.future.length, nodes: g.nodes.length } + }) + +/** the confirmation the app asks before replacing a diagram that is not the + * untouched sample — pressed when it appears */ +async function confirmIfAsked(page: Page): Promise { + const confirm = page.locator('.mcdlg--confirm .mcdlg__foot button').nth(1) + if (await confirm.isVisible({ timeout: 1500 }).catch(() => false)) await confirm.click() + await expect(page.locator('.mcdlg--confirm')).toHaveCount(0) +} +async function fileNew(page: Page): Promise { + await page.getByRole('button', { name: 'File', exact: true }).click() + await page.getByRole('menuitem', { name: 'New', exact: true }).click() + await confirmIfAsked(page) +} +async function openTemplate(page: Page, name: string): Promise { + await page.getByRole('button', { name: 'Templates', exact: true }).click() + await page.locator('[role=menuitem]').filter({ hasText: name }).first().click() + await confirmIfAsked(page) +} +async function importFile(page: Page, text: string): Promise { + await page.locator('input[type=file][accept*="json"]').first().setInputFiles({ name: 'g.json', mimeType: 'application/json', buffer: Buffer.from(text, 'utf8') }) + await confirmIfAsked(page) +} +/** a palette click: adds a node only while the canvas is unlocked */ +async function addPool(page: Page): Promise { + await page.getByRole('button', { name: 'Pool', exact: true }).click() +} +/** an edited session: the palette added two Pools, one was undone */ +async function editedSession(page: Page): Promise { + await openApp(page) + await resetAll(page) + await addPool(page) + await addPool(page) + await undoBtn(page).click() + await expect(undoBtn(page)).toBeEnabled() + await expect(redoBtn(page)).toBeEnabled() +} +async function expectUnlockedEmptyHistory(page: Page): Promise { + await expect(lockBtn(page)).toHaveAttribute('aria-pressed', 'false') + await expect(page.locator('.canvas.canvas--locked')).toHaveCount(0) + await expect(undoBtn(page)).toBeDisabled() + await expect(redoBtn(page)).toBeDisabled() + expect(await history(page)).toMatchObject({ past: 0, future: 0 }) +} +async function reload(page: Page): Promise { + await page.evaluate(() => (window as unknown as { __loop: { autosave: { flush: () => void } } }).__loop.autosave.flush()) + await page.reload() + await page.waitForFunction(() => Boolean((window as unknown as { __loop?: unknown }).__loop)) + await expect(page.locator('.react-flow')).toBeVisible() +} + +// the Storage and privacy area (e2e/storage-sessions.spec.ts has the full flows) +const storageDialog = (page: Page) => page.locator('.mcdlg--storage') +async function storageAction(page: Page, action: 'to-temporary' | 'delete-work'): Promise { + await page.locator('.toolbar__settingsmenu > button').click() + await page.locator('[data-settings-row="storage-privacy"]').click() + await expect(storageDialog(page)).toHaveAttribute('data-storage-step', 'menu') + await storageDialog(page).locator(`[data-storage-action="${action}"]`).click() + await storageDialog(page).locator('[data-storage-confirm]').click() + if (action === 'delete-work') { + await expect(storageDialog(page).locator('[data-storage-notice="done"]')).toBeVisible() + await page.keyboard.press('Escape') + } + await expect(storageDialog(page)).toHaveCount(0) +} + +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 }) => { + await openApp(page) + await resetAll(page) + await openTemplate(page, 'Early MMO progression') + await expect(lockBtn(page)).toHaveAttribute('aria-pressed', 'true') + + await fileNew(page) + expect(await nodeCount(page)).toBe(0) + await expectUnlockedEmptyHistory(page) + await addPool(page) + await expect(page.locator('.react-flow__node')).toHaveCount(1) + + await reload(page) + await expect(lockBtn(page)).toHaveAttribute('aria-pressed', 'false') + }) + + 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 expect(lockBtn(page)).toHaveAttribute('aria-pressed', 'true') + await storageAction(page, 'to-temporary') + expect(await nodeCount(page)).toBe(0) + await expectUnlockedEmptyHistory(page) + }) + + 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 expect(lockBtn(page)).toHaveAttribute('aria-pressed', 'true') + await storageAction(page, 'delete-work') + expect(await nodeCount(page)).toBe(0) + await expectUnlockedEmptyHistory(page) + }) +}) + +test.describe('#334 — every other document starts with an empty history and its own lock', () => { + test('a Template: Undo and Redo disabled, Ctrl+Z brings nothing back', async ({ page }) => { + await editedSession(page) + await openTemplate(page, 'Coffee roastery') + const opened = await history(page) + expect(opened).toMatchObject({ past: 0, future: 0 }) + await expect(undoBtn(page)).toBeDisabled() + await expect(redoBtn(page)).toBeDisabled() + await page.locator('.react-flow__pane').click({ position: { x: 5, y: 5 } }) + await page.keyboard.press('Control+z') + expect((await history(page)).nodes).toBe(opened.nodes) + await expect(lockBtn(page)).toHaveAttribute('aria-pressed', 'false') + }) + + test('an imported file: Undo and Redo disabled; a locked file opens locked and stays so after a reload', async ({ page }) => { + await editedSession(page) + await importFile(page, COFFEE) + expect(await history(page)).toMatchObject({ past: 0, future: 0 }) + await expect(undoBtn(page)).toBeDisabled() + await expect(lockBtn(page)).toHaveAttribute('aria-pressed', 'false') + + await importFile(page, MMO) + expect(await history(page)).toMatchObject({ past: 0, future: 0 }) + await expect(lockBtn(page)).toHaveAttribute('aria-pressed', 'true') + await reload(page) + await expect(lockBtn(page)).toHaveAttribute('aria-pressed', 'true') + + // and back: an unlocked file from a locked session opens unlocked + await importFile(page, COFFEE) + await expect(lockBtn(page)).toHaveAttribute('aria-pressed', 'false') + }) + + test('a share link: Undo and Redo disabled after it opens, and its own lock', async ({ page }) => { + await editedSession(page) + const payload = await page.evaluate(async (text) => { + const M = await import('/src/model/share.ts') + return (await M.encodeShareText(text)).payload + }, MMO) + await page.goto('about:blank') + page.once('dialog', (d) => void d.accept()) // the stored diagram is not the untouched sample + await page.goto('/#g1=' + payload) + await page.waitForFunction(() => Boolean((window as unknown as { __loop?: unknown }).__loop)) + await page.waitForFunction(() => location.hash === '') + await expect.poll(async () => (await history(page)).nodes).toBeGreaterThan(50) + expect(await history(page)).toMatchObject({ past: 0, future: 0 }) + await expect(undoBtn(page)).toBeDisabled() + await expect(redoBtn(page)).toBeDisabled() + await expect(lockBtn(page)).toHaveAttribute('aria-pressed', 'true') + }) +}) diff --git a/e2e/edge-label-ref-types.spec.ts b/e2e/edge-label-ref-types.spec.ts index d0bd665b..aa55d69f 100644 --- a/e2e/edge-label-ref-types.spec.ts +++ b/e2e/edge-label-ref-types.spec.ts @@ -86,6 +86,9 @@ test.describe('edge label — @-reference value types', () => { await openApp(page) await resetAll(page) await importGraph(page, GACHA) + // #334 — the gacha file opens edit-locked and the lock refuses every user + // edit; the value edits below lift it first, as a user has to + await page.evaluate(() => (window as any).__loop.ui.getState().setCanvasLocked(false)) // the error label as the ACTIVE locale renders it — never hard-code English, // and never accept the raw id as a substitute @@ -125,6 +128,9 @@ test.describe('edge label — @-reference value types', () => { await openApp(page) await resetAll(page) await importGraph(page, GACHA) + // #334 — the gacha file opens edit-locked and the lock refuses every user + // edit; the value edits below lift it first, as a user has to + await page.evaluate(() => (window as any).__loop.ui.getState().setCanvasLocked(false)) const refError = await page.evaluate( () => (window as any).__loop.i18n.getState().activeCatalog['canvas.edgeLabel.refMissing'] as string, diff --git a/e2e/flow-colour-views-visual.spec.ts-snapshots/flow-views-template-coffee-dark-chromium-win32.png b/e2e/flow-colour-views-visual.spec.ts-snapshots/flow-views-template-coffee-dark-chromium-win32.png index ef646025..da5d9dff 100644 Binary files a/e2e/flow-colour-views-visual.spec.ts-snapshots/flow-views-template-coffee-dark-chromium-win32.png and b/e2e/flow-colour-views-visual.spec.ts-snapshots/flow-views-template-coffee-dark-chromium-win32.png differ diff --git a/e2e/flow-colour-views-visual.spec.ts-snapshots/flow-views-template-coffee-light-chromium-win32.png b/e2e/flow-colour-views-visual.spec.ts-snapshots/flow-views-template-coffee-light-chromium-win32.png index 7559a782..a3459ee4 100644 Binary files a/e2e/flow-colour-views-visual.spec.ts-snapshots/flow-views-template-coffee-light-chromium-win32.png and b/e2e/flow-colour-views-visual.spec.ts-snapshots/flow-views-template-coffee-light-chromium-win32.png differ diff --git a/e2e/flow-colour-views-visual.spec.ts-snapshots/flow-views-template-gacha-dark-chromium-win32.png b/e2e/flow-colour-views-visual.spec.ts-snapshots/flow-views-template-gacha-dark-chromium-win32.png index 4ebcef47..683caf0a 100644 Binary files a/e2e/flow-colour-views-visual.spec.ts-snapshots/flow-views-template-gacha-dark-chromium-win32.png and b/e2e/flow-colour-views-visual.spec.ts-snapshots/flow-views-template-gacha-dark-chromium-win32.png differ diff --git a/e2e/flow-colour-views-visual.spec.ts-snapshots/flow-views-template-gacha-light-chromium-win32.png b/e2e/flow-colour-views-visual.spec.ts-snapshots/flow-views-template-gacha-light-chromium-win32.png index 0ca0460e..a67a08c8 100644 Binary files a/e2e/flow-colour-views-visual.spec.ts-snapshots/flow-views-template-gacha-light-chromium-win32.png and b/e2e/flow-colour-views-visual.spec.ts-snapshots/flow-views-template-gacha-light-chromium-win32.png differ diff --git a/e2e/flow-colour-visual.spec.ts-snapshots/flow-colour-dark-L2-chromium-win32.png b/e2e/flow-colour-visual.spec.ts-snapshots/flow-colour-dark-L2-chromium-win32.png index 36a0ead9..553c409c 100644 Binary files a/e2e/flow-colour-visual.spec.ts-snapshots/flow-colour-dark-L2-chromium-win32.png and b/e2e/flow-colour-visual.spec.ts-snapshots/flow-colour-dark-L2-chromium-win32.png differ diff --git a/e2e/flow-colour-visual.spec.ts-snapshots/flow-colour-light-L2-chromium-win32.png b/e2e/flow-colour-visual.spec.ts-snapshots/flow-colour-light-L2-chromium-win32.png index da3e5406..a2122945 100644 Binary files a/e2e/flow-colour-visual.spec.ts-snapshots/flow-colour-light-L2-chromium-win32.png and b/e2e/flow-colour-visual.spec.ts-snapshots/flow-colour-light-L2-chromium-win32.png differ diff --git a/e2e/flow-colour-visual.spec.ts-snapshots/flow-colour-states-chromium-win32.png b/e2e/flow-colour-visual.spec.ts-snapshots/flow-colour-states-chromium-win32.png index 2f646e3b..d1953866 100644 Binary files a/e2e/flow-colour-visual.spec.ts-snapshots/flow-colour-states-chromium-win32.png and b/e2e/flow-colour-visual.spec.ts-snapshots/flow-colour-states-chromium-win32.png differ diff --git a/e2e/forced-colors-edge-tell.spec.ts b/e2e/forced-colors-edge-tell.spec.ts index f238b19a..7d04e903 100644 --- a/e2e/forced-colors-edge-tell.spec.ts +++ b/e2e/forced-colors-edge-tell.spec.ts @@ -309,6 +309,9 @@ async function injectIdleStateEdge(page: Page, nearEdgeId: string): Promise { + // #334 — the mmo fixture opens edit-locked and the lock refuses every user + // edit; the waypoint below is one, so the lock is lifted first + await page.evaluate(() => (window as any).__loop.ui.getState().setCanvasLocked(false)) await page.evaluate((id) => { const g = (window as unknown as { __loop: { graph: { getState: () => { nodes: { id: string; position: { x: number; y: number } }[]; edges: { id: string; source: string; target: string; data: Record }[]; setEdgeData: (id: string, d: object) => void } } } }).__loop.graph.getState() const e = g.edges.find((x) => x.id === id)! diff --git a/e2e/i18n-lazy.spec.ts b/e2e/i18n-lazy.spec.ts index 54adcf66..4a2063da 100644 --- a/e2e/i18n-lazy.spec.ts +++ b/e2e/i18n-lazy.spec.ts @@ -128,6 +128,8 @@ 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 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() diff --git a/e2e/large-graph-readability.spec.ts b/e2e/large-graph-readability.spec.ts index b4863c9b..6a25c03b 100644 --- a/e2e/large-graph-readability.spec.ts +++ b/e2e/large-graph-readability.spec.ts @@ -1653,7 +1653,13 @@ test.describe('LGR Slice 4a — transient group frames', () => { const after = await snapshot(page) expect(after.graph).toBe(before.graph) - expect(after.canUndo).toBe(before.canUndo) + // the GraphDoc and its digest are untouched; the frame itself is one undo + // entry since saved frames (LGR Slice 5, §SF11.1). (#334 — the import + // before it now leaves an empty history, which is what made this visible: + // the old `canUndo` equality held only because the import's own entry + // was there.) + expect(before.canUndo).toBe(false) + expect(after.canUndo).toBe(true) expect(await gDigest(page)).toBe(digestBefore) }) @@ -2953,7 +2959,12 @@ test.describe('LGR frame colour (§FC)', () => { await expect(page.locator('.lgr-frame__fill[data-color]')).toHaveCount(0) expect(await gDigest(page)).toBe(digestBefore) - expect(await fcCanUndo(page)).toBe(undoBefore) + // the GraphDoc digest is untouched; the frame's add and colour changes are + // undo entries of their own (saved frames, §SF11.1). #334 — the import + // before them now leaves an empty history, so the old `canUndo` equality + // (true only because of the import's entry) no longer stands in for it. + expect(undoBefore).toBe(false) + expect(await fcCanUndo(page)).toBe(true) }) test('pick an accent on an AUTO frame -> it PROMOTES: leaves the auto set, becomes a SOLID manual Group frame with that colour (§AF5 R5)', async ({ page }) => { @@ -3314,17 +3325,19 @@ test.describe('LGR Slice 5 — saved frames (SF / loop-revision/5)', () => { expect((await sfState(page)).struct).toBe(base.struct) }) - test('Import adds ONE undo entry, never one-per-frame; pure Suggest / Dismiss / Clear suggested add none and never touch the doc digest or the record (SF11.3 / boundaries 4-7)', async ({ page }) => { + test('Import is a document boundary (#334): an empty history, never one entry per frame; pure Suggest / Dismiss / Clear suggested add none and never touch the doc digest or the record (SF11.3 / boundaries 4-7)', async ({ page }) => { await loadAF(page) await fcAdd(page, { x: 0, y: 0, w: 100, h: 60 }, 'One') await fcAdd(page, { x: 200, y: 0, w: 100, h: 60 }, 'Two') await sfRecSettled(page) // let the create autosave land first const before = await sfState(page) - // a whole-graph import that CARRIES two frames = still exactly one entry + // a whole-graph import that CARRIES two frames: no per-frame entry — and, + // since #334, no entry at all (another document starts an empty history) + expect(before.past).toBeGreaterThan(0) await page.evaluate((t) => (window as unknown as { __loop: { graph: { getState: () => { loadJSON: (t: string) => void } } } }).__loop.graph.getState().loadJSON(t), before.exported) expect((await sfState(page)).frames.map((f) => f.label)).toEqual(['One', 'Two']) - expect((await sfState(page)).past - before.past).toBe(1) + expect((await sfState(page)).past).toBe(0) // pure Suggest / Dismiss / Clear suggested — no undo entry, digest + record frozen const recMid = await sfRecSettled(page) diff --git a/e2e/large-graph-readability.spec.ts-snapshots/auto-frames-chromium-win32.png b/e2e/large-graph-readability.spec.ts-snapshots/auto-frames-chromium-win32.png index cabb904f..7d7317ae 100644 Binary files a/e2e/large-graph-readability.spec.ts-snapshots/auto-frames-chromium-win32.png and b/e2e/large-graph-readability.spec.ts-snapshots/auto-frames-chromium-win32.png differ diff --git a/e2e/large-graph-readability.spec.ts-snapshots/auto-frames-mixed-chromium-win32.png b/e2e/large-graph-readability.spec.ts-snapshots/auto-frames-mixed-chromium-win32.png index 30e2a7e6..0258e7b4 100644 Binary files a/e2e/large-graph-readability.spec.ts-snapshots/auto-frames-mixed-chromium-win32.png and b/e2e/large-graph-readability.spec.ts-snapshots/auto-frames-mixed-chromium-win32.png differ diff --git a/e2e/large-graph-readability.spec.ts-snapshots/run-distinction-states-chromium-win32.png b/e2e/large-graph-readability.spec.ts-snapshots/run-distinction-states-chromium-win32.png index a017ce0e..4cc91314 100644 Binary files a/e2e/large-graph-readability.spec.ts-snapshots/run-distinction-states-chromium-win32.png and b/e2e/large-graph-readability.spec.ts-snapshots/run-distinction-states-chromium-win32.png differ diff --git a/e2e/lock-control.spec.ts b/e2e/lock-control.spec.ts new file mode 100644 index 00000000..0b27bf4c --- /dev/null +++ b/e2e/lock-control.spec.ts @@ -0,0 +1,188 @@ +import type { Page } from '@playwright/test' +import { expect, openApp, resetAll, snap, test } from './support/loop' + +// Issue #335 — the Canvas edit-lock control tells locked from unlocked at a +// glance, without colour (docs/canvas-edit-lock.md): +// - unlocked: an open padlock whose shackle is swung to the side, its free end +// clear of the body by a measurable gap at the real 1× size; locked: the +// closed padlock (no gap) WITH the rail's pressed tell (tint + inset ring + +// the signal colour; `Highlight` in forced colours); +// - the accessible name is fixed ("Edit lock"), `aria-pressed` carries the +// state, and the tooltip names the next action; +// - a keyboard focus ring stays visible on the pressed button. + +type Bridge = { __loop: Record any }> } +const lockBtn = (page: Page) => page.locator('.react-flow__controls-button.rf-lock') +const setLocked = (page: Page, v: boolean) => + page.evaluate((x) => (window as unknown as Bridge).__loop.ui.getState().setCanvasLocked(x), v) + +/** the lock icon rendered at 1×: in the leg column nearest `legUnits` (16-unit + * grid), the run of background pixels between the leg's lowest ink above the + * body and the body's top edge */ +async function gapAt1x(page: Page, legUnits: number): Promise { + const svg = lockBtn(page).locator('svg') + const shot = (await svg.screenshot({ animations: 'disabled' })).toString('base64') + return page.evaluate( + async ([b64, leg]) => { + const img = new Image() + await new Promise((r) => { + img.onload = r + img.src = 'data:image/png;base64,' + b64 + }) + const w = img.width, h = img.height + const c = new OffscreenCanvas(w, h) + const x = c.getContext('2d')! + x.drawImage(img, 0, 0) + const d = x.getImageData(0, 0, w, h).data + const lum = (cx: number, cy: number) => { + const i = (cy * w + cx) * 4 + return 0.2126 * d[i] + 0.7152 * d[i + 1] + 0.0722 * d[i + 2] + } + const bg = lum(0, h - 1) // a corner is the button's background + const ink = (cx: number, cy: number) => Math.abs(lum(cx, cy) - bg) > 60 + const legPx = (leg * w) / 16 + let col = -1, most = -1 + for (let cx = Math.max(0, Math.floor(legPx) - 1); cx <= Math.min(w - 1, Math.floor(legPx) + 1); cx++) { + let n = 0 + for (let cy = 0; cy < Math.round(h / 2); cy++) if (ink(cx, cy)) n++ + if (n > most) { most = n; col = cx } + } + const bodyTop = Math.round((7.5 * h) / 16) - 1 + let lowest = -1 + for (let cy = 0; cy < bodyTop; cy++) if (ink(col, cy)) lowest = cy + if (lowest < 0) return null + let gap = 0 + for (let cy = lowest + 1; cy < h && !ink(col, cy); cy++) gap++ + return gap + }, + [shot, legUnits] as const, + ) +} + +/** WCAG contrast of two `rgb(...)` strings */ +const contrast = (a: string, b: string) => { + const lin = (v: number) => { + const s = v / 255 + return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4 + } + const L = (rgb: string) => { + const [r, g, bl] = rgb.match(/\d+(\.\d+)?/g)!.map(Number) + return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(bl) + } + const [x, y] = [L(a), L(b)].sort((p, q) => q - p) + return (x + 0.05) / (y + 0.05) +} +const styleOf = (page: Page) => + lockBtn(page).evaluate((b) => { + const cs = getComputedStyle(b) + return { color: cs.color, background: cs.backgroundColor, ring: cs.boxShadow, outline: `${cs.outlineStyle} ${cs.outlineWidth}` } + }) + +test.describe('#335 — the edit-lock control', () => { + test.beforeEach(async ({ page }) => { + await openApp(page) + await resetAll(page) + await page.mouse.move(700, 450) // no hover on the rail + }) + + test('a fixed accessible name, aria-pressed for the state, and the tooltip names the next action', async ({ page }) => { + const b = lockBtn(page) + await expect(b).toHaveAttribute('aria-pressed', 'false') + await expect(b).toHaveAccessibleName('Edit lock') + await expect(b).toHaveAttribute('title', /^Lock editing/) + await expect(b.locator('svg[data-icon="unlock"]')).toHaveCount(1) + await b.click() + await expect(b).toHaveAttribute('aria-pressed', 'true') + await expect(b).toHaveAccessibleName('Edit lock') + await expect(b).toHaveAttribute('title', /^Unlock editing/) + await expect(b.locator('svg[data-icon="lock"]')).toHaveCount(1) + await expect(page.getByRole('button', { name: 'Edit lock', pressed: true })).toHaveCount(1) + }) + + test('at the real 1× size the open shackle stands clear of the body and the closed one meets it', async ({ page }) => { + expect(await page.evaluate(() => window.devicePixelRatio)).toBe(1) + await setLocked(page, false) + const open = await gapAt1x(page, 10.35) // the free (right) end of the open shackle + expect(open, 'the open padlock has a visible gap').not.toBeNull() + expect(open!).toBeGreaterThanOrEqual(2) + await setLocked(page, true) + await page.mouse.move(700, 450) + for (const leg of [5.5, 10.5]) { + expect(await gapAt1x(page, leg), `closed padlock leg at ${leg}`).toBe(0) + } + }) + + for (const scheme of ['light', 'dark'] as const) { + test(`${scheme}: locked carries the Focus toggle's pressed tell, at least 3:1 against the unlocked button`, async ({ page }) => { + await page.emulateMedia({ colorScheme: scheme }) + await setLocked(page, false) + const off = await styleOf(page) + await setLocked(page, true) + await page.mouse.move(700, 450) + const on = await styleOf(page) + // the same tell as the Focus toggle when it is on + const focus = page.locator('.react-flow__controls-button.rf-focus') + await focus.click() + await page.mouse.move(700, 450) + const focusOn = await focus.evaluate((b) => { + const cs = getComputedStyle(b) + return { color: cs.color, background: cs.backgroundColor, ring: cs.boxShadow } + }) + expect({ color: on.color, background: on.background, ring: on.ring }).toEqual(focusOn) + expect(on.ring).toMatch(/inset/) + const ringColour = on.ring.match(/rgb\([^)]+\)/)![0] + expect(contrast(ringColour, off.background)).toBeGreaterThanOrEqual(3) + expect(on.background).not.toBe(off.background) + }) + } + + test('forced colours: locked is the system Highlight pair', async ({ page }) => { + await page.emulateMedia({ forcedColors: 'active' }) + await setLocked(page, true) + await page.mouse.move(700, 450) + const on = await styleOf(page) + const system = await page.evaluate(() => { + const probe = document.createElement('div') + probe.style.cssText = 'background: Highlight; color: HighlightText; position: absolute' + document.body.append(probe) + const cs = getComputedStyle(probe) + const r = { background: cs.backgroundColor, color: cs.color } + probe.remove() + return r + }) + expect(on.background).toBe(system.background) + expect(on.color).toBe(system.color) + await setLocked(page, false) + await page.mouse.move(700, 450) + expect((await styleOf(page)).background).not.toBe(system.background) + }) + + // the real 26 px button with its 14 px icon, straight from the product UI, at + // 1×, in both states and every theme. No hover (the pointer rests on the + // canvas) and no focus (the keyboard ring is checked through the DOM below). + for (const theme of ['light', 'dark', 'forced'] as const) { + test(`${theme}: the button's baseline in both states`, async ({ page }) => { + await page.emulateMedia(theme === 'forced' ? { forcedColors: 'active' } : { colorScheme: theme, forcedColors: 'none' }) + for (const state of ['unlocked', 'locked'] as const) { + await setLocked(page, state === 'locked') + await lockBtn(page).evaluate((b) => (b as HTMLElement).blur()) + await page.mouse.move(700, 450) + await expect(lockBtn(page)).toHaveAttribute('aria-pressed', state === 'locked' ? 'true' : 'false') + await expect(lockBtn(page)).toHaveScreenshot(...snap(page, `lock-${theme}-${state}`)) + } + }) + } + + test('a keyboard focus ring stays visible on the pressed (locked) button', async ({ page }) => { + await setLocked(page, true) + const b = lockBtn(page) + // reach it from the previous rail control by keyboard, so it is :focus-visible + await page.locator('.react-flow__controls-button.rf-activity').focus() + await page.keyboard.press('Tab') + await expect(b).toBeFocused() + expect(await b.evaluate((x) => x.matches(':focus-visible'))).toBe(true) + const s = await styleOf(page) + expect(s.outline.startsWith('none')).toBe(false) + expect(parseFloat(s.outline.split(' ')[1])).toBeGreaterThan(0) + }) +}) diff --git a/e2e/lock-control.spec.ts-snapshots/lock-dark-locked-chromium-win32.png b/e2e/lock-control.spec.ts-snapshots/lock-dark-locked-chromium-win32.png new file mode 100644 index 00000000..66637e96 Binary files /dev/null and b/e2e/lock-control.spec.ts-snapshots/lock-dark-locked-chromium-win32.png differ diff --git a/e2e/lock-control.spec.ts-snapshots/lock-dark-unlocked-chromium-win32.png b/e2e/lock-control.spec.ts-snapshots/lock-dark-unlocked-chromium-win32.png new file mode 100644 index 00000000..d6421a83 Binary files /dev/null and b/e2e/lock-control.spec.ts-snapshots/lock-dark-unlocked-chromium-win32.png differ diff --git a/e2e/lock-control.spec.ts-snapshots/lock-forced-locked-chromium-win32.png b/e2e/lock-control.spec.ts-snapshots/lock-forced-locked-chromium-win32.png new file mode 100644 index 00000000..2a149b2a Binary files /dev/null and b/e2e/lock-control.spec.ts-snapshots/lock-forced-locked-chromium-win32.png differ diff --git a/e2e/lock-control.spec.ts-snapshots/lock-forced-unlocked-chromium-win32.png b/e2e/lock-control.spec.ts-snapshots/lock-forced-unlocked-chromium-win32.png new file mode 100644 index 00000000..28e94a50 Binary files /dev/null and b/e2e/lock-control.spec.ts-snapshots/lock-forced-unlocked-chromium-win32.png differ diff --git a/e2e/lock-control.spec.ts-snapshots/lock-light-locked-chromium-win32.png b/e2e/lock-control.spec.ts-snapshots/lock-light-locked-chromium-win32.png new file mode 100644 index 00000000..1f82d250 Binary files /dev/null and b/e2e/lock-control.spec.ts-snapshots/lock-light-locked-chromium-win32.png differ diff --git a/e2e/lock-control.spec.ts-snapshots/lock-light-unlocked-chromium-win32.png b/e2e/lock-control.spec.ts-snapshots/lock-light-unlocked-chromium-win32.png new file mode 100644 index 00000000..815f7825 Binary files /dev/null and b/e2e/lock-control.spec.ts-snapshots/lock-light-unlocked-chromium-win32.png differ diff --git a/e2e/model-nodes-visual.spec.ts-snapshots/register-unit-row-chromium-win32.png b/e2e/model-nodes-visual.spec.ts-snapshots/register-unit-row-chromium-win32.png index e230e03e..4fc82d02 100644 Binary files a/e2e/model-nodes-visual.spec.ts-snapshots/register-unit-row-chromium-win32.png and b/e2e/model-nodes-visual.spec.ts-snapshots/register-unit-row-chromium-win32.png differ diff --git a/e2e/module-label-localization.spec.ts b/e2e/module-label-localization.spec.ts index 46574a77..a4ccd1f8 100644 --- a/e2e/module-label-localization.spec.ts +++ b/e2e/module-label-localization.spec.ts @@ -472,58 +472,49 @@ test('[P1] a user rename to another locale\'s official string is preserved throu expect((await gs(page)).nodes.find((n) => n.id === supply.id)!.data?.label).toBe('공급원') }) -test('[P1] insert -> New -> Undo -> a locale switch still syncs the restored instance', async ({ page }) => { - await resetAll(page) - const before = await gs(page) - await insertViaMenu(page, 'en', 'buffered-step') - - await page.evaluate(() => (window as any).__loop.graph.getState().newGraph()) - expect((await gs(page)).nodes).toHaveLength(0) - - await page.evaluate(() => (window as any).__loop.graph.getState().undo()) - expect(labelsOf(await gs(page), before)).toEqual([...LABELS['buffered-step'].en].sort()) - - await setLocale(page, 'ko') - expect(labelsOf(await gs(page), before)).toEqual([...LABELS['buffered-step'].ko].sort()) -}) - -test('[P1] insert -> loadDoc (Import) -> Undo -> a locale switch still syncs the restored instance', async ({ page }) => { - await resetAll(page) - const before = await gs(page) - await insertViaMenu(page, 'en', 'reward-split') - - // Import replaces the whole document via loadDoc — a real "start fresh" - // point (§MLS4.2), just like New - await page.evaluate(() => { - const l = (window as unknown as { __loop: Record any }> }).__loop - l.mc.getState().applyRecommended( - l.graph.getState().loadJSON( - JSON.stringify({ schema: 'loop-studio/graph', version: 1, nodes: [], edges: [] }), - ), - ) - }) - expect((await gs(page)).nodes).toHaveLength(0) - - await page.evaluate(() => (window as any).__loop.graph.getState().undo()) - expect(labelsOf(await gs(page), before)).toEqual([...LABELS['reward-split'].en].sort()) - - await setLocale(page, 'ja') - expect(labelsOf(await gs(page), before)).toEqual([...LABELS['reward-split'].ja].sort()) +// Issue #334 — New, a file (loadDoc via loadJSON) and a Template-style load +// (loadGraph) are document BOUNDARIES: the history starts empty, so Undo can +// never bring the inserted module back. (Before #334 each was undoable and the +// [P1] review-round-2 tests here checked a locale switch after that Undo; that +// path no longer exists. The history-aware provenance sidecar is still covered +// below through the one in-place whole-graph load left, a revision Apply.) +test('#334 New, a file and a Template-style load are document boundaries: Undo cannot bring the inserted module back', async ({ page }) => { + for (const which of ['New', 'a file', 'a Template-style load'] as const) { + await resetAll(page) + const before = await gs(page) + await insertViaMenu(page, 'en', 'buffered-step') + await page.evaluate((w) => { + const l = (window as unknown as { __loop: Record any }> }).__loop + const g = l.graph.getState() + if (w === 'New') g.newGraph() + else if (w === 'a file') { + l.mc.getState().applyRecommended(g.loadJSON(JSON.stringify({ schema: 'loop-studio/graph', version: 1, nodes: [], edges: [] }))) + } else { + g.loadGraph( + { nodes: [{ id: 'tpl_1', type: 'pool', position: { x: 0, y: 0 }, data: { kind: 'pool', label: 'Template pool', activation: 'passive', initial: 0, capacity: null, mode: 'pullAny' } }], edges: [] }, + { canvasLocked: false }, + ) + } + }, which) + const after = await gs(page) + expect(after.past, which).toHaveLength(0) + expect(after.future, which).toHaveLength(0) + await page.evaluate(() => (window as any).__loop.graph.getState().undo()) + expect(labelsOf(await gs(page), before), which).toEqual(labelsOf(after, before)) + } }) -test('[P1] insert -> loadGraph (Template-style load) -> Undo -> a locale switch still syncs the restored instance', async ({ page }) => { +test('[P1] insert -> a revision Apply (in place) -> Undo -> a locale switch still syncs the restored instance', async ({ page }) => { await resetAll(page) const before = await gs(page) await insertViaMenu(page, 'en', 'buffered-step') - await page.evaluate(() => { - const g = (window as unknown as { __loop: { graph: { getState: () => any } } }).__loop.graph.getState() - g.loadGraph({ - nodes: [{ id: 'tpl_1', type: 'pool', position: { x: 0, y: 0 }, data: { kind: 'pool', label: 'Template pool', activation: 'passive', initial: 0, capacity: null, mode: 'pullAny' } }], - edges: [], - }) - }) - expect((await gs(page)).nodes.map((n) => n.data?.label)).toEqual(['Template pool']) + // the in-place whole-graph load of a revision Apply clears the live + // provenance but keeps one undo entry carrying it (§MLS3.2) + await page.evaluate(() => + (window as any).__loop.graph.getState().loadDoc({ nodes: [], edges: [] }, { mode: 'revision-apply' }), + ) + expect((await gs(page)).nodes).toHaveLength(0) await page.evaluate(() => (window as any).__loop.graph.getState().undo()) expect(labelsOf(await gs(page), before)).toEqual([...LABELS['buffered-step'].en].sort()) diff --git a/e2e/playback-visual.spec.ts-snapshots/play-depart-dark-L2-chromium-win32.png b/e2e/playback-visual.spec.ts-snapshots/play-depart-dark-L2-chromium-win32.png index 32d16f98..042501f3 100644 Binary files a/e2e/playback-visual.spec.ts-snapshots/play-depart-dark-L2-chromium-win32.png and b/e2e/playback-visual.spec.ts-snapshots/play-depart-dark-L2-chromium-win32.png differ diff --git a/e2e/playback-visual.spec.ts-snapshots/play-depart-forced-colors-L2-chromium-win32.png b/e2e/playback-visual.spec.ts-snapshots/play-depart-forced-colors-L2-chromium-win32.png index 548c75ac..f9ded90d 100644 Binary files a/e2e/playback-visual.spec.ts-snapshots/play-depart-forced-colors-L2-chromium-win32.png and b/e2e/playback-visual.spec.ts-snapshots/play-depart-forced-colors-L2-chromium-win32.png differ diff --git a/e2e/playback-visual.spec.ts-snapshots/play-depart-light-L2-chromium-win32.png b/e2e/playback-visual.spec.ts-snapshots/play-depart-light-L2-chromium-win32.png index 549ccf19..a01ab2d3 100644 Binary files a/e2e/playback-visual.spec.ts-snapshots/play-depart-light-L2-chromium-win32.png and b/e2e/playback-visual.spec.ts-snapshots/play-depart-light-L2-chromium-win32.png differ diff --git a/e2e/playback-visual.spec.ts-snapshots/play-travel-L0-chromium-win32.png b/e2e/playback-visual.spec.ts-snapshots/play-travel-L0-chromium-win32.png index b0546c14..3b54d5ca 100644 Binary files a/e2e/playback-visual.spec.ts-snapshots/play-travel-L0-chromium-win32.png and b/e2e/playback-visual.spec.ts-snapshots/play-travel-L0-chromium-win32.png differ diff --git a/e2e/storage-port-runtime.spec.ts b/e2e/storage-port-runtime.spec.ts index bb9bbbbd..4859600f 100644 --- a/e2e/storage-port-runtime.spec.ts +++ b/e2e/storage-port-runtime.spec.ts @@ -383,10 +383,12 @@ test.describe('browser storage is reached through the port only, and only behind // 2. work in the session: a preference, the example spreadsheet, a theme - // the autosave and the toggles run, and none of it reaches localStorage + // (#334 — the data import is an edit, which the lock refuses: import + // first, then lock) + await importExample(page) const lock = page.locator('.react-flow__controls-button.rf-lock') await lock.click() await expect(lock).toHaveAttribute('aria-pressed', 'true') - await importExample(page) await page.waitForTimeout(700) // past the 400 ms autosave debounce const session = (await take(page, 'session')).filter((c) => c.viaPort || c.viaBoot) expect(session.map(brief)).toEqual([['setItem', MODE, 'port']]) diff --git a/e2e/storage-sessions.spec.ts b/e2e/storage-sessions.spec.ts index e47a8aa3..1719b53f 100644 --- a/e2e/storage-sessions.spec.ts +++ b/e2e/storage-sessions.spec.ts @@ -271,10 +271,13 @@ test.describe('the two deletions', () => { test('personal: Delete work data removes the document record; the author record and the preferences stay; the canvas is emptied and what is autosaved afterwards holds none of the old diagram', async ({ page }) => { await openWithOneNode(page) await labelTheWork(page) - // the author record and a preference, to prove they stay + // the author record and a preference, to prove they stay. (#334 — the + // Canvas edit lock is not that preference any more: the emptied canvas is a + // new document, and a new document starts unlocked; see + // document-boundary.spec.ts.) await page.evaluate(() => { localStorage.setItem('loop-studio:author', JSON.stringify({ name: 'Keep Me' })) - localStorage.setItem('loop-studio:canvas-locked', '1') + localStorage.setItem('loop-studio:focus-mode', '1') }) await openStorageArea(page) await expect(dialog(page).locator('[data-storage-action="delete-work"]')).toBeVisible() @@ -288,7 +291,7 @@ test.describe('the two deletions', () => { await page.waitForTimeout(600) // the autosave of the emptied canvas const after = Object.fromEntries((await stored(page)) as [string, string][]) expect(after['loop-studio:author']).toBe(JSON.stringify({ name: 'Keep Me' })) - expect(after['loop-studio:canvas-locked']).toBe('1') + expect(after['loop-studio:focus-mode']).toBe('1') expect(after[MODE]).toBe('personal') // the record is gone, or it is the emptied canvas: in neither case does any // of the old diagram survive - the key may come back, the data may not diff --git a/e2e/support/snapshot-policy.ts b/e2e/support/snapshot-policy.ts index 4a1ec1ce..ab44af1a 100644 --- a/e2e/support/snapshot-policy.ts +++ b/e2e/support/snapshot-policy.ts @@ -116,6 +116,14 @@ export const SNAPSHOTS: Record 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. */ +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() + await expect(lock).toHaveAttribute('aria-pressed', 'false') +} + async function pickTemplate(page: Page, hasText: string) { await templatesBtn(page).click() await page @@ -67,6 +76,7 @@ test.describe('template label overlay', () => { expect(l).not.toContain('Level') // the user renames the `gold` 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') @@ -227,6 +237,7 @@ test.describe('template label overlay', () => { await setLocale(page, 'ko') await pickTemplate(page, MMO_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') @@ -313,6 +324,7 @@ test.describe('official template label — locale switch (§TLO11)', () => { await pickTemplate(page, MMO_EN) // rename one official node to exactly "