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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions css/library.css
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions css/mobile.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -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,
Expand Down
26 changes: 25 additions & 1 deletion css/views.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -1265,6 +1265,7 @@ <h2 class="sr-only" id="aux-overlay-title"></h2>
</button>
</div>
</header>
<div class="status aux-status" id="aux-status" role="status" aria-live="polite"></div>
<div class="aux-overlay-body">
<button class="mobile-aux-sheet-scrim" id="mobile-aux-sheet-scrim" type="button"
aria-label="Close tool navigation" onclick="closeMobileAuxNav()"></button>
Expand Down
23 changes: 23 additions & 0 deletions js/ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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(); }
Expand Down
7 changes: 6 additions & 1 deletion js/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
35 changes: 31 additions & 4 deletions js/view.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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];
Expand Down
70 changes: 70 additions & 0 deletions tests/aux-window.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
});
Loading