From f906ab6d3fe5cad33eb9ed702dbd52a1da74c474 Mon Sep 17 00:00:00 2001 From: Shreyan C Date: Mon, 5 Oct 2026 06:36:57 +0530 Subject: [PATCH] fix(aux-window): make the tool window behave as the modal it says it is - Canvas shortcuts stop at the window: Delete removed selected states from behind the Library, and S/T/V/D, Shift+X, Enter and the arrows reached the hidden canvas. Only the window's own keys and save/reopen get through. - The focus trap counts Tab stops only, so Shift+Tab no longer walks out past the roving-tabindex tabs; a collapsed phone sheet is not a stop either. - Escape from a field inside the window closes it, after any popover or non-empty search box has had the key. - showStatus writes to the window's own toast while it is open; the canvas toast was underneath it. - The symbol-suggest popover sits above the window and the modals (1100). - On a phone the window sits under the header (195 < 200), so the menus the header drops are no longer covered. - Phone Grammar loses its doubled padding and left-aligns its actions; the Library rail gets the top inset the other rails have. --- CLAUDE.md | 2 +- css/library.css | 4 +++ css/mobile.css | 15 +++++++++ css/views.css | 26 ++++++++++++++- index.html | 1 + js/ui.js | 23 +++++++++++++ js/utils.js | 7 +++- js/view.js | 35 +++++++++++++++++--- tests/aux-window.test.js | 70 ++++++++++++++++++++++++++++++++++++++++ 9 files changed, 176 insertions(+), 7 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3173f57..db36415 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -203,7 +203,7 @@ Node internals are reached through `node.__parts` (`circle`, `label`, `ring`, `s `setView()` in [js/view.js](js/view.js) is the single entry point. The build view (canvas) is always mounted; `algo`, `grammar`, `reference` and `library` render as overlays on top of it, so canvas geometry stays measurable. Algorithms call `setView('build')` to reveal a result. -The four share one window (`#aux-overlay`), and its bar is a **tab strip across them**, built by `ensureAuxChrome()` from `AUX_META` on first open — so a view is renamed or re-iconed in one place, and none of the bar's controls are in `bridge.js`. Library comes first. `AUX_VIEWS` is the order of the strip, the More menu and the shortcut digits alike — `auxViewKey()` derives the digit from the position (Build is 1), so reordering is one edit. Each view keeps its place when closed, which is why the bar has a close button and no minimize. Maximize is a per-reader preference (`automata-aux-max`), desktop only. The rails share one width, `--aux-rail-w`, set by the window rather than per view: once the views sit a click apart, a rail edge that moves between them is visible. `#aux-ctx` names the canvas machine while Algorithms is open, because that is the view that reads it. The header reopens the last view from `#hdr-last-view`, inside the workspace strip's frame (`.tab-strip`) but outside its scroller so it never scrolls away with the tabs — the frame, not `.tab-bar`, holds the border, and the overflow chevron sits in it for the same reason. On a phone the same button shares a pill with the workspace switcher (`#mobile-last-view`); the window opens below the header there, so a second press closes it. The view is remembered per reader (`automata-aux-last`). [tests/aux-window.test.js](tests/aux-window.test.js) pins the strip, the arrow keys, maximize and the chip. +The four share one window (`#aux-overlay`), and its bar is a **tab strip across them**, built by `ensureAuxChrome()` from `AUX_META` on first open — so a view is renamed or re-iconed in one place, and none of the bar's controls are in `bridge.js`. Library comes first. `AUX_VIEWS` is the order of the strip, the More menu and the shortcut digits alike — `auxViewKey()` derives the digit from the position (Build is 1), so reordering is one edit. Each view keeps its place when closed, which is why the bar has a close button and no minimize. Maximize is a per-reader preference (`automata-aux-max`), desktop only. The rails share one width, `--aux-rail-w`, set by the window rather than per view: once the views sit a click apart, a rail edge that moves between them is visible. `#aux-ctx` names the canvas machine while Algorithms is open, because that is the view that reads it. The header reopens the last view from `#hdr-last-view`, inside the workspace strip's frame (`.tab-strip`) but outside its scroller so it never scrolls away with the tabs — the frame, not `.tab-bar`, holds the border, and the overflow chevron sits in it for the same reason. On a phone the same button shares a pill with the workspace switcher (`#mobile-last-view`); the window opens below the header there, so a second press closes it. The view is remembered per reader (`automata-aux-last`). **The window is modal, and that has to be enforced in four places, not declared once:** canvas shortcuts stop at it (`auxWindowKey` in `ui.js` — Delete used to remove selected states from behind the Library), Escape from a field inside it closes it (`view.js`), `showStatus` writes to the window's own `#aux-status` while it is open (the canvas toast is under it), and a popover a view opens must sit above 890 (`.sym-suggest` is 1100). On a phone the window is z 195, *under* the header: the header is a stacking context at 200, so a higher window covered every menu it drops. [tests/aux-window.test.js](tests/aux-window.test.js) pins the strip, the arrow keys, maximize, the chip and those four. ### Reference diff --git a/css/library.css b/css/library.css index ade75b2..49d7bbe 100644 --- a/css/library.css +++ b/css/library.css @@ -36,6 +36,10 @@ .aux-overlay #v-library .algo-content { padding: 0 40px 56px; } +/* The other three rails open on a group heading or a search box; this one + opens on a link, which sat flush against the window's bar. */ +#lib-nav-list { padding-top: 10px; } + /* The aux overlay sets the monospace face for its tool views; the Library reads as prose, so it takes the sans for controls and says where else. Inside :where(), so this sets a default and never outranks a rule below diff --git a/css/mobile.css b/css/mobile.css index 6a2735b..3fc3116 100644 --- a/css/mobile.css +++ b/css/mobile.css @@ -1254,12 +1254,18 @@ screen where scrolling it out of sight costs the most. It stops being *resizable* — a drag handle over a bottom sheet is a gesture collision — and gives up most of its height, but it stays put. */ + /* No padding, as on the desktop (see .aux-overlay .gram-main in + css/views.css): each child pads itself to 14px. The phone's generic + `.algo-content, .gram-main { padding: 16px }` stacked a second inset on + top, so the workbench sat at 30px from the edge with 16px of dead space + over its bar, where the other three views sit at 14px. */ .aux-overlay .gram-main { flex: 1 1 auto; width: 100%; height: 100%; min-height: 0; min-width: 0; + padding: 0; overflow: hidden; } @@ -1276,8 +1282,17 @@ gap: 8px; } + /* When the actions wrap under the title they take the row and start at its + left edge, with only the fold button pushed to the end. Pushed right as a + group, they hung off the far side of an otherwise empty line. */ .gram-ed-acts { gap: 4px; + flex: 1 1 auto; + margin-left: 0; + } + + .gram-ed-fold { + margin-left: auto; } .gram-source, diff --git a/css/views.css b/css/views.css index 1754d77..f08da19 100644 --- a/css/views.css +++ b/css/views.css @@ -316,6 +316,18 @@ overflow: hidden; } +/* The window's own toast (showStatus routes here while it is open). The + canvas's sits at the top of the canvas; this one sits at the foot of the + window, clear of the bar and the headings under it, above the views. */ +.aux-overlay .status.aux-status { + top: auto; + bottom: 18px; + left: 50%; + transform: translateX(-50%); + max-width: calc(100% - 48px); + z-index: 6; +} + /* Each view is a nav + content pair that must fill the modal. */ .aux-overlay-body > div { flex: 1; @@ -394,6 +406,13 @@ border: 0; border-radius: 0; box-shadow: none; + /* Under the header's layer (200), not over it. The header is a stacking + context, so every menu it drops — More, the model picker, the + workspace list — is drawn at 200 whatever its own z-index says, and at + 890 the window covered them: More opened, and nothing appeared. The + window starts below the header, so it gives up nothing by sitting + under it; it still covers the phone's sheets (160) and bar (180). */ + z-index: 195; } /* The centered animation translates by -50%; a full-screen modal must not, @@ -3590,10 +3609,15 @@ body.aux-open .canvas-aurora > i { box-shadow: 0 0 0 2px var(--red-border); } +/* Above the dialog layers (the tool window's 890, a modal's 900), because the + fields it completes live in both — the Algorithms view's test words, the + grammar workbench's, the transition dialog's. At 800 it opened, took the + Tab key, and was drawn underneath the window it belonged to. Below the + tooltip (1200), which can name one of its chips. */ .sym-suggest { position: fixed; display: none; - z-index: 800; + z-index: 1100; background: var(--surface2); border: 1px solid var(--border2); border-radius: 8px; diff --git a/index.html b/index.html index 14ccd35..688044a 100644 --- a/index.html +++ b/index.html @@ -1265,6 +1265,7 @@

+
diff --git a/js/ui.js b/js/ui.js index d6e2909..a91477c 100644 --- a/js/ui.js +++ b/js/ui.js @@ -1424,6 +1424,28 @@ export async function pasteFromSystemClipboard() { // ══════════════════════════════════════════════════════════════════ // KEYBOARD SHORTCUTS // ══════════════════════════════════════════════════════════════════ + +// The tool window is a dialog, and the canvas is out of sight under it — so a +// key that edits the diagram, switches its tool or steps its run must not +// reach it. Delete used to remove the selected states from behind the +// Library. What is left live is the window's own (Escape, the view digits, +// the UTM simulator's keys in Algorithms) and the document's (save, reopen a +// closed tab). +function auxWindowKey(e) { + if (e.ctrlKey || e.metaKey) { + return e.key === 's' || (e.key === 'S' && e.shiftKey) || (e.shiftKey && (e.key === 't' || e.key === 'T')); + } + if (e.key === 'Escape' || e.key === '1') return true; + if (AUX_VIEWS.some(v => auxViewKey(v) === e.key)) return true; + // Not on a focused control: Space and Enter press a button, and the arrows + // walk the tab strip. + if (App.view === 'algo' && App.currentAlgo === 'utm' + && (e.key === ' ' || e.key === 'Enter' || e.key === 'ArrowLeft' || e.key === 'ArrowRight')) { + return !(e.target.closest && e.target.closest('button, a, [role="tab"]')); + } + return false; +} + document.addEventListener('keydown', e => { const tag = e.target.tagName; if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return; @@ -1447,6 +1469,7 @@ document.addEventListener('keydown', e => { // header menu must not switch the canvas tool underneath it. if (e.target.closest && e.target.closest('.ctx-i, .model-item, .tab-overflow-item')) return; if (handleWorkspaceShortcut(e)) return; + if (AUX_VIEWS.includes(App.view) && !auxWindowKey(e)) return; if (e.ctrlKey || e.metaKey) { if (e.key === 'z') { e.preventDefault(); undo(); } if (e.key === 'y' || e.key === 'Z') { e.preventDefault(); redo(); } diff --git a/js/utils.js b/js/utils.js index 0c3c08d..20fe896 100644 --- a/js/utils.js +++ b/js/utils.js @@ -152,8 +152,13 @@ export function performClear() { resetSim(); emit(Change.GRAPH); } +// The canvas's toast sits on the canvas, under the tool window while one is +// open — so "Grammar copied", "Could not download" and every other message +// sent from inside the window went unread. The window has its own. export function showStatus(msg) { - const b = $('status-bar'); b.textContent = msg; b.classList.add('show'); + const inWindow = document.body && document.body.classList.contains('aux-open'); + const b = (inWindow && $('aux-status')) || $('status-bar'); + b.textContent = msg; b.classList.add('show'); clearTimeout(b._t); b._t = setTimeout(() => b.classList.remove('show'), 2500); } diff --git a/js/view.js b/js/view.js index 6a4d4e0..11df354 100644 --- a/js/view.js +++ b/js/view.js @@ -328,6 +328,29 @@ export function setView(v) { export let auxReturnFocus = null; +const AUX_TAB_STOPS = ['a[href]', 'button:not([disabled])', 'input:not([disabled])', 'select:not([disabled])', + 'textarea:not([disabled])', '[tabindex]'].map(s => `${s}:not([tabindex="-1"])`).join(', '); + +// Escape from a field inside the window closes it, the way it closes every +// other dialog. The canvas shortcuts ignore keys typed into fields, which is +// where the window's own Escape lives — so with the caret in the Algorithms +// search, the grammar editor or a word to parse, Escape did nothing at all. +// Bubble phase, after the field's own handlers: a completion list or an open +// select that took the key has already said so with preventDefault. +document.addEventListener('keydown', e => { + if (e.key !== 'Escape' || e.defaultPrevented) return; + if (!AUX_VIEWS.includes(App.view)) return; + if (typeof anyModalOpen === 'function' && anyModalOpen()) return; + const t = e.target; + if (!t || !t.closest || !t.closest('#aux-overlay')) return; + if (t.tagName !== 'INPUT' && t.tagName !== 'TEXTAREA' && t.tagName !== 'SELECT') return; + // A search box with text in it: the browser's Escape empties it, and that + // is the one the reader meant. + if (t.type === 'search' && t.value) return; + e.preventDefault(); + closeAuxView(); +}); + // Keeps Tab from escaping the dialog while it is open. document.addEventListener('keydown', e => { if (e.key !== 'Tab') return; @@ -337,10 +360,14 @@ document.addEventListener('keydown', e => { if (typeof anyModalOpen === 'function' && anyModalOpen()) return; const shell = $('aux-overlay'); if (!shell) return; - const focusable = shell.querySelectorAll( - 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])' - ); - const visible = Array.prototype.filter.call(focusable, el => el.offsetParent !== null); + // Tab stops only. The strip's inactive tabs are tabindex -1 — a roving + // tabindex — and counting them made the first Library tab "first" while + // the browser's first stop was the active tab, so Shift+Tab walked straight + // out of the window into the panels behind it. A collapsed phone sheet is + // visibility: hidden, which offsetParent does not see. + const focusable = shell.querySelectorAll(AUX_TAB_STOPS); + const visible = Array.prototype.filter.call(focusable, el => el.offsetParent !== null + && (!el.checkVisibility || el.checkVisibility({ visibilityProperty: true }))); if (!visible.length) return; const first = visible[0]; const last = visible[visible.length - 1]; diff --git a/tests/aux-window.test.js b/tests/aux-window.test.js index 96eaf81..d08d95c 100644 --- a/tests/aux-window.test.js +++ b/tests/aux-window.test.js @@ -199,3 +199,73 @@ test('on a phone the button shares the workspace pill', () => { const end = pill.indexOf('id="mobile-last-view"'); assert.ok(pill.indexOf('id="mobile-ws-btn"') > 0 && pill.indexOf('id="mobile-ws-btn"') < end); }); + +// ── The window is modal ── + +test('canvas shortcuts do not reach the canvas under the window', () => { + const h = createHarness(); + const { App } = h.context; + App.states.push({ id: 'q0', name: 'q0', x: 0, y: 0 }, { id: 'q1', name: 'q1', x: 100, y: 0 }); + App.selectedStates.add('q0'); + h.context.setView('library'); + // Delete used to remove the selected state from behind the Library. + h.dispatchDocumentEvent('keydown', { key: 'Delete' }); + assert.equal(App.states.length, 2); + const tool = App.tool; + h.dispatchDocumentEvent('keydown', { key: 's' }); + assert.equal(App.tool, tool); + // The window's own keys still work: a digit moves along the strip. + h.dispatchDocumentEvent('keydown', { key: h.context.auxViewKey('reference') }); + assert.equal(App.view, 'reference'); + // And on the canvas, the same key does what it always did. + h.context.setView('build'); + App.selectedStates.add('q0'); + h.dispatchDocumentEvent('keydown', { key: 'Delete' }); + assert.equal(App.states.length, 1); +}); + +test('Escape from a field inside the window closes it, unless the field took it', () => { + const h = createHarness(); + const field = (extra = {}) => ({ + tagName: 'INPUT', type: 'text', value: '', + closest: sel => (sel === '#aux-overlay' ? h.getElement('aux-overlay') : null), + ...extra + }); + h.context.setView('algo'); + // A completion list that consumed the key has said so. + h.dispatchDocumentEvent('keydown', { key: 'Escape', target: field(), defaultPrevented: true }); + assert.equal(h.context.App.view, 'algo'); + // A search box with text in it: Escape empties it first. + h.dispatchDocumentEvent('keydown', { key: 'Escape', target: field({ type: 'search', value: 'nfa' }) }); + assert.equal(h.context.App.view, 'algo'); + h.dispatchDocumentEvent('keydown', { key: 'Escape', target: field() }); + assert.equal(h.context.App.view, 'build'); +}); + +test('a message sent from inside the window is shown in the window', () => { + const h = createHarness(); + h.context.setView('grammar'); + h.context.showStatus('Grammar copied'); + assert.equal(h.getElement('aux-status').textContent, 'Grammar copied'); + assert.equal(h.getElement('aux-status').classList.contains('show'), true); + h.context.setView('build'); + h.context.showStatus('Machine: DFA'); + assert.equal(h.getElement('status-bar').textContent, 'Machine: DFA'); +}); + +test('popovers the window opens are drawn above it, and the phone header menus too', () => { + const views = css.replace(/\r\n/g, '\n'); + const layout = readFileSync(join(ROOT, 'css/layout.css'), 'utf8').replace(/\r\n/g, '\n'); + const z = (src, selector) => { + const at = src.indexOf(`\n${selector} {`); + assert.ok(at >= 0, selector); + return Number(src.slice(at, src.indexOf('}', at)).match(/z-index: (\d+)/)[1]); + }; + assert.ok(z(views, '.sym-suggest') > z(views, '.aux-overlay'), 'the symbol popover completes fields inside the window'); + // On a phone the window sits under the header's stacking context, which + // caps every menu the header drops at the header's own z-index. + const header = z(layout, 'header'); + const phone = views.slice(views.indexOf('@media (max-width: 900px) {\n /* Full-screen below the header')); + const phoneZ = Number(phone.slice(0, phone.indexOf('\n }')).match(/z-index: (\d+)/)[1]); + assert.ok(phoneZ < header, `phone window z ${phoneZ} must be under the header's ${header}`); +});