App nav dock and tool sidebar
The macOS-style icon dock that carries the app's top-level destinations, and the sidebar column that hosts it on every tool route.
- Component:
apps/debate-ai.com/components/layout/CategoryDock.tsx - Sidebar shell:
apps/debate-ai.com/components/layout/AppSidebarShell.tsx - Route predicates:
packages/debate-videos/src/components/category-gallery/sidebar-routes.ts(re-exported for app code asapps/debate-ai.com/lib/sidebar-routes.ts) - Dock primitive:
apps/debate-ai.com/lib/ui/layout/dock.tsx
What it shows
Five destinations — Videos, Shared, Debate, Practice vs AI, Docs — plus a
gear-icon Settings menu, each with a hover label and Alt+1…Alt+5
shortcuts in dock order.
Tools are not dock icons. The tools catalog is reached from the sidebar
nav tree's Coaching / Research / Practice sections, and the whole feature
catalog from the /features row in the Settings menu's Site Links submenu.
Holding the dock to five destinations is what lets it fit inside the sidebar
column it is hosted in — see below.
The Settings menu is the whole nav on a phone
The sidebar column below is md+ only: on a phone the dock is a bottom bar
and its Settings menu is the only navigation there is. So the menu carries a
submenu per sidebar section — Videos, Coaching, Research, Practice —
above its theme/links block, and those submenus are derived from the data
the sidebar itself renders rather than restated:
| Menu submenu | Comes from |
|---|---|
| Videos | SIDEBAR_VIDEO_LINKS (components/category-gallery/sidebar-video-links.ts) |
| Coaching / Research / Practice | SIDEBAR_TOOL_SECTIONS |
| Site Links / Debate Links | FOOTER_LINKS, split by its group field |
There is no "Apps" submenu. It used to be the menu's one catalog — the
dock's own five destinations as plain rows, then every surface in
feature-catalog.ts as a nested submenu per category — which made the
Settings menu a second, deeper copy of /features hanging off the dock that
already showed those five icons. The catalog is now one Features row
(/features) in Site Links, next to a Docs row (/docs) for the help
site; both are FOOTER_LINKS entries, so the sidebar footer carries them
too.
apps/debate-ai.com/lib/nav/dock-menu-sections.ts does that composition, and
lib/nav/__tests__/dock-menu-sections.test.ts fails if a sidebar destination
is not in the menu. That coupling is the point: the Videos links and the
glossary/rankings pair below the tree were in the sidebar and in no mobile
menu at all, because the menu was a hand-kept list.
The menu scrolls (max-h-[min(560px,80vh)]) — with the nav submenus above
the account block it can still run past a phone viewport.
/videos additionally prints the tree inline in its own md:hidden block,
under the quick-link tiles: the tiles are the Videos section only, so
ToolNavTree follows them with defaultExpanded={false} (its sections start
collapsed there — expanded, forty rows would push the video grid off the
screen).
Where it renders
The dock has two forms, and which one a route gets is decided by
hasEmbeddedDock:
Sidebar-hosted (<CategoryDock embedded />). On /videos (whose own
<aside> takes it through LecturesVideoGridView's dockSlot) and on every
route the tool-nav tree links to (where AppSidebarShell wraps the page in
the generic sidebar), the dock sits at the top of that column (300px wide until you
drag it — see Resizing the sidebar).
/doc hosts it in a column of its own instead. The REASON research
workspace already renders a sidebar — the files tree and the "Open Tabs"
list — and the generic one stood beside it as a second, taller nav column, so
that route is skipped by AppSidebarShell (hostsOwnSidebarDock, in
packages/debate-videos/src/components/category-gallery/sidebar-routes.ts).
The dock moves into the sidebar that remains:
apps/debate-ai.com/components/qwksearch/SidebarWithAppDock.tsx is the
SidebarComponent the workspace injects into ReasonDocs, and it puts
<CategoryDock embedded /> above the editor's own toolbar — in the band the
published <aside> reserves (pt-14) for the header qwksearch's own app
renders there. hasEmbeddedDock stays true for the route, so the fixed
fallback does not appear on top of it; below md the sidebar is a slide-over
sheet and the dock is left to its own mobile bottom bar.
/videos/rankings and /videos/dictionary are branches of LecturesPage
that render their own content instead of the grid, and they used to return it
bare. Because hasEmbeddedDock reports every /videos path as already
carrying a sidebar-hosted dock, the fixed fallback stayed hidden as well — so
two rows of the sidebar's own tree answered, on desktop, with no navigation
at all beyond a "Back" link. LecturesSidebarShell
(packages/debate-videos/src/panels/LecturesSidebarShell.tsx) wraps both in
the same column: dock slot, VideoSidebarTree, footer.
That <aside> is hidden md:flex, and the same hasEmbeddedDock rule keeps
CategoryDock's mobile bottom bar hidden, so below md the two pages were
still bare. The shell now carries a md:hidden block as well — QuickLinksGrid,
ToolNavTree defaultExpanded={false}, Footer — matching what
LecturesVideoGridView renders below md.
packages/debate-videos/test/lectures-sidebar-shell.test.tsx pins it.
Neither column carries the search controls any more, on either page or on the grid: see Search floats over the results below. That is what makes the three columns identical, so the sidebar no longer changes shape when you cross between them.
Fixed fallback. Everywhere else — the home page, /login, /legal/* —
it renders fixed in the top-left corner on desktop and as a bottom bar on
mobile. The two never show at once.
Route matching is by path prefix, so a nested route under a tree entry
(/cards/awards under /cards, a document route under /doc) keeps the
sidebar rather than dropping to the fixed dock. The trailing / in the
comparison keeps /docs from matching /doc.
Search floats over the results
VideoSearchBar — the search input, the season and style dropdowns, the sort,
favourites, thumbnail and grid/rows toggles and the result count — used to
render twice on every /videos page: stacked in the desktop <aside>
above the nav tree, and again in a StickyHeader at the top of the
md:hidden column. It cost the sidebar roughly a third of its height, which
pushed the nav tree below the fold on a laptop, and the two copies held
separate local input state, so a half-typed query appeared to vanish when the
viewport crossed md.
FloatingVideoSearch
(packages/debate-videos/src/components/video-search/FloatingVideoSearch.tsx)
replaces both with one instance, mounted at the top of the results panel:
sticky top-3withh-0, so it rides the scroll and costs the grid no vertical space;- collapsed to a 36px round button in the panel's top-right corner;
- opens on hover (desktop), on click or tap (both), and on focus — so Tab reaches it without a pointer;
- stays open while focus is inside it, or while a search term is set
(
keepOpen), and closes on Escape or apointerdownoutside; - the panel is
hidden, not unmounted, so the input keeps its value (and its 2s debounce) across an accidental hover-out; - the wrapper is
pointer-events-none— only the button and the open panel take clicks, so nothing is stolen from the video cards underneath.
Results open as rows
useVideoState starts viewMode at "list", so /videos opens as
VideoListRows rather than VideoGrid. The dense list fits roughly four
times as many results on a screen; the thumbnail grid is one toggle away in
the floating search panel. Nothing persists the choice — it resets per page
load.
Resizing the sidebar
The sidebar column is ResizableSidebarLayout
(packages/debate-videos/src/ui/layout/ResizableSidebarLayout.tsx), a
react-resizable-panels group. AppSidebarShell, LecturesSidebarShell and
LecturesVideoGridView all render their <aside> through it, so the handle,
the limits and the saved width are shared:
- The panel is sized in pixels: 300px by default, 220–640px, and
groupResizeBehavior="preserve-pixel-size"keeps it that wide when the window resizes. The content panel has a 320px floor. - The last dragged width is stored under the
app-sidebar-widthlocalStorage key and applied in a layout effect through the panel's imperativeresize, not throughdefaultSize, so the server render and the first client render match. Nothing is saved until that restore has run. Double-clicking the separator resets todefaultSize(library behaviour). - The library's group defaults to
height: 100%; overflow: hidden, and its panels scroll their own content. Both are overridden, so the page still scrolls the way it did and the<aside>stayssticky top-0 h-screen. - Below
mdthe sidebar panel and the separator are hidden with CSS (max-md:…:hidden!), not unmounted. Crossing the breakpoint therefore never remounts the page beside them, which would reload a framed destination. - During a drag the library sets
pointer-events: noneon the panels, so a framed destination cannot swallow the pointer.
Tests that mount a page with this sidebar under jsdom need a ResizeObserver
stub. The group observes its panels on mount, and jsdom has no
ResizeObserver (see video-watch-page.test.tsx).
Staying inside the sidebar
A dock sized to its own contents (w-max, the default fluid: false form)
is wider than the sidebar at its default 300px. Hosted there it
either forced the sidebar to scroll sideways or reached over its border onto
the page beside it — which on /reason-editor and /doc is a CardMirror
editor.
The sidebar-hosted form passes fluid to Dock, which:
- takes its width from the column (
w-full max-w-full min-w-0) instead of from the item count, and - wraps an overflowing row onto a second line (
flex-wrap) instead of growing past the column, so a future sixth or seventh destination still cannot escape it.
CategoryDock also shrinks the icons for that form: EMBEDDED_ICON_SIZE
(34px) at rest and EMBEDDED_MAGNIFICATION (46px) hovered. Six items at 34px
with gap-1.5 and p-2 come to 250px against 276px of usable column width,
and a magnified icon adds 12px more — so the row fits without wrapping at
every breakpoint, and the wrapping above is the backstop rather than the
normal case.
Hover labels stay overflow-visible: the sidebar column is the clipping
boundary, and clipping at the dock would hide every tooltip.
One collection highlighted at a time
Two highlight rules meet in VideoSidebarTree: the round collections come
from activeId (derived in LecturesVideoGridView from the style filter, the
favourites toggle and the slug), the lecture categories from
selectedCategory. The second is the trap — LecturesPage resets
selectedCategory to "all" on every video route, not only inside the
lecture library, so "All Lectures" was highlighted alongside whichever round
collection was open, and on the rankings and glossary pages as well.
The tree now takes browsingLectures (currentCategory === "lectures" && !selectedStyle && !showFavoritesOnly, and unset on the rankings/glossary
shell) and applies the category rule only when it is set.
test/sidebar-single-highlight.test.tsx asserts the highlighted rows of the
rendered tree in all three cases.
The feature catalog is a sidebar route
/features is not one of the nav tree's own links, but it gets the tree
anyway: EXTRA_SIDEBAR_HREFS in
debate-videos/src/components/category-gallery/sidebar-routes.ts adds it to
TOOL_SIDEBAR_HREFS, so isGenericToolSidebarRoute is true for it and
AppSidebarShell wraps it — which also suppresses CategoryDock's floating
instance, since the column now hosts one. The page itself carries no chrome
of its own any more; the "Back to lectures" pill it used to float over its
hero is the sidebar's job now.
Getting there had to change too. The footer under the tree rendered every
in-app row as origin + url — an absolute URL, which Next treats as external,
so "Features" tore the document down and rebuilt it with no sidebar and no
player. The footer's links now carry a hardNavigate flag
(ui/layout/footer-links.ts): only /docs, the statically exported help site
served from public/, sets it and keeps its full page load; every other app
route is a relative next/link href, and the dock's Site Links menu pushes
the same routes through the router. test/footer-links-navigation.test.tsx
pins that split.
The nav tree's headings
Below the dock, the same column carries the nav tree
(VideoSidebarTree on /videos, its ToolNavTree tail everywhere else).
It is three levels deep:
| Level | Element | What it is |
|---|---|---|
| 1 | h1 | A top-level section: Videos, Coaching, Research, Practice |
| 2 | h2 | A subgroup inside one — College Debates, Favorites, Lectures |
| 3 | span | A leaf link: a tool, a lecture category, a debate format |
Two rules hold for every h1:
-
It is not a link. The row renders without an
href, so clicking it does nothing but toggle the section. Each section's flagship tool is the first link inside it (/coachunder Coaching), so nothing became unreachable — a click on the heading no longer navigates away from the page you are on just because you wanted to see what else is in the group. -
Every section starts open, and they collapse independently. Opening one section no longer closes another. The tree is the only nav on the tool pages, and as an accordion — one section open at a time, closed sections rendering none of their links — reaching a tool in another section was always two clicks, with the list you were reading disappearing in between. The state is per-mount and sticky for the session, so collapsing a section you don't use keeps the column short; navigating into a section you had collapsed re-opens it (
sidebar-active-section.ts), so a dock button never lands you on a page whose nav is shut.The accordion existed for a reason worth naming: rendering every section up front puts around fifty
next/links in the sidebar on/videos, and the router used to prefetch an RSC payload for each one the moment the page mounted — a burst of requests racing the video feed on the page that already felt slowest. That cost is paid off separately: the tree links passprefetch={false}, so the rendered links are fetched on click rather than on sight, which is what makes a fully expanded tree affordable.Resolution is longest-match over the links the tree actually renders:
/cards/libraryopens Research, which lists it, and/docopens Research too ("Debate Docs"). A dock destination in no tool section —/cards,/debate— matches nothing and opens nothing.
SidebarToolSection.href outlives the heading link: sidebar-routes.ts
still folds it into the set of paths that get the tool sidebar.
Every row carries one Lucide glyph
SidebarToolLink.icon is required, and typed LucideIcon rather than
TreeItemIcon, so every tool row in the tree draws a glyph and every one of
them is a Lucide component. TreeItem renders them all through one exported
class, TREE_ITEM_ICON_CLASS (h-4 w-4 shrink-0 text-muted-foreground), which
a Lucide icon picks up through currentColor — so a section heading and its
tool rows are the same size and the same color.
That is why the type is narrowed. TreeItem also accepts an image icon (an
imported SVG/PNG from debate-videos' ui/icons, rendered through
next/image), and an image cannot take a text color — it draws at whatever
colors are baked into the file. The glossary/rankings pair at the end of
Practice used to be exactly that (icon-book.svg, icon-leaderboard.png) and
were the only two rows in the tool tree whose icons did not match the rest;
they are now BookOpen and ChartColumn. The image branch remains for the
video tree's own nodes, which are bespoke artwork rather than tool rows.
dock-menu-sections.ts takes { href, title } off these links for the mobile
Settings menu and does not use the icons — the menu draws one icon per
section, not per link.
Indentation follows nesting, not level — each expanded node pads its own
child list — so a leaf under College Debates sits a step deeper than one
hanging straight off Coaching, wherever in the tree it is.
Tests
packages/debate-videos/test/video-sidebar-render.test.tsx— the heading structure above: College Debates under a Videosh1, noh1inside an anchor, every section a toggle button, and only the section holding the current route open (with the closed ones rendering none of their links).packages/debate-videos/test/tool-nav-tree-icons.test.tsx— that every tool link and app-dock link carries an icon, that all of them are Lucide components rather than image sources, and that the rendered tree draws one glyph per row in the single shared icon color with no<img>among them.packages/debate-videos/test/sidebar-active-section.test.ts— the route-to-section mapping itself, including the longest-match and dock-wins-a-tie rules above.apps/debate-ai.com/lib/ui/__tests__/dock.test.tsx— thefluidvariant's classes, host-set icon sizing, and that magnification never shrinks an icon below its resting size.packages/debate-videos/test/sidebar-routes.test.ts— that/toolsis no longer a dock destination but is still a sidebar one, and the prefix matching (including/docsnot matching/doc).packages/debate-videos/test/sidebar-video-links.test.tsx— that the tree and the mobile quick-link tiles both render everySIDEBAR_VIDEO_LINKSentry, glossary and rankings included.packages/debate-videos/test/lectures-sidebar-docs-slot.test.tsx— that/videos'md:hiddenblock carries the tool sections, collapsed.packages/debate-videos/test/lectures-sidebar-shell.test.tsx— that the rankings and glossary branches render the dock slot, the whole nav tree and the footer, rather than a bare page, and that themd:hiddenblock below that column carries the quick links and the collapsed tool sections.packages/debate-videos/test/videos-search-and-layout.test.tsx— that the search controls render once, after</aside>rather than inside it, start collapsed with the panel hidden rather than unmounted, and thatuseVideoStateopens on"list".apps/debate-ai.com/lib/nav/__tests__/dock-menu-sections.test.ts— that every sidebar destination (videos, tool sections, footer links) is reachable from the Settings menu, which is the whole nav belowmd, that there is no Apps section, and that/featuresand/docsare reachable.apps/debate-ai.com/lib/layout/__tests__/frame-navigation.test.ts— which links a framed page hands up to the shell and which it leaves alone.
A sidebar link inside a framed page
The shell runs each dock destination in a same-origin <iframe>
(AppFrameProvider), which puts /videos' sidebar — tool tree included —
inside that frame. Those rows point at tools the dock never frames, and
following one used to navigate the frame: the tool rendered inside it with no
sidebar and no dock, and AppShell then threw the whole tab at a fresh
top-level load to recover. Both halves were visible — the nav vanished, then
everything reloaded.
FrameNavigationBridge takes the click instead. A capture-phase listener in
the framed document hands the path to the shell over postMessage, and the
shell routes there with the client router (or re-frames it, when the path is
itself a dock destination). The shell document never goes away, so the
sidebar and dock stay exactly where they were. topNavigationTarget
(lib/layout/frame-navigation.ts) decides what is handed up: not a modified
or middle click, not another origin, not /docs or /api (no client route
exists for either), and not a path under the same dock destination the
frame is already showing — /videos/lectures inside a framed /videos is
the frame navigating within itself.
A hard top-level load is still the fallback, used when no shell acknowledges
within ACK_TIMEOUT_MS, and the pathname effect is the second layer for a
frame that navigated itself some other way (page code, a redirect).
