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/menu-keyboard.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{ "type": "user-facing", "releaseNoteId": "release:0.18.1" }
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.18.1 — 2026-10-06

A fix release (issue #307): the menus answer the keyboard the same way, and the phone's sheets take focus and step back one level at a time.

- **Every desktop menu button opens, moves and closes alike**: Templates, Insert module, File, Data, Help, the temporary-session chip, Theme and the Distribution export. A keyboard open puts focus on the first item, and Arrow Down or Arrow Up on a closed button opens it at the first or last item. Inside, the arrows wrap, Home and End jump, and disabled items are skipped. Escape returns to the button; Tab and Shift+Tab close the menu and move on. After a keyboard choice that opens no dialog, focus returns to the button; a dialog returns it there when it closes. Pointer use is unchanged.
- **Settings and the `⋯` button are disclosures**, not menus: Tab moves through them, and Escape closes them and returns to their button. Theme's three choices are announced as a choice of one. Language keeps its search field, and Arrow Down or Arrow Up on its closed button opens it at the first or last language.
- **On a phone, each sheet takes focus when it opens.** Escape in a sheet opened from More goes back to More, on the row that opened it; Close still closes everything. A sheet stays non-modal: the run bar and the update bar are still usable while it is open, by touch, keyboard and screen reader, and only what the sheet covers leaves the keyboard order. A dialog opened from a sheet returns focus to the row that opened it.
- **A dialog is modal for real, on every screen**: while one is open (the Monte Carlo dialog, a confirmation, About, the guided tour), nothing outside it can be reached by pointer, keyboard or screen reader. The update notice waits behind it and is back, unchanged, the moment it closes; while only a sheet is open it stays usable.

**No migration.** Three release-note lines in 18 languages, 16 of them without native review. The informational `meta.tool` string is now `loop-studio/0.18.1`.

## v0.18.0 — 2026-10-05

The third-party open-source licenses, inside the app (issue #301).
Expand Down
24 changes: 14 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,17 @@ Additional feature-specific design documents (localization, mobile, module
system, large-graph readability, simulation playback, edge routing, data
import, …) live under [`docs/`](docs/).

## Latest — v0.18.0
## Latest — v0.18.1

A fix release: the menus answer the keyboard the same way.

- **Every menu button** opens at its first item from the keyboard, moves with the arrows,
Home and End, and closes with Escape or Tab, back to its button
- **Settings and `⋯`** are disclosures you Tab through; Theme is a choice of one
- **On a phone**, a sheet takes focus when it opens, and Escape in a sheet opened from
More goes back to More; the run bar and the update bar stay usable

## v0.18.0

The third-party open-source licenses, inside the app.

Expand All @@ -167,15 +177,9 @@ A fix release: share links use the browser's own compression.
unchanged
- **A browser without them** makes no link and says so, and the open diagram is kept

## v0.17.0

- **Password-protected share links** — an optional password encrypts the diagram inside
the link, in the browser; the password is asked for before anything from the diagram is
shown, and a lost password cannot be recovered. A plain link is still the default

See [`CHANGELOG.md`](CHANGELOG.md) for the full notes of these releases, v0.16.0 (the
storage gate, temporary sessions and the Storage and privacy area), the v0.15 releases and
every earlier one.
See [`CHANGELOG.md`](CHANGELOG.md) for the full notes of these releases, v0.17.0
(password-protected share links), v0.16.0 (the storage gate, temporary sessions and the
Storage and privacy area), the v0.15 releases and every earlier one.

## Credits

Expand Down
8 changes: 5 additions & 3 deletions docs/guided-tour.md
Original file line number Diff line number Diff line change
Expand Up @@ -240,9 +240,11 @@ is resolved once:
page session (`offeredThisSession`), so a re-render, route change, or a
surface opening/closing after the check changes nothing.
4. **Z-order** (§GT4): the tour / Welcome layer is above the Canvas / Toolbar /
Timeline but **below** `ConfirmDialog` — a confirm can always appear over the
tour and take focus. (They should not coexist, but the ordering is fixed
regardless.)
Timeline and the PWA update bar but **below** `ConfirmDialog` — a confirm can
always appear over the tour and take focus. (They should not coexist, but the
ordering is fixed regardless.) The tour's card is a real modal dialog: while
it is open everything outside it is `inert`, the update bar included
(issue #307, docs/mobile.md §MV8a).

Manual entry via `Help → Take a tour` (§GT7) has **no** timing gate — the user
asked for it — beyond the normal focus handoff.
Expand Down
45 changes: 35 additions & 10 deletions docs/mobile.md
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,22 @@ Both are bottom sheets. **Shared sheet contract:**
`role="dialog"` + `aria-label`;
- a visible **Close** button (44 px), **Escape** closes, and focus **returns to
the trigger** on close; focus moves into the sheet on open;
- **a sheet is not modal** (issue #307): no `aria-modal` and no Tab trap,
because the run bar and the PWA update bar are used while a sheet is open
(MV-D11, MV-D18, MV-D19). They stay reachable by pointer, keyboard and screen
reader, and Tab follows the page's own order out of the sheet. Only what the
sheet's scrim covers for the pointer (the canvas, the top bar's `More`
button, the open-file card) is `inert` while it is open, so the keyboard and
a screen reader reach the same controls as a finger. A real dialog opened
over a sheet is modal like every real dialog: it traps Tab and everything
else, the sheet, the run bar and the PWA update bar included, is `inert`
until it closes (MV8a); focus then returns to the sheet row that opened it,
and the sheet is non-modal again. Only the top dialog or sheet answers
Escape (`src/ui/overlayStack.ts`);
- **Escape in a sheet opened from `More`** (Templates, Export, Filters, Help,
Share) goes back one level: it closes that sheet, reopens `More` and puts
focus on the row that opened it. Close and the scrim still close
everything;
- `env(safe-area-inset-bottom)` padding; max-height leaves the top bar visible;
the sheet body scrolls internally (`overflow-y: auto`; `overscroll-behavior:
contain`);
Expand Down Expand Up @@ -397,15 +413,24 @@ opening. Its mobile placement rules:
full-width minus the left/right safe-area insets. This keeps it away from the
crowded bottom edge (run bar + rising sheets) entirely, so it can collide with
neither.
- **Z-index — above everything**, so its two 44 px buttons (**Update** /
**Dismiss**) stay reachable even while an exclusive sheet or the MC dialog is
open: `--z-canvas < --z-runbar < --z-sheet <= --z-mc-dialog < --z-pwa-update`.
Bottom sheets open to at most ~55 vh and the MC dialog is centred with a
safe-area top margin, so the top-anchored bar and a sheet **do not overlap**
in practice; the z-order is the guarantee if they ever do.
- **Usable while a sheet is open; behind a real dialog (issue #307).** The
update notice can be used while a sheet is open. While a real modal dialog
is open (the MC dialog, a confirmation, About, the guided tour, any
`aria-modal` dialog) it is covered and inactive, and the moment that dialog
closes it is back in the state it was in. So its two 44 px buttons
(**Update** / **Dismiss**) sit above every sheet and the run bar but below
the tour and the dialogs:
`--z-sheet < --z-runbar < --z-pwa-update < --z-tour < --z-mc-dialog`, and
while a dialog is open everything outside it, the bar included, is `inert`
(`src/ui/overlayStack.ts`), so the pointer, Tab and a screen reader cannot
reach it either. Nothing about the waiting update is dropped: the bar is
still mounted behind the dialog's scrim. Bottom sheets open to at most
~55 vh, so the top-anchored bar and a sheet **do not overlap** in practice;
the z-order is the guarantee if they ever do.
- **The dialog layer (2026-10-02, issue #296).** The order in the stylesheet is
`canvas < open-file card < sheet < run bar < dialogs < PWA update bar`. Two
things did not follow it and were measured before being fixed:
`canvas < open-file card < sheet < run bar < PWA update bar < tour < dialogs`
(the update bar was above the dialogs until issue #307). Two things did not
follow it and were measured before being fixed:
- A dialog declared INSIDE a sheet (About, the contextual-tips dialog, the
export-author dialog) was drawn in the sheet's layer, so its own z-index
only competed with the sheet's other children. The run bar and the
Expand Down Expand Up @@ -460,7 +485,7 @@ opening. Its mobile placement rules:
| MV-D16a | opening files | no account / cloud sync (MV6a). `More` → `Import file` accepts Graph **and** Workspace JSON; a `#g1=` Share link is the other path. An **"Open a file"** card with the "No account sync" copy sits on the pristine first screen and clears once a document loads |
| MV-D17 | viewport height | `100dvh` with `100vh` fallback everywhere (MV4a); a `visualViewport` listener nudges the bottom bar if a browser lags; the fixed bottom bar stays on-screen through iOS address-bar / keyboard height changes |
| MV-D18 | PWA update bar | **not** in the exclusive set (MV8a) — a pending update never closes a sheet and a sheet never blocks it |
| MV-D19 | PWA update bar placement | fixed at the **top**, below the top bar (`top: calc(topbar + safe-area)`); **highest z-index** (`canvas < runbar < sheet <= mc-dialog < pwa-update`) so Update/Dismiss stay clickable with a sheet open; Canvas top padding grows by its height; can only ever occlude canvas |
| MV-D19 | PWA update bar placement | fixed at the **top**, below the top bar (`top: calc(topbar + safe-area)`); z-index above every sheet and the run bar, below the tour and the dialogs (`sheet < runbar < pwa-update < tour < mc-dialog`), so Update/Dismiss stay clickable with a sheet open and are covered and inert while a real dialog is open (issue #307); Canvas top padding grows by its height; can only ever occlude canvas |

## MV10. Required E2E

Expand Down Expand Up @@ -497,7 +522,7 @@ A dedicated **`mobile` Playwright project** — `devices['iPhone 13']`, run at
- with the update bar **and** a sheet open at once (open the Inspector while the
bar shows): the sheet's **Close**, the bar's **Update**, and the run bar's
**Play** are each fully visible and independently clickable — none is occluded
by another (the bar carries the highest z-index, MV8a);
by another (the bar is above every sheet and the run bar, MV8a);
- the Share result URL field: open `More` → Share, the selectable URL field's
rect is fully within the viewport and the text is selectable.

Expand Down
54 changes: 54 additions & 0 deletions docs/toolbar-responsive.md
Original file line number Diff line number Diff line change
Expand Up @@ -233,3 +233,57 @@ browser: the attribute at the first animation frame for every stored value,
desktop and mobile; no light or blank frame painted before a dark start, on the
browser's own screencast, including a throttled network; the choice survives a
reload; the boot script is in the page once.

## The menu keyboard contract (issue #307)

One contract for every desktop menu button, in `src/ui/useMenuKeyboard.ts`
(`useMenuTrigger` + `useMenuKeyboard`): Templates, Insert module, File, Data,
Help, the temporary-session chip, Settings → Theme and the Distribution
panel's export. It follows the WAI-ARIA menu-button pattern, with typeahead
left out.

- **Opening.** Enter, Space or a screen reader's activation opens the menu
at its first item; Arrow Down on the closed trigger opens it at the first
item, Arrow Up at the last. A pointer open leaves focus on the trigger.
- **Inside.** Arrow Down / Arrow Up move between items and wrap; Home and End
go to the first and last. Disabled items, hidden items and separators are
skipped (`usableItems`).
- **Leaving.** Escape closes the menu and returns focus to its trigger. Tab
and Shift+Tab close it and move to the element after or before the
trigger; Tab never moves between items. The popup is gone before Tab's
default action runs (`flushSync`), so focus cannot land inside it.
- **After a choice.** A keyboard choice that opens no dialog returns focus to
the trigger; one that opens a dialog leaves focus to the dialog, which
returns it to the trigger when it closes. A pointer choice never pulls
focus (`useReturnFocusAfterKeyboardChoice`).
- **Theme** offers one of three, so its items are `menuitemradio` with
`aria-checked`.
- **`aria-controls` only while the panel exists.** A popup mounts on open,
so Settings, `⋯`, File, the temporary-session chip and Theme name it in
`aria-controls` only while it is open. MEASURED with Narrator and Edge on a
comparison page: a button whose `aria-controls` named a panel absent while
closed was never read as "expanded" after opening, even on a re-read; with
the attribute only while the panel exists, "expanded" and "collapsed" were
both read at once.

Three controls are deliberately not menu buttons:

- **Settings and `⋯` are disclosures**: no `role="menu"`, the trigger
carries `aria-expanded` and `aria-controls`, the panel is a labelled
`role="group"`, Tab moves through it, opening leaves focus on the trigger,
and Escape inside closes it and returns to the trigger. A keyboard choice
in a group nested inside `⋯` still returns focus to `⋯`.
- **Language keeps its combobox**: real focus stays in the search field
(`aria-activedescendant`); Arrow Down on the closed trigger opens it at the
first language, Arrow Up at the last, Enter or Space at the current one.
- **Help** keeps the behaviour it had since issue #306 and now shares the
hook.

The phone's sheets are not modal; the open dialogs and sheets are ordered for
Escape in `src/ui/overlayStack.ts`: see docs/mobile.md §MV5. Every real
dialog (`aria-modal="true"`, at any width) makes everything outside it
`inert` while it is the top one, the PWA update bar included, which sits
behind the dialog layer meanwhile (docs/mobile.md §MV8a).

`e2e/menu-keyboard.spec.ts` runs the same contract on every menu above, the
disclosures, the Language combobox and the phone's sheets.
4 changes: 2 additions & 2 deletions e2e/data-import-guide.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -414,8 +414,8 @@ test('a 2-table import with a lookup-only table reports "0 (lookup only)" and th

test('keyboard only: the quick start, fields, role selects and buttons are reachable in order; Escape returns focus to the Data trigger', async ({ page }) => {
await dataButton(page).focus()
await page.keyboard.press('Enter') // opens the Data menu
await page.keyboard.press('Tab') // the first menu item follows the trigger in DOM order
await page.keyboard.press('Enter') // opens the Data menu at its first item (issue #307)
await expect(page.getByRole('menuitem').first()).toBeFocused()
await page.keyboard.press('Enter')
await expect(dialog(page)).toBeVisible()
const active = () => page.evaluate(() => {
Expand Down
Loading
Loading