Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .changes/rail-frame-buttons-locked.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{ "type": "user-facing", "releaseNoteId": "release:0.21.4" }
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
30 changes: 17 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions docs/canvas-edit-lock.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
12 changes: 12 additions & 0 deletions docs/mobile.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:**
Expand Down
111 changes: 111 additions & 0 deletions e2e/canvas-lock.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
})
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
7 changes: 4 additions & 3 deletions e2e/large-graph-readability.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down
47 changes: 47 additions & 0 deletions e2e/mobile.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLElement>('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()
Expand Down
2 changes: 1 addition & 1 deletion e2e/whats-new.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading