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