Part 11 of The Operator’s Cockpit, the series on driving one GopherTrunk daemon through one REST + SSE API from browser and terminal alike. The last four posts filled the console with panels. This post is about the *frame around them: the layout that turns the same SPA into a desktop console and a phone app, the command palette that reaches any panel in two keystrokes, and the PWA packaging that lets you install the whole thing on your phone and point it at a Raspberry Pi in the field.*
TL;DR: One
AppShellrenders four navigation surfaces — a desktop sidebar, a mobile top bar, a mobile bottom nav, and a full drawer — all driven by a single nav registry, so every panel is reachable on every device. A global ⌘K / Ctrl-K command palette fuzzy-jumps to any panel and is the discovery safety net for the deep ones. The SPA ships as an installable PWA: Vite’s PWA plugin emits a manifest and a Workbox service worker that precaches the whole bundle for offline-first loading — with a navigate-fallback denylist so the console’s service worker never shadows the daemon’s sibling apps (Config Builder, Signal Lab, RF Scope, Crypto Lab).
Key takeaways
- One shell, four nav surfaces.
AppShellcomposes a sidebar, top bar, bottom nav, and drawer; all four read the same registry, so a hidden or new tab appears consistently everywhere. - The command palette is the universal shortcut. ⌘K opens a fuzzy switcher over every nav item — the fastest path to any panel, and how the deep panels stay discoverable without cluttering the primary nav.
- The console installs like an app. Vite-Plugin-PWA emits the manifest and a Workbox SW that precaches the bundle, so it opens instantly and runs offline (the connect screen still needs a live daemon).
- The service worker knows its lane. A navigate-fallback denylist keeps the
main console’s SW from answering
/config/,/siglab/,/rfscope/,/cryptolab/, or/api/with its own cachedindex.html.
Cheat sheet
| Piece | What it does | Where it lives |
|---|---|---|
| App shell | composes all nav surfaces around the routed page | web/src/components/shell/AppShell.tsx |
| Bottom nav | mobile primary tabs + “More” | web/src/components/shell/BottomNav.tsx |
| Mobile drawer | full nav sheet behind “More” | web/src/components/shell/MobileDrawer.tsx |
| Top bar | title, live pill, palette trigger, hamburger | web/src/components/shell/TopBar.tsx |
| Command palette | ⌘K fuzzy quick-switcher | web/src/components/CommandPalette.tsx |
| PWA manifest + SW | installable, offline-first packaging | web/vite.config.ts (VitePWA) |
| SW denylist | don’t shadow sibling consoles | web/src/lib/swDenylist.ts |
In this post
- One shell, four nav surfaces — desktop and phone from one layout.
- The registry as the single source — why nav is data, not markup.
- The command palette — ⌘K, fuzzy ranking, keyboard-first.
- Installing as a PWA — manifest, precache, offline-first.
- The denylist — how the service worker avoids shadowing siblings.
One shell, four nav surfaces
AppShell is pure chrome. It owns no connection or data state — it composes the
navigation surfaces around whatever page the router hands it as children, and
manages exactly three bits of local UI state: the drawer, the palette, and the
sidebar’s collapsed flag.
// web/src/components/shell/AppShell.tsx (shape)
export function AppShell({ children }: Props) {
const [drawerOpen, setDrawerOpen] = useState(false);
const [paletteOpen, setPaletteOpen] = useState(false);
const isDesktop = useIsDesktop();
// Global ⌘K / Ctrl-K toggles the palette from anywhere.
useEffect(() => { /* window keydown → setPaletteOpen(o => !o) */ }, []);
// The drawer is mobile-only; close it if the viewport grows to desktop.
useEffect(() => { if (isDesktop && drawerOpen) setDrawerOpen(false); }, [isDesktop, drawerOpen]);
return (
<div className="min-h-full flex">
<Sidebar collapsed={collapsed} onToggleCollapse={toggleCollapse} /> {/* desktop */}
<div className="flex-1 flex flex-col min-w-0">
<TopBar onOpenMore={() => setDrawerOpen(true)} onOpenPalette={() => setPaletteOpen(true)} />
<main id="main" className="flex-1 p-3 sm:p-4 pb-24 sm:pb-4">{children}</main>
</div>
<BottomNav onOpenMore={() => setDrawerOpen(true)} /> {/* mobile */}
<MobileDrawer open={drawerOpen} onClose={() => setDrawerOpen(false)} />
<CommandPalette open={paletteOpen} onClose={() => setPaletteOpen(false)} />
<ToastViewport />
</div>
);
}
The responsiveness is entirely Tailwind breakpoints, not JS branching: the
Sidebar is hidden below sm, the BottomNav and TopBar hamburger are hidden
at sm and up. The same DOM serves both; CSS decides which surface shows. That’s
why a phone rotating to landscape, or a browser window growing, just works — and
why the drawer-close effect exists at all, to tidy up the one case CSS can’t (a
mobile-only overlay left open when the viewport crosses into desktop).
Note the accessibility bones baked into the frame: a “Skip to content” link that
appears on focus, a semantic <main id="main">, and pb-24 bottom padding so the
fixed bottom nav never covers the last row of a scrolling panel. The shell is
small, but it carries the whole app’s keyboard and screen-reader story.
The registry as the single source
None of these four surfaces hard-code a list of panels. They all read a shared nav registry, and each surface asks it a slightly different question. The bottom nav takes the primary items and filters hidden ones:
// web/src/components/shell/BottomNav.tsx (shape)
export function BottomNav({ onOpenMore }: Props) {
const hidden = new Set(useShared((s) => s.hiddenTabs));
const items = PRIMARY_ITEMS.filter((i) => !hidden.has(tabKey(i)));
// …render four primaries + a fifth "More" button that opens the drawer
}
The drawer asks for the grouped view (useVisibleNavGroups), the palette asks
for the flat visible list (useVisibleNavItems), and the top bar looks up the
current route’s label in NAV_ITEMS. One data source, four projections. The
payoff is the same one the whole series keeps hitting: a tab hidden via config
(web.tabs) disappears from every surface at once, and a new panel is a new
registry entry, not four edits. It’s the exact discipline the TUI applies to its
own tab strip — the same web.tabs keys hide a panel in both UIs, a thread we
pick up in Part 12.
The command palette
The palette is the fastest path to any panel and the discovery net for the deep ones — the panels that don’t earn a slot in the primary nav still turn up the moment you type. It’s a ⌘K-triggered dialog over the flat visible nav list, with arrow keys to move, Enter to activate, Escape to close, and a focus trap so the keyboard stays inside it:
// web/src/components/CommandPalette.tsx (shape)
export function CommandPalette({ open, onClose }: Props) {
const items = useVisibleNavItems();
const results = useMemo(() => filterItems(items, query), [items, query]);
// …ArrowUp/Down move `active`; Enter → choose(results[active]); Esc closes
function choose(item) {
onClose();
if (item.external) window.open(item.to, "_blank", "noopener");
else navigate(item.to);
}
}
// filterItems ranks: label startsWith (3) > label includes (2) > keyword hit (1).
function filterItems(items, query) {
// …empty query returns everything in registry order; else score + sort desc
}
The ranking is intentionally simple and predictable: a label that starts with your query beats one that merely contains it, which beats a keyword match. Empty query lists everything in registry order, so ⌘K with no typing is a full index. External items (the sibling consoles) open in a new tab; internal ones navigate in place. It’s a small component, but it’s the single control that makes a twenty-something-panel console feel small — you never hunt through nav, you type two letters.
Installing as a PWA
The console is a static bundle with no runtime Node dependency and no CDN fetches, which is exactly what makes it installable. Vite-Plugin-PWA emits the manifest and a Workbox service worker at build time:
// web/vite.config.ts (shape)
VitePWA({
registerType: "autoUpdate",
manifest: {
name: "GopherTrunk", short_name: "GopherTrunk",
display: "standalone", start_url: "./", scope: "./",
theme_color: "#0f172a", background_color: "#0f172a",
icons: [ /* favicon.svg, purpose any + maskable */ ],
},
workbox: {
// Precache the whole SPA so it installs offline-first and loads instantly.
globPatterns: ["**/*.{js,css,html,svg,png,ico,webmanifest}"],
navigateFallbackDenylist: SW_NAVIGATE_FALLBACK_DENYLIST,
},
});
display: "standalone" and the relative start_url/scope are what let a phone
“Add to Home Screen” the console and launch it chromeless, pointed at whatever
daemon it was installed from. The Workbox globPatterns precache the entire
bundle, so the UI itself loads instantly and works without a network round-trip
for its assets — the connect screen still needs a live daemon to do anything
useful, but the app is always there. The base path defaults to ./, so the
same dist/ works opened via file://, hosted at a static path, or rooted at
/. One build, every deployment shape.
How that principle shaped the front-end
- Everything is inlined at build. No CDN scripts, no external fonts — the precache list is meaningful because the whole app is genuinely self-contained.
autoUpdatekeeps installs fresh. A new daemon build ships a new SW that updates the installed app on next load, so a field phone doesn’t drift.- API responses are never cached. The SW precaches assets only;
/api/*and/metricsare always fetched live, so the console never shows stale daemon state from a cache.
The denylist: knowing its lane
There’s a sharp edge to a service worker scoped at /: by default its
navigateFallback answers any navigation with the cached index.html. But the
daemon mounts sibling consoles at subpaths — Config Builder at /config/, Signal
Lab at /siglab/, RF Scope at /rfscope/, Crypto Lab at /cryptolab/. Without a
guard, opening one of those in a new tab would get the main console’s
index.html, and the tab would boot at a route React Router doesn’t know — a
blank page. The denylist is the fix:
// web/src/lib/swDenylist.ts (shape)
export const SW_NAVIGATE_FALLBACK_DENYLIST: RegExp[] = [
/^\/api\//, /^\/metrics/,
/^\/config\//, /^\/siglab\//, /^\/rfscope\//, /^\/cryptolab\//,
];
Any navigation matching one of these is not served the main SPA’s fallback — it
goes to the network, so the sibling app loads. This list is kept in sync with the
external console links in the nav registry, and a unit test (swDenylist.test.ts)
enforces that sync so a new sibling console can’t be added without teaching the
service worker to stay out of its way. It’s a tiny file that encodes an important
boundary: one console’s offline cache must never eat another console’s front door.
Where this goes next
Part 12
leaves the browser entirely for the terminal — the Bubbletea TUI that drives the
same REST + SSE API from an SSH session. It’s the other half of the series’
through-line made concrete: an Elm-model reducer, a polling fan plus an SSE pump,
and a tab strip that hides the same panels the web web.tabs config hides here.
FAQ
How does the same UI serve a desktop and a phone?
AppShell renders all four navigation surfaces into one DOM; Tailwind breakpoints
show the sidebar on desktop and the top bar + bottom nav + drawer on mobile. There
is almost no JS branching — CSS decides which surface is visible, so resizing or
rotating just works.
What powers the command palette? The shared nav registry. ⌘K opens a fuzzy quick-switcher over every visible nav item, ranked by how the query matches the label (startsWith > includes > keyword). It’s the fastest route to any panel and how the deep panels stay discoverable.
Does the PWA work offline? The app shell does — Workbox precaches the whole bundle, so the console loads instantly and runs without a network round-trip for its assets. But it’s an operator console for a live daemon: the connect screen still needs a reachable daemon to show real data. API responses are never cached.
Why is there a service-worker denylist?
Because the console’s SW is scoped at /, and the daemon mounts sibling apps at
/config/, /siglab/, /rfscope/, /cryptolab/. The denylist stops the SW from
answering those navigations (and /api/, /metrics) with its own cached
index.html, which would otherwise open those tabs blank.
Can I install it pointed at any daemon?
Yes — start_url/scope are relative (./), so installing from a given daemon’s
web console launches the app pointed back at that daemon. The bundle is fully
self-contained, so it also works served from any static path or even file://.
Series navigation
Part 11 of 14 · ← Part 10: Write Mode — Mutating Config Safely · Next → Part 12: The TUI Cockpit — The Same API in Your Terminal