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 @@ -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

Expand Down
90 changes: 82 additions & 8 deletions css/layout.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -640,19 +655,20 @@ 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;
justify-content: center;
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);
Expand All @@ -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"] {
Expand All @@ -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;
Expand Down
74 changes: 66 additions & 8 deletions css/mobile.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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. */
Expand Down
44 changes: 31 additions & 13 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -89,25 +89,43 @@
</div>

<div class="tab-bar-wrap" id="tab-bar-wrap">
<div id="tab-bar" class="tab-bar"></div>
<button class="tab-overflow-btn" id="tab-overflow-btn" type="button" aria-haspopup="listbox" aria-expanded="false" aria-label="Show all workspace tabs" data-tip="All workspace tabs" onclick="toggleTabOverflowMenu(event)">
<svg viewBox="0 0 256 256" fill="currentColor"><path d="M213.66,101.66l-80,80a8,8,0,0,1-11.32,0l-80-80A8,8,0,0,1,53.66,90.34L128,164.69l74.34-74.35a8,8,0,0,1,11.32,11.32Z" /></svg>
</button>
<!-- One frame for everything the strip offers: the tabs (which scroll),
the chevron listing the ones scrolled out of sight, and the way back
to the tool window. The border is the frame's, not the scroller's,
so the last two stay put while the tabs move under them. -->
<div class="tab-strip" id="tab-strip">
<div id="tab-bar" class="tab-bar"></div>
<button class="tab-overflow-btn" id="tab-overflow-btn" type="button" aria-haspopup="listbox" aria-expanded="false" aria-label="Show all workspace tabs" data-tip="All workspace tabs" onclick="toggleTabOverflowMenu(event)">
<svg viewBox="0 0 256 256" fill="currentColor"><path d="M213.66,101.66l-80,80a8,8,0,0,1-11.32,0l-80-80A8,8,0,0,1,53.66,90.34L128,164.69l74.34-74.35a8,8,0,0,1,11.32,11.32Z" /></svg>
</button>
<!-- The view the tool window last showed, reopened in one click.
Inside the strip's frame but outside its scroller, so however
many workspaces there are it never scrolls away with them.
Labelled and wired by js/view.js; none of it is in bridge.js. -->
<span class="tab-strip-sep" aria-hidden="true"></span>
<button class="last-view-btn" id="hdr-last-view" type="button"></button>
</div>
</div>
<!-- The workspace strip, for a screen too narrow to hold one. The tab bar
is the wrong thing to give the narrowest row on the device: on a phone
there are one to three workspaces and switching is rare. This names the
active one and opens the same overflow listbox the strip's own button
opens, so there is one list of workspaces rather than two. Hidden above
900px, where the strip itself is back. -->
<button class="mobile-ws-btn" id="mobile-ws-btn" type="button"
aria-haspopup="listbox" aria-expanded="false" aria-label="Switch workspace"
data-tip="Switch workspace" onclick="toggleTabOverflowMenu(event)">
<span class="mobile-ws-dot" aria-hidden="true"></span>
<span class="mobile-ws-name" id="mobile-ws-name">Workspace</span>
<span class="mobile-ws-dirty" id="mobile-ws-dirty" hidden aria-hidden="true"></span>
<svg class="chevron" viewBox="0 0 256 256" aria-hidden="true" fill="currentColor"><path d="M213.66,101.66l-80,80a8,8,0,0,1-11.32,0l-80-80A8,8,0,0,1,53.66,90.34L128,164.69l74.34-74.35a8,8,0,0,1,11.32,11.32Z"/></svg>
</button>
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. -->
<div class="mobile-ws-pill" id="mobile-ws-pill">
<button class="mobile-ws-btn" id="mobile-ws-btn" type="button"
aria-haspopup="listbox" aria-expanded="false" aria-label="Switch workspace"
data-tip="Switch workspace" onclick="toggleTabOverflowMenu(event)">
<span class="mobile-ws-dot" aria-hidden="true"></span>
<span class="mobile-ws-name" id="mobile-ws-name">Workspace</span>
<span class="mobile-ws-dirty" id="mobile-ws-dirty" hidden aria-hidden="true"></span>
<svg class="chevron" viewBox="0 0 256 256" aria-hidden="true" fill="currentColor"><path d="M213.66,101.66l-80,80a8,8,0,0,1-11.32,0l-80-80A8,8,0,0,1,53.66,90.34L128,164.69l74.34-74.35a8,8,0,0,1,11.32,11.32Z"/></svg>
</button>
<span class="mobile-ws-sep" aria-hidden="true"></span>
<button class="last-view-btn mobile-last-view" id="mobile-last-view" type="button"></button>
</div>
<div class="ctx tab-overflow-menu" id="tab-overflow-menu" role="listbox" aria-label="Workspace tabs"></div>

<div class="hdr-right">
Expand Down
59 changes: 59 additions & 0 deletions js/view.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `<span class="last-view-ico" aria-hidden="true">${meta.icon}</span>`
+ `<span class="last-view-label">${meta.title}</span>`;
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();
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading