From 07b95c3bb687054e936f6a3d56a4eb34770d26cb Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 03:09:58 +0000 Subject: [PATCH] Header: reopen the last tool view from the workspace strip A button at the right end of the workspace strip opens whichever view the tool window showed last (Library, Algorithms, Grammar or Reference), named and iconed from AUX_META, with its shortcut in the tooltip. - The strip gets a frame (.tab-strip) that holds the border, so the button sits inside the strip but outside the scroller: with many workspaces the tabs scroll under it and it never scrolls away. The overflow chevron moves into the frame for the same reason and is drawn flat, like the tabs it lists. - On a phone the button shares a pill with the workspace switcher - icon only below 560px. The window opens below the header there, so the button is lit while its view is open and a second press closes it. - The view is remembered per reader (automata-aux-last); Library until another view has been opened. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HPVKDwzQWjyqE1nApBQt6s --- CLAUDE.md | 2 +- css/layout.css | 90 ++++++++++++++++++++++++++++++++++++---- css/mobile.css | 74 +++++++++++++++++++++++++++++---- index.html | 44 ++++++++++++++------ js/view.js | 59 ++++++++++++++++++++++++++ tests/aux-window.test.js | 67 ++++++++++++++++++++++++++++++ 6 files changed, 306 insertions(+), 30 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d55441a..eb16fcc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -193,7 +193,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. [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`). [tests/aux-window.test.js](tests/aux-window.test.js) pins the strip, the arrow keys, maximize and the chip. ### Reference diff --git a/css/layout.css b/css/layout.css index 8bd9b8a..4c68a08 100644 --- a/css/layout.css +++ b/css/layout.css @@ -620,15 +620,30 @@ body:has(.rpanel.is-empty) .hdr-right { overflow: hidden; } -.tab-bar { +/* The strip's frame. It holds the border so that what sits beside the + scroller — the chevron, the last tool view — is inside the strip without + scrolling with it. */ +.tab-strip { display: flex; align-items: center; - gap: 1px; - background: transparent; + gap: 2px; + flex: 1; + min-width: 0; height: 36px; align-self: center; + padding-right: 3px; border: 1px solid var(--border); border-radius: 7px; +} + +.tab-bar { + display: flex; + align-items: center; + gap: 1px; + background: transparent; + height: 100%; + align-self: stretch; + border-radius: 6px 0 0 6px; flex: 1; min-width: 0; overflow-x: auto; @@ -640,6 +655,8 @@ body:has(.rpanel.is-empty) .hdr-right { transition: box-shadow var(--transition-fast); } +/* Drawn flat, like the tabs it lists: it is part of the strip now, and a + bordered box inside a bordered strip read as a second control bolted on. */ .tab-overflow-btn { display: none; align-items: center; @@ -647,12 +664,11 @@ body:has(.rpanel.is-empty) .hdr-right { width: 24px; height: 24px; align-self: center; - margin-right: 2px; flex: 0 0 auto; z-index: 1; - border: 1px solid var(--border); + border: 1px solid transparent; border-radius: 5px; - background: var(--bg); + background: transparent; color: var(--text3); cursor: pointer; transition: background var(--transition-fast), color var(--transition-fast), border-color var(--transition-fast); @@ -663,9 +679,9 @@ body:has(.rpanel.is-empty) .hdr-right { } .tab-overflow-btn:hover { - background: var(--surface); + background: var(--tab-hover-bg); color: var(--text2); - border-color: var(--border2); + border-color: var(--border); } .tab-overflow-btn[aria-expanded="true"] { @@ -685,6 +701,64 @@ body:has(.rpanel.is-empty) .hdr-right { transform: rotate(180deg); } +/* ── The last tool view ── + An unselected tab in everything but behaviour: same height, type and hover, + no box at rest. The divider is what says it is not one of the workspaces. */ +.tab-strip-sep { + flex: 0 0 auto; + width: 1px; + align-self: stretch; + margin: 7px 3px; + background: var(--border); +} + +.last-view-btn { + display: inline-flex; + align-items: center; + gap: 7px; + flex: 0 0 auto; + height: 30px; + padding: 0 10px; + border: 1px solid transparent; + border-radius: 5px; + background: transparent; + color: var(--text3); + font-family: var(--sans); + font-size: .74rem; + letter-spacing: .01em; + white-space: nowrap; + cursor: pointer; + transition: color var(--transition-fast), background var(--transition-fast), border-color var(--transition-fast); +} + +.last-view-btn:hover { + background: var(--tab-hover-bg); + color: var(--text2); + border-color: var(--border); +} + +.last-view-btn:focus-visible { + outline: none; + color: var(--text); + border-color: var(--tab-active-border); + box-shadow: 0 0 0 2px var(--focus-ring); +} + +.last-view-ico { + display: inline-flex; + width: 13px; + height: 13px; +} + +.last-view-ico svg { + width: 100%; + height: 100%; +} + +.last-view-btn.is-open { + color: var(--accent); +} + .tab-overflow-menu { max-height: min(60vh, 380px); overflow-y: auto; diff --git a/css/mobile.css b/css/mobile.css index bd06fd3..35bf8c8 100644 --- a/css/mobile.css +++ b/css/mobile.css @@ -18,7 +18,7 @@ .mobile-panel-scrim, .mobile-more-menu, .mobile-sheet-head, -.mobile-ws-btn, +.mobile-ws-pill, .mobile-aux-bar, .mobile-aux-sheet-scrim { display: none; @@ -85,21 +85,72 @@ and a bare class is (0,1,0), so `flex: 1 1 auto` on its own loses to it silently and the button collapses to its text. Capped, or on a tablet it stretches a two-word name across a third of the header. */ - header > .mobile-ws-btn { + header > .mobile-ws-pill { flex: 1 1 auto; - max-width: 200px; + max-width: 260px; } - .mobile-ws-btn { + /* The pill holds the box, so the workspace button and the last tool view + are two halves of one control — the shape the machine picker beside it + already has, a name and then an action. Each half is its own 32px target. */ + .mobile-ws-pill { display: flex; - align-items: center; - gap: 6px; + align-items: stretch; min-width: 0; height: 32px; - padding: 0 8px; border: 1px solid var(--border); border-radius: 7px; background: var(--surface); + overflow: hidden; + } + + .mobile-ws-sep { + flex: 0 0 auto; + width: 1px; + margin: 7px 0; + background: var(--border); + } + + .mobile-last-view { + height: auto; + padding: 0 12px; + border: 0; + border-radius: 0; + color: var(--text2); + font-size: .74rem; + touch-action: manipulation; + -webkit-tap-highlight-color: transparent; + } + + .mobile-last-view .last-view-ico { + width: 15px; + height: 15px; + } + + .mobile-last-view:hover { + background: transparent; + } + + .mobile-last-view:active { + background: var(--surface2); + color: var(--text); + } + + .mobile-last-view.is-open { + color: var(--accent); + } + + .mobile-ws-btn { + display: flex; + flex: 1 1 auto; + align-items: center; + gap: 6px; + min-width: 0; + height: 100%; + padding: 0 8px; + border: 0; + border-radius: 0; + background: transparent; color: var(--text2); font-family: var(--sans); font-size: .74rem; @@ -110,10 +161,17 @@ .mobile-ws-btn:active { background: var(--surface2); - border-color: var(--border2); color: var(--text); } + /* A phone has room for the icon, not the name; the tooltip and the + accessible name still carry it. */ + @media (max-width: 560px) { + .mobile-last-view .last-view-label { + display: none; + } + } + /* The tab strip's accent dot, kept: it is how a workspace is told apart at a glance everywhere else in the app, and the button is now the only place the active one is named. */ diff --git a/index.html b/index.html index b12f4ac..d0c80d8 100644 --- a/index.html +++ b/index.html @@ -89,25 +89,43 @@
-
- + +
+
+ + + + +
- + 900px, where the strip itself is back. It shares a pill with the last + tool view, so the phone's header has the strip's shape: the + workspace, a divider, the way back to the tool window. --> +
+ + + +
diff --git a/js/view.js b/js/view.js index bd6802d..6a4d4e0 100644 --- a/js/view.js +++ b/js/view.js @@ -185,6 +185,63 @@ function syncAuxContext() { } subscribe(Change.GRAPH, syncAuxContext); +// ── The last view, reopened from the header ── +// One button in the workspace strip (and one in the phone's workspace pill) +// opens whichever view the tool window showed last. It names that view, since +// what a click does changes with use and an icon alone would have to be +// decoded every time. Library until something else has been opened, as the +// first of the four. Remembered per reader, like maximize. +const AUX_LAST_KEY = 'automata-aux-last'; +const LAST_VIEW_BUTTONS = ['hdr-last-view', 'mobile-last-view']; +let lastAuxView = (() => { + try { + const v = localStorage.getItem(AUX_LAST_KEY); + return AUX_VIEWS.includes(v) ? v : AUX_VIEWS[0]; + } catch (e) { return AUX_VIEWS[0]; } +})(); + +export function lastAuxViewId() { return lastAuxView; } + +function rememberAuxView(v) { + if (v === lastAuxView) return; + lastAuxView = v; + try { localStorage.setItem(AUX_LAST_KEY, v); } catch (e) { } +} + +// On a phone the window opens below the header, so the button is still there +// while its view is showing: it is lit then, and a second press puts the view +// away — the same press that opened it. +export function openLastAuxView() { + if (App.view === lastAuxView) closeAuxView(); + else setView(lastAuxView); +} + +function syncLastViewButtons() { + const meta = AUX_META[lastAuxView]; + const open = App.view === lastAuxView; + for (const id of LAST_VIEW_BUTTONS) { + const btn = $(id); + if (!btn) continue; + // Wired on first sync rather than at module scope: init's setView('build') + // is the first sync, and by then the element is the page's own. + if (!btn.dataset.wired) { + btn.dataset.wired = '1'; + btn.addEventListener('click', openLastAuxView); + } + // Rebuilt only when the view changes, so a repaint never swaps the + // glyph out from under a hover. + if (btn.dataset.view !== lastAuxView) { + btn.dataset.view = lastAuxView; + btn.innerHTML = `` + + `${meta.title}`; + btn.setAttribute('aria-label', `Open ${meta.title}`); + btn.setAttribute('data-tip', `Open ${meta.title}`); + btn.setAttribute('data-tip-kbd', auxViewKey(lastAuxView)); + } + btn.classList.toggle('is-open', open); + } +} + // Populates the shared modal chrome for the given aux view. export function applyAuxChrome(v) { ensureAuxChrome(); @@ -224,6 +281,8 @@ export function setView(v) { else delete document.body.dataset.auxView; applyAuxChrome(v); + if (isAux) rememberAuxView(v); + syncLastViewButtons(); // Keep the trigger lit while an aux view is up, and mark the open item. The // trigger is now the More button, because that menu is where the three diff --git a/tests/aux-window.test.js b/tests/aux-window.test.js index c7ffb2e..96eaf81 100644 --- a/tests/aux-window.test.js +++ b/tests/aux-window.test.js @@ -132,3 +132,70 @@ test('Library comes first, and the More menu, the strip and the digits agree on assert.deepEqual(tabs(h).map(t => t.getAttribute('data-tip-kbd')), ['2', '3', '4', '5']); h.context.setView('build'); }); + +// ── The last view, reopened from the header ── + +function lastView(h, id = 'hdr-last-view') { + return h.getElement(id); +} + +test('the header button names the view the window showed last, Library before any', () => { + const h = createHarness(); + // The remembered view is module state an earlier test may have set, so the + // Library default is pinned on the source rather than observed here. + assert.match(readFileSync(join(ROOT, 'js/view.js'), 'utf8'), + /AUX_VIEWS\.includes\(v\) \? v : AUX_VIEWS\[0\]/); + h.context.setView('build'); + const btn = lastView(h); + h.context.setView('grammar'); + h.context.setView('build'); + assert.equal(btn.dataset.view, 'grammar'); + assert.match(btn.innerHTML, /Grammar/); + assert.equal(btn.getAttribute('aria-label'), 'Open Grammar'); + assert.equal(btn.getAttribute('data-tip-kbd'), h.context.auxViewKey('grammar')); + assert.equal(h.context.localStorage.getItem('automata-aux-last'), 'grammar'); +}); + +test('pressing it opens that view, and on a phone a second press puts it away', () => { + const h = createHarness(); + h.context.setView('reference'); + h.context.setView('build'); + lastView(h)._listeners.click(); + assert.equal(h.context.App.view, 'reference'); + assert.equal(lastView(h).classList.contains('is-open'), true); + lastView(h, 'mobile-last-view')._listeners.click(); + assert.equal(h.context.App.view, 'build'); + assert.equal(lastView(h, 'mobile-last-view').classList.contains('is-open'), false); +}); + +test('the desktop and phone buttons always name the same view', () => { + const h = createHarness(); + for (const v of h.context.AUX_VIEWS) { + h.context.setView(v); + h.context.setView('build'); + assert.equal(lastView(h).dataset.view, v); + assert.equal(lastView(h, 'mobile-last-view').dataset.view, v); + } +}); + +test('the button and the tab chevron sit inside the strip frame, outside the scroller', () => { + const strip = html.slice(html.indexOf('
0, 'the scroller is inside the frame'); + const scrollerEnd = strip.indexOf('
', scroller); + for (const id of ['tab-overflow-btn', 'hdr-last-view']) { + const at = strip.indexOf(`id="${id}"`); + assert.ok(at > scrollerEnd, `${id} is in the frame, after the scroller`); + } + // The frame holds the border, so nothing in it scrolls away with the tabs. + const layout = readFileSync(join(ROOT, 'css/layout.css'), 'utf8'); + const rule = name => (layout.match(new RegExp(`\\n${name.replace('.', '\\.')} \\{[^}]*\\}`)) || [''])[0]; + assert.match(rule('.tab-strip'), /border: 1px solid/); + assert.doesNotMatch(rule('.tab-bar'), /border:/); +}); + +test('on a phone the button shares the workspace pill', () => { + const pill = html.slice(html.indexOf('
0 && pill.indexOf('id="mobile-ws-btn"') < end); +});