diff --git a/.changes/rail-frame-buttons-locked.json b/.changes/rail-frame-buttons-locked.json new file mode 100644 index 0000000..2a1c371 --- /dev/null +++ b/.changes/rail-frame-buttons-locked.json @@ -0,0 +1 @@ +{ "type": "user-facing", "releaseNoteId": "release:0.21.4" } diff --git a/CHANGELOG.md b/CHANGELOG.md index f488ea1..10e89ec 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.4 — 2026-10-08 + +A fix release (issues #338 and #340): while the Canvas is edit-locked, the Controls rail keeps every button where it is, as [`docs/canvas-edit-lock.md`](docs/canvas-edit-lock.md) describes; and on a phone, focusing a text field no longer zooms the page in, as [`docs/mobile.md`](docs/mobile.md) §MV4b describes. + +- **The frame buttons stay, disabled.** "Group frame" and "Clear all frames" remain in the rail while editing is locked, as disabled buttons left out of the Tab order, so in the same document the rail has the same buttons and the same height locked and unlocked. Since v0.12.0 the two had been removed from the rail while locked, so the rail grew shorter and every button above them moved down by a button's height. +- **Locking turns the frame tool off.** A frame tool armed before locking used to stay armed: a drag on the empty canvas started a frame the lock then refused, and did not pan. Locking now turns it off, so the drag moves the view. +- **Unchanged:** the buttons that appear only when they apply ("Clear all frames" with no frame, "Suggest frames", "Clear suggested frames"), and everything the lock allows or refuses. +- **The phone's language search no longer zooms the page in.** On an iPhone, Safari zooms in on a focused text field smaller than 16 px and does not zoom back out; the More sheet's language search was 12 px, so after a search the page stayed zoomed until it was pinched back. Every text field the phone shows now takes at least 16 px (the Monte-Carlo fields and the share password field already did, one at a time). Pinch zoom is never blocked. + +**No migration.** Files, share links, digests and simulation results are unchanged. Four release-note lines in 18 languages, 16 of them without native review. The informational `meta.tool` string is now `loop-studio/0.21.4`. + ## 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. diff --git a/README.md b/README.md index 482e48f..e502196 100644 --- a/README.md +++ b/README.md @@ -140,7 +140,21 @@ 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.3 +## Latest — v0.21.4 + +A fix release: the canvas controls keep their place while editing is locked, and the phone's +language search no longer zooms the page in. + +- **The frame buttons stay, turned off**: Group frame and Clear all frames remain in the + canvas controls while editing is locked, so the other buttons no longer move; before, the + two disappeared and every button above them shifted down +- **Locking turns the frame tool off**: a drag on the empty canvas then moves the view, as it + should while editing is locked +- **No zoom from the phone's text fields**: on an iPhone, tapping the language search used to + zoom the page in and leave it zoomed; every phone text field is now large enough that it + does not + +## v0.21.3 A fix release: the edit lock is exact, and its button shows the state. @@ -170,18 +184,8 @@ A fix release: Focus mode dims the connections too. - **What stays at full strength is unchanged**: selection, warnings and the moving markers of a running simulation; under high contrast a faded connection is a fine dotted line -## v0.21.0 - -Compact nodes: more of a large graph fits in view. - -- **Shorter, a little narrower**: a one-line node is 56 px tall instead of 64, with less - side padding, so more of a big graph fits at the same zoom -- **Content still decides**: two-line titles, capacity rows, Parameters and Registers keep - the height they need, and long titles wrap as before -- **Nothing in a file changes**: positions, saved diagrams and simulation results stay the - same; connections attach a few pixels higher - -See [`CHANGELOG.md`](CHANGELOG.md) for the full notes of these releases, v0.20.0 (flow +See [`CHANGELOG.md`](CHANGELOG.md) for the full notes of these releases, v0.21.0 (compact +nodes, so more of a large graph fits in view), v0.20.0 (flow colours in the minimap and the timeline), v0.19.0 (flow colours on nodes and connections), v0.18.2 (the guided tour says each step once), v0.18.1 (one diff --git a/docs/canvas-edit-lock.md b/docs/canvas-edit-lock.md index 10b0d4f..7b2486d 100644 --- a/docs/canvas-edit-lock.md +++ b/docs/canvas-edit-lock.md @@ -32,7 +32,7 @@ Copy, cut, paste and duplicate of nodes do not exist today. If they are added, c Twice, on purpose: -- **The UI** disables each entry point while locked (`disabled`, not hidden), so nothing invites an edit that will not happen. +- **The UI** disables each entry point while locked (`disabled`, not hidden), so nothing invites an edit that will not happen. That includes the Controls rail's "Group frame" tool and "Clear all frames" (#338, v0.21.4; from v0.12.0 they had been removed from the rail while locked, which changed its height and moved every button above them): they stay in place, native `disabled` and out of the Tab order, so in the same document the rail has the same buttons and the same height locked and unlocked. Locking also turns an armed frame tool off (`editPolicy.ts`), whichever way the lock comes on. Buttons that appear only when they apply ("Clear all frames" with no frame, "Suggest frames", "Clear suggested frames") still follow the document. - **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. @@ -88,5 +88,5 @@ The phone is view-only (`docs/mobile.md` §MV3a) and has no lock control; a Temp - `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/canvas-lock.spec.ts` — the controls are disabled and change nothing while locked, the keyboard neither reaches nor runs them; the allowed ones work. #338: the Controls rail's buttons, each button's 26 px height and the rail's top and height are identical locked and unlocked, with and without a frame, in light, dark and forced colours; the two frame buttons are disabled, out of the Tab order and inert while locked and work again unlocked; locking turns an armed frame tool off, and a drag on the empty canvas then pans and draws no frame. - `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/docs/mobile.md b/docs/mobile.md index 0e3b6ed..c36f7d7 100644 --- a/docs/mobile.md +++ b/docs/mobile.md @@ -240,6 +240,18 @@ Pinch / accessibility zoom is **never** blocked — the viewport meta stays `width=device-width, initial-scale=1.0, viewport-fit=cover` with no `maximum-scale` or `user-scalable=no`. +**Every text field, not one at a time (#340, v0.21.4).** The "only place" +above stopped being true: the More sheet's language search (12 px, inherited +from its menu) zoomed an iPhone in and left it zoomed, as the share password +field had before it (#300, patched alone). Under the mobile media query **every +text-like field — `input` of any type but checkbox, radio, range, file, +colour, button, submit, reset and hidden, `textarea` and `select` — takes +`font-size: max(16px, 1em)`**, so a field added later cannot bring the zoom +back, and one already larger keeps its size. `e2e/mobile.spec.ts` "every text +field the phone shows is ≥ 16px" reads every visible field's computed size on +the canvas, in the More sheet, in the language menu (and types into its search +and closes it), in the share panel and in the Monte-Carlo dialog. + ## MV5. Timeline & Inspector sheets Both are bottom sheets. **Shared sheet contract:** diff --git a/e2e/canvas-lock.spec.ts b/e2e/canvas-lock.spec.ts index 955d960..c060b22 100644 --- a/e2e/canvas-lock.spec.ts +++ b/e2e/canvas-lock.spec.ts @@ -403,3 +403,114 @@ test.describe('Canvas edit-lock', () => { expect(await locked(page)).toBe(true) }) }) + +// #338 — the Controls rail keeps its buttons under the lock: the frame tool and +// "Clear all frames" stay in place, native `disabled`, so the rail's height and +// every button's place are the same locked and unlocked. +test.describe('Canvas edit-lock — the Controls rail (#338)', () => { + const rail = (page: Page) => + page.evaluate(() => { + const c = document.querySelector('.react-flow__controls')! + const r = c.getBoundingClientRect() + return { + top: r.top, + height: r.height, + buttons: [...c.querySelectorAll('button')].map((b) => { + const q = b.getBoundingClientRect() + return { cls: b.className, top: q.top - r.top, height: q.height } + }), + } + }) + const frameTool = (page: Page) => page.locator('.react-flow__controls-button.rf-frame') + const clearAll = (page: Page) => page.locator('.react-flow__controls-button.rf-frame-clear') + const addFrame = (page: Page) => + page.evaluate(() => (window as unknown as Bridge).__loop.frame.getState().addFrame({ x: -40, y: -40, w: 600, h: 160 })) + const frameCount = (page: Page) => + page.evaluate(() => (window as unknown as Bridge).__loop.frame.getState().frames.length) + + test('the rail keeps the same buttons, button height and overall height locked and unlocked, with and without frames, in light, dark and forced colours', async ({ page }) => { + await openApp(page) + await resetAll(page) + await seed(page) + for (const withFrame of [false, true]) { + if (withFrame) await addFrame(page) + for (const scheme of [{ colorScheme: 'light', forcedColors: 'none' }, { colorScheme: 'dark', forcedColors: 'none' }, { colorScheme: 'light', forcedColors: 'active' }] as const) { + await page.emulateMedia(scheme) + const tag = `${withFrame ? 'a frame' : 'no frame'}, ${scheme.colorScheme}${scheme.forcedColors === 'active' ? ' forced' : ''}` + expect(await locked(page)).toBe(false) + const open = await rail(page) + await lockBtn(page).click() + await expect(frameTool(page), tag).toBeDisabled() + const shut = await rail(page) + expect(shut.buttons.map((b) => b.cls.replace(/\s+/g, ' ')), tag).toEqual(open.buttons.map((b) => b.cls.replace(/\s+/g, ' '))) + expect(shut.buttons.map((b) => [b.top, b.height]), tag).toEqual(open.buttons.map((b) => [b.top, b.height])) + expect(new Set(shut.buttons.map((b) => b.height)), tag).toEqual(new Set([26])) + expect([shut.top, shut.height], tag).toEqual([open.top, open.height]) + if (withFrame) await expect(clearAll(page), tag).toBeDisabled() + // a disabled rail button reads as disabled: never the enabled colour + const colours = await page.evaluate(() => { + const c = (s: string) => getComputedStyle(document.querySelector(s)!).color + return { off: c('.react-flow__controls-button.rf-frame'), on: c('.react-flow__controls-button.rf-focus') } + }) + expect(colours.off, tag).not.toBe(colours.on) + await lockBtn(page).click() + await expect(frameTool(page), tag).toBeEnabled() + } + } + }) + + test('locked: the two frame buttons are disabled, Tab skips them, and clicking them changes nothing; unlocked they work again', async ({ page }) => { + await openApp(page) + await resetAll(page) + await seed(page) + await addFrame(page) + await lockBtn(page).click() + expect(await locked(page)).toBe(true) + await expect(frameTool(page)).toBeDisabled() + await expect(clearAll(page)).toBeDisabled() + // Tab through the rail never lands on either + await page.locator('.react-flow__controls-button').first().focus() + for (let i = 0; i < 16; i++) { + const at = await page.evaluate(() => { + const a = document.activeElement + return a?.closest('.react-flow__controls') ? a.className : null + }) + if (at === null) break + expect(at, `Tab stop ${i + 1}`).not.toMatch(/\brf-frame(-clear)?\b/) + await page.keyboard.press('Tab') + } + // a click reaches nothing: no tool armed, the frame still there + await frameTool(page).click({ force: true }) + await clearAll(page).click({ force: true }) + expect(await page.evaluate(() => (window as unknown as Bridge).__loop.frame.getState().toolArmed)).toBe(false) + expect(await frameCount(page)).toBe(1) + // unlocked, both work again + await lockBtn(page).click() + await expect(frameTool(page)).toBeEnabled() + await clearAll(page).click() + expect(await frameCount(page)).toBe(0) + }) + + test('locking turns an armed frame tool off; a drag on the empty canvas then pans and draws no frame', async ({ page }) => { + await openApp(page) + await resetAll(page) + await seed(page) + await frameTool(page).click() + await expect(frameTool(page)).toHaveAttribute('aria-pressed', 'true') + await lockBtn(page).click() + expect(await locked(page)).toBe(true) + await expect(frameTool(page)).toHaveAttribute('aria-pressed', 'false') + expect(await page.evaluate(() => (window as unknown as Bridge).__loop.frame.getState().toolArmed)).toBe(false) + const v0 = await page.evaluate(() => (window as unknown as Bridge).__loop.rf.getViewport()) + const pane = await page.locator('.react-flow__pane').boundingBox() + const x = pane!.x + pane!.width - 120 + const y = pane!.y + pane!.height - 160 + await page.mouse.move(x, y) + await page.mouse.down() + await page.mouse.move(x - 80, y - 60, { steps: 6 }) + await page.mouse.up() + const v1 = await page.evaluate(() => (window as unknown as Bridge).__loop.rf.getViewport()) + expect([v1.x, v1.y], 'the drag panned').not.toEqual([v0.x, v0.y]) + expect(await frameCount(page)).toBe(0) + }) +}) 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 683caf0..6b5a28a 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 a67a08c..eae70f3 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-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 index 759c83e..136a62b 100644 Binary files a/e2e/flow-colour-views-visual.spec.ts-snapshots/flow-views-template-mmo-dark-chromium-win32.png and b/e2e/flow-colour-views-visual.spec.ts-snapshots/flow-views-template-mmo-dark-chromium-win32.png differ 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 index 6ed4c2e..120563a 100644 Binary files a/e2e/flow-colour-views-visual.spec.ts-snapshots/flow-views-template-mmo-light-chromium-win32.png and b/e2e/flow-colour-views-visual.spec.ts-snapshots/flow-views-template-mmo-light-chromium-win32.png differ diff --git a/e2e/large-graph-readability.spec.ts b/e2e/large-graph-readability.spec.ts index 6a25c03..681fef2 100644 --- a/e2e/large-graph-readability.spec.ts +++ b/e2e/large-graph-readability.spec.ts @@ -3599,8 +3599,9 @@ test.describe('LGR frame membership — a frame drag carries its contents (§LGR locked, ) await setLocked(true) - await expect(frameToolBtn(page)).toHaveCount(0) - await expect(clearFramesBtn(page)).toHaveCount(0) + // #338 — the tool and "Clear all frames" stay in the rail, disabled + await expect(frameToolBtn(page)).toBeDisabled() + await expect(clearFramesBtn(page)).toBeDisabled() // select via the strip still works await strip(page, 0).click({ position: { x: 10, y: 6 } }) expect((await frameHead(page)).selectedId).toBe(id) @@ -3617,7 +3618,7 @@ test.describe('LGR frame membership — a frame drag carries its contents (§LGR expect(await positions(page), 'locked: the nodes do not move').toEqual(p0) expect((await history(page)).past).toBe(h0.past) await setLocked(false) - await expect(frameToolBtn(page)).toHaveCount(1) + await expect(frameToolBtn(page)).toBeEnabled() // mobile: same contract await page.setViewportSize({ width: 390, height: 780 }) await expect(page.locator('.lgr-frame')).toHaveCount(1) diff --git a/e2e/mobile.spec.ts b/e2e/mobile.spec.ts index d134b0b..5aaefa6 100644 --- a/e2e/mobile.spec.ts +++ b/e2e/mobile.spec.ts @@ -368,6 +368,53 @@ test.describe('mobile view/run — Slice 2 chrome', () => { expect(await page.evaluate(() => document.activeElement?.tagName)).not.toBe('INPUT') }) + // #340 — iOS Safari zooms in on a focused text field under 16 px and stays + // zoomed; the More sheet's language search was 12 px. Every text field the + // phone shows takes at least 16 px, in each place one appears. + test('every text field the phone shows is ≥ 16px: the language search, the share link, the Monte-Carlo fields (#340)', async ({ page }) => { + const under16 = () => + page.evaluate(() => { + const NOT_TEXT = new Set(['checkbox', 'radio', 'range', 'file', 'color', 'button', 'submit', 'reset', 'hidden']) + return [...document.querySelectorAll('input, textarea, select')] + .filter((el) => !(el instanceof HTMLInputElement && NOT_TEXT.has(el.type))) + .filter((el) => el.getClientRects().length > 0 && getComputedStyle(el).visibility !== 'hidden') + .map((el) => ({ el: `${el.tagName.toLowerCase()}.${el.className}`, px: parseFloat(getComputedStyle(el).fontSize) })) + .filter((f) => f.px < 16) + }) + await loadDiagram(page) + expect(await under16(), 'the canvas').toEqual([]) + + // the More sheet, its language menu and the search field + await more(page).click() + expect(await under16(), 'the More sheet').toEqual([]) + await sheet(page, 'More').locator('button.lang-switch').click() + const search = page.locator('.lang-menu__search') + await expect(search).toBeVisible() + expect(await search.evaluate((el) => parseFloat(getComputedStyle(el).fontSize))).toBeGreaterThanOrEqual(16) + expect(await under16(), 'the language menu').toEqual([]) + expect(rectInside(await search.boundingBox(), PORTRAIT.width, PORTRAIT.height, 1)).toBe(true) + await search.focus() + await page.keyboard.type('fra') + await expect(page.locator('.lang-menu__item')).toHaveCount(1) + await page.keyboard.press('Escape') + await expect(search).toBeHidden() + expect(await page.evaluate(() => document.activeElement?.classList.contains('lang-menu__search') ?? false)).toBe(false) + await page.keyboard.press('Escape') + + // the share panel's link field + await more(page).click() + await sheet(page, 'More').locator('.sheet__row', { hasText: 'Share link' }).click() + await answerDialog(page, 'accept', /anyone with it/i) + await expect(page.locator('.sheet .share-pop__url')).toBeVisible() + expect(await under16(), 'the share panel').toEqual([]) + await page.keyboard.press('Escape') + + // the Monte-Carlo dialog + await runBar(page).getByRole('button', { name: 'Monte Carlo' }).click() + await expect(page.locator('.mcdlg__field input').first()).toBeVisible() + expect(await under16(), 'the Monte-Carlo dialog').toEqual([]) + }) + test('Timeline sheet opens and closes', async ({ page }) => { await loadDiagram(page) await runBar(page).getByRole('button', { name: /^Timeline/ }).click() diff --git a/e2e/whats-new.spec.ts b/e2e/whats-new.spec.ts index 8c1bf1b..3ed439f 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.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.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 cadf06e..da01e5e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "loop-studio", - "version": "0.21.3", + "version": "0.21.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "loop-studio", - "version": "0.21.3", + "version": "0.21.4", "dependencies": { "@fontsource/ibm-plex-mono": "^5.3.0", "@fontsource/ibm-plex-sans": "^5.3.0", diff --git a/package.json b/package.json index aa42033..c73ff0e 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "loop-studio", "private": true, - "version": "0.21.3", + "version": "0.21.4", "type": "module", "engines": { "node": ">=22.12.0" diff --git a/src/components/Canvas.tsx b/src/components/Canvas.tsx index d590b28..553ce54 100644 --- a/src/components/Canvas.tsx +++ b/src/components/Canvas.tsx @@ -1061,13 +1061,17 @@ export function Canvas() { {/* docs/large-graph-readability.md §LGR6 — the one-shot "draw a group frame" tool (desktop only; frame drawing is not on mobile, §LGR9). Armed ⇒ a pane drag rubber-bands a labelled rectangle behind the - nodes. Transient, session-only, never in the GraphDoc / undo. */} - {!isMobile && !canvasLocked && ( + nodes. Transient, session-only, never in the GraphDoc / undo. + #338 — while the canvas is locked it stays in place, `disabled` + (a saved frame is document content, #334), so the rail keeps its + height; locking also turns an armed tool off (store/editPolicy). */} + {!isMobile && ( (frameToolArmed ? disarmFrameTool() : armFrameTool())} title={frameToolArmed ? t('canvas.frame.drawing') : t('canvas.frame.draw')} aria-label={frameToolArmed ? t('canvas.frame.drawing') : t('canvas.frame.draw')} aria-pressed={frameToolArmed} + disabled={canvasLocked} className="rf-frame" > )} {/* §AF5 R4 — the DEFAULT clear removes BOTH kinds ("Clear all frames"). - Shown when either a manual or an auto frame exists. */} - {!isMobile && !canvasLocked && (framesExist || autoFramesExist) && ( + Shown when either a manual or an auto frame exists. #338 — + `disabled` while the canvas is locked, never removed. */} + {!isMobile && (framesExist || autoFramesExist) && ( { clearFrames() @@ -1114,6 +1119,7 @@ export function Canvas() { }} title={t('canvas.frame.clearAll')} aria-label={t('canvas.frame.clearAll')} + disabled={canvasLocked} className="rf-frame-clear" >