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 as apps/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 submenuComes from
VideosSIDEBAR_VIDEO_LINKS (components/category-gallery/sidebar-video-links.ts)
Coaching / Research / PracticeSIDEBAR_TOOL_SECTIONS
Site Links / Debate LinksFOOTER_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-3 with h-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 a pointerdown outside;
  • 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-width localStorage key and applied in a layout effect through the panel's imperative resize, not through defaultSize, so the server render and the first client render match. Nothing is saved until that restore has run. Double-clicking the separator resets to defaultSize (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> stays sticky top-0 h-screen.
  • Below md the 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: none on 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:

LevelElementWhat it is
1h1A top-level section: Videos, Coaching, Research, Practice
2h2A subgroup inside one — College Debates, Favorites, Lectures
3spanA 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 (/coach under 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 pass prefetch={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/library opens Research, which lists it, and /doc opens 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 Videos h1, no h1 inside 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 — the fluid variant'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 /tools is no longer a dock destination but is still a sidebar one, and the prefix matching (including /docs not matching /doc).
  • packages/debate-videos/test/sidebar-video-links.test.tsx — that the tree and the mobile quick-link tiles both render every SIDEBAR_VIDEO_LINKS entry, glossary and rankings included.
  • packages/debate-videos/test/lectures-sidebar-docs-slot.test.tsx — that /videos' md:hidden block 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 the md:hidden block 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 that useVideoState opens 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 below md, that there is no Apps section, and that /features and /docs are 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.

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).