Dock
The bottom tab bar: how it's built, how it behaves, and the full story of keeping it above Android's gesture bar. First of the docs deep dives.
Anatomy
A floating bottom tab bar with a sliding highlight pill. Generalized from three tabs to N when the AI blog arrived — nothing in the bar knows the count. Two sections, two docks: the main dock (Home, Resume, AI blog) renders from TABS, the docs dock (README, Components, Checklist, Dock) from DOCS_TABS.
- One source of truth per section
chrome.jsTABS/DOCS_TABSrender the section's links and set--tab-count; the grid and the pill width derive from it, never a hardcoded count.site.jsreads tab paths back out of the dock DOM, so there is no parallel path list to keep in sync — the SPA works within whichever section's dock is showing. - The pillA single live
::beforeelement gliding on a.28stransform transition the instant--active-indexmoves. It sits on a tab or between tabs — never outside the dock — and every icon and label stays visible. - Same element, every page in the sectionTapping a tab never does a document navigation:
site.jsfetches the page and swaps<main>in place while the tab bar stays mounted as the same element, so the pill glides instead of re-rendering. The URL updates viahistory.pushState; back and forward go throughpopstate. Crossing sections (main ⇄ docs) is a full document navigation — the dock's view-transition name is scoped per section (site-nav-main/site-nav-docs) so a 3-tab dock never morphs into a 4-tab one. - Position
bottom: calc(10px + env(safe-area-inset-bottom, 0px))— the inset is load-bearing on Android. See below.
Interaction
Acknowledge on pointerdown, commit on click — the native-app feel. Neither the animation nor the load waits for the other; both start on tap.
- pointerdownThe pill glides to the pressed tab, the tab dips via
.pressed, and the fetch starts early (~100ms head start; in-flight requests are deduped so the click reuses the prefetch). Primary button only. - clickCommits the navigation — fetch, swap,
pushState— with a 10ms haptic tick on Android (skipped under reduced-motion). - Cancelled pressDrag off or scroll takeover glides the pill back via the
pressPreview{from,to} state — no navigation, no vibration. The click handler commits the preview before readingfrom, or the previewed tab confuses from/to. - Rapid tapsStale responses never overwrite newer destinations; modified clicks (new tab, etc.) stay native.
- FailureA failed fetch falls back to a real navigation.
- RepaintThe drawer's current-page highlight and the dock's
no-activestate repaint through the same commit path (Drawer.paint/TabBar.painton everynavigateToandrenderPage) —chrome.jsonly bakes them for the initial load, so a same-document tab switch would otherwise leave the wrong drawer item highlighted, or the pill invisible after leaving a utility page.
Android vs. the gesture bar
Chrome on Android shows a dynamic bottom bar over the gesture area that retracts on scroll, growing the layout viewport downward. A bottom-anchored fixed dock follows it and slides under the gesture bar. Every theory about this died on a real device until measured.
- Dead end: constant offset
bottom: 10pxcan't move — wrong. The viewport moves underneath it. - Dead end: env() alone
bottom: calc(10px + env(safe-area-inset-bottom))changed nothing, because the meta tag lackedviewport-fit=cover— without cover everyenv()evaluates to 0. (The retracting bar is browser UI, not a system inset, so env() was also the wrong tool for the dynamic part on its own.) - Dead end: JS pin, resize-onlyRemembering the viewport height at scroll top and correcting the drift on resize events made the dock visibly dip-then-snap — the viewport had already moved when the correction landed.
- Dead end: JS pin, scroll-handlerCorrecting synchronously inside the scroll handler still fought the platform — judder on every scroll.
- Dead end: the rest
transition: bottom, hiding the dock until stable (flashed content behind it), hardcoded offsets,dvh − svhmath (polluted by the top toolbar hiding too), worker UA stamping. - The fix
viewport-fit=cover+ liveenv(). The CSS was already written for cover — the meta just never enabled it.chrome.js(parser-blocking, pre-paint) now appendsviewport-fit=coverto the viewport meta on Android; with cover,env(safe-area-inset-bottom)is evaluated against the live viewport, so as the toolbar retracts and the viewport grows, the inset grows in step and the dock tracks it with pure CSS — browser-composited, zero judder. The old "cover applies a frame late and jumps the dock" finding turned out moot: viewport growth and inset growth are the same quantity, so they self-cancel. Android-only — an iOS cover experiment broke scrolling under the Dynamic Island and couldn't be diagnosed without a physical iPhone, so iOS keeps the status quo.
Contract
- Pill placementOn a tab or between tabs. Never outside the dock; icons and labels always visible.
- No snapshot keyframesNever keyframe
transformon the pill's view-transition snapshot — it replaces the pill's base positioning and misplaces it over other tabs. - Utility pagesAbout, Contact, Privacy keep full-document navigations with cross-document view transitions. The dock renders
.no-activeand the pill hides. - Motion budget200–300ms, killed under
prefers-reduced-motion. - chrome.js stays parser-blockingNo
defer— the chrome must exist before the cross-document view-transition snapshot is captured, or the transition silently never runs.
Adding a tab
One TABS (or DOCS_TABS, for the docs section) entry in chrome.js — everything else follows: links, --tab-count, grid, pill width, drawer item, active states.
- Entry
{ page, href, label, icon, fillIcon? }—fillIconis optional and falls back to the outline icon when Tabler has no fill variant (that's why the AI tab reuses its sparkles). - DOM interfaceAll tab-bar DOM goes through the
TabBarobject (nav/current/paint/indexOf/keyForIndex/indexForKey); data throughfetchPage/prefetch/renderPage/swapTowith apageCacheand aninflightdedup map; the single commit path isnavigateTo(key, to). - Don'tDon't add tabs unasked. Main pages get a
TABSentry, docs pages aDOCS_TABSentry; utility pages get a drawer link instead.