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}`);
+});