# Sidebar Nav

> Collapsible workspace and chat navigation with searchable history.

- **Slug:** `sidebar-nav`
- **Kind:** component · **Category:** Navigation
- **Source:** ported from [Beautiful UI](https://github.com/slev12397/beautiful-ui/blob/main/components/primitives/SidebarNav.tsx) (MIT)
- **Docs page:** https://viewership.ai/ui/components/sidebar-nav
- **All files in one bundle:** https://viewership.ai/ui/components/sidebar-nav.bundle.txt
- **Structured JSON:** https://viewership.ai/ui/components/sidebar-nav.json

## Install

1. Import the foundation tokens once (download: https://viewership.ai/ui/raw/foundation.css.txt):

```astro
---
// src/layouts/Layout.astro
import "../styles/foundation.css";
---
```

2. Copy this file into `src/components/viewership/sidebar-nav/`:

   - `SidebarNav.astro` (raw: https://viewership.ai/ui/raw/sidebar-nav/SidebarNav.astro.txt)

3. Copy its registry dependencies, each into its own sibling folder under `src/components/viewership/`:

   - `glide-menu`: `GlideMenu.astro`, `glide-menu.ts` (docs: https://viewership.ai/ui/components/glide-menu.md)

npm packages: none.

## Usage

```astro
---
import SidebarNav from "../components/viewership/sidebar-nav/SidebarNav.astro";
---

<SidebarNav />
```

## Example

The gallery demo, verbatim. Use the import path from Usage instead of `./`.

```astro
---
import SidebarNav from "./SidebarNav.astro";

const chats = [
  { id: "weekly-report", label: "Weekly GA4 report" },
  { id: "spring-launch", label: "Spring launch campaign" },
  { id: "citations", label: "ChatGPT citation check" },
  { id: "brand-voice", label: "Brand voice guide" },
  { id: "reddit", label: "Reply to a Reddit thread" },
  { id: "approvals", label: "Campaign approval list" },
  { id: "email-draft", label: "Draft the spring email" },
  { id: "ads-audit", label: "Google Ads spend audit" },
];
---

<SidebarNav
  workspace={{ key: "northwind", name: "Northwind Outdoor", monogram: "N" }}
  recents={chats}
  height="560px"
/>
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `workspace` | `{ key, name, monogram? }` | `{ key: "workspace", name: "Workspace" }` | Current workspace. |
| `workspaces` | `SidebarWorkspace[]` |  | Switcher menu workspaces. Defaults to the current one. |
| `menuActions` | `{ key, label, icon? }[]` | `New workspace / Workspace settings / Invite team members` | Workspace menu actions. Sign out is always last. |
| `items` | `{ key, label, icon?, count?, href? }[]` | `Home / Invite users 3/10` | Primary rows. `icon`: built-in name (home, invite, inbox, calendar, chart, book, settings, spark) or inner SVG. `href` makes a link. |
| `recents` | `{ id, label, href? }[]` | `[]` | Searchable chat history. |
| `recentsLabel` | `string` | `"Chats"` | History label. |
| `activeNav / activeRecent` | `string` |  | Current item. Gets aria-current="page". |
| `newChat` | `boolean` | `true` | Show the New chat row. |
| `footerLabel / footerHref` | `string \| null` | `"Upgrade"` | Footer button. null hides it. |
| `collapsed` | `boolean` | `false` | Start as the 52px rail. |
| `fill / height` | `boolean / string` | `false / "600px"` | Fill parent height, or fixed height. |
| `Events` | `CustomEvent` |  | vs-sidebar-navigate { key }, vs-sidebar-pick { id, label }, vs-sidebar-new-chat, vs-sidebar-toggle { collapsed }, vs-sidebar-workspace { key }, vs-sidebar-menu { action }, vs-sidebar-footer. |

## Source

### `src/components/viewership/sidebar-nav/SidebarNav.astro`

```astro
---
/**
 * SidebarNav: collapsible workspace and chat navigation with a gliding hover
 * state and searchable history. Collapsing keeps every rail glyph centred
 * at x = 26 in both states.
 *
 * Ported from Beautiful UI `components/primitives/SidebarNav.tsx`
 * (MIT, © 2026 Shane Levine). Icons are Lucide (ISC).
 *
 * - Collapse and expand buttons use `aria-expanded`; the current item
 *   carries `aria-current="page"`.
 * - Items with `href` render as links; without, as buttons that fire an event.
 * - Without JS the expanded sidebar renders with working links.
 *
 * Events (bubbling, on the root):
 *   vs-sidebar-navigate   detail: { key }
 *   vs-sidebar-pick       detail: { id, label }
 *   vs-sidebar-new-chat
 *   vs-sidebar-toggle     detail: { collapsed }
 *   vs-sidebar-workspace  detail: { key }
 *   vs-sidebar-menu       detail: { action }
 *   vs-sidebar-footer
 *
 * @example
 * <SidebarNav workspace={{ key: "northwind", name: "Northwind Outdoor" }} recents={chats} />
 */
import type { HTMLAttributes } from "astro/types";
import GlideMenu from "../glide-menu/GlideMenu.astro";

export interface SidebarWorkspace {
  key: string;
  name: string;
  /** Single glyph for the menu's monogram. Defaults to the first letter. */
  monogram?: string;
}
export interface SidebarItem {
  key: string;
  label: string;
  /** A built-in icon name (home, invite, inbox, calendar, chart, book, settings, spark) or raw inner SVG markup (24×24, stroked). */
  icon?: string;
  /** Trailing meta, e.g. "3/10". */
  count?: string;
  href?: string;
}
export interface SidebarRecent {
  id: string;
  label: string;
  href?: string;
}

type Props = HTMLAttributes<"nav"> & {
  /** Current workspace. */
  workspace?: SidebarWorkspace;
  /** Every workspace listed in the switcher (defaults to just the current one). */
  workspaces?: SidebarWorkspace[];
  /** Workspace menu actions below the workspace list. */
  menuActions?: Array<{ key: string; label: string; icon?: string }>;
  items?: SidebarItem[];
  /** Label of the history section. */
  recentsLabel?: string;
  recents?: SidebarRecent[];
  /** Key of the current primary item (aria-current). */
  activeNav?: string;
  /** Id of the current chat (aria-current). */
  activeRecent?: string;
  /** Show the New chat row. */
  newChat?: boolean;
  footerLabel?: string | null;
  footerHref?: string;
  /** Start collapsed. */
  collapsed?: boolean;
  /** Fill the parent's height instead of `height`. */
  fill?: boolean;
  height?: string;
};

const {
  workspace = { key: "workspace", name: "Workspace" },
  workspaces,
  menuActions = [
    { key: "new-workspace", label: "New workspace", icon: "plus" },
    { key: "settings", label: "Workspace settings", icon: "settings" },
    { key: "invite", label: "Invite team members", icon: "invite" },
  ],
  items = [
    { key: "home", label: "Home", icon: "home" },
    { key: "invite", label: "Invite users", icon: "invite", count: "3/10" },
  ],
  recentsLabel = "Chats",
  recents = [],
  activeNav,
  activeRecent,
  newChat = true,
  footerLabel = "Upgrade",
  footerHref,
  collapsed = false,
  fill = false,
  height = "600px",
  class: className,
  style,
  "aria-label": ariaLabel = "Workspace navigation",
  ...rest
} = Astro.props;

const uid = `vs-sb-${Math.random().toString(36).slice(2, 8)}`;
const allWorkspaces = workspaces?.length ? workspaces : [workspace];

/* Lucide icons (ISC). */
const ICONS: Record<string, string> = {
  home: '<path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8" /><path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />',
  invite: '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" /><circle cx="9" cy="7" r="4" /><path d="M19 8v6" /><path d="M22 11h-6" />',
  edit: '<path d="M12 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" /><path d="M18.375 2.625a1 1 0 0 1 3 3l-9.013 9.014a2 2 0 0 1-.853.505l-2.873.84a.5.5 0 0 1-.62-.62l.84-2.873a2 2 0 0 1 .506-.852z" />',
  search: '<circle cx="11" cy="11" r="8" /><path d="m21 21-4.3-4.3" />',
  x: '<path d="M18 6 6 18" /><path d="m6 6 12 12" />',
  chevronDown: '<path d="m6 9 6 6 6-6" />',
  panelClose: '<rect width="18" height="18" x="3" y="3" rx="2" /><path d="M9 3v18" /><path d="m16 15-3-3 3-3" />',
  panelOpen: '<rect width="18" height="18" x="3" y="3" rx="2" /><path d="M9 3v18" /><path d="m14 9 3 3-3 3" />',
  plus: '<path d="M5 12h14" /><path d="M12 5v14" />',
  settings: '<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" /><circle cx="12" cy="12" r="3" />',
  logout: '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" /><path d="m16 17 5-5-5-5" /><path d="M21 12H9" />',
  check: '<path d="M20 6 9 17l-5-5" />',
  spark: '<path d="M12 3l1.8 7.2L21 12l-7.2 1.8L12 21l-1.8-7.2L3 12l7.2-1.8z" />',
  inbox: '<path d="M22 12h-6l-2 3h-4l-2-3H2" /><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" />',
  calendar: '<path d="M8 2v4" /><path d="M16 2v4" /><rect width="18" height="18" x="3" y="4" rx="2" /><path d="M3 10h18" />',
  chart: '<path d="M3 3v16a2 2 0 0 0 2 2h16" /><path d="M18 17V9" /><path d="M13 17V5" /><path d="M8 17v-3" />',
  book: '<path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H19a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1H6.5a1 1 0 0 1 0-5H20" />',
};
const icon = (name: string | undefined, size = 18, sw = 2) => {
  const inner = !name ? ICONS.home : name.includes("<") ? name : (ICONS[name] ?? ICONS.home);
  return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="${sw}" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${inner}</svg>`;
};

const rootStyle = [fill ? "--sb-h: 100%" : `--sb-h: ${height}`, typeof style === "string" ? style : ""].filter(Boolean).join("; ");
---

<nav
  class:list={["vs-sidebar-nav", className]}
  id={uid}
  data-collapsed={collapsed ? "true" : "false"}
  aria-label={ariaLabel}
  style={rootStyle}
  {...rest}
>
  <div class="sb-tree">
    <div class="sb-top">
      <button
        type="button"
        class="sb-workspace"
        data-workspace-trigger
        aria-haspopup="menu"
        aria-expanded="false"
        aria-controls={`${uid}-ws-menu`}
        inert={collapsed}
      >
        <span class="sb-logo" set:html={icon("spark")} />
        <span class="sb-copy sb-ws-name">{workspace.name}</span>
        <span class="sb-copy sb-ws-chevron" set:html={icon("chevronDown", 16)} />
        <span class="sb-sr">, switch workspace</span>
      </button>
      <button type="button" class="sb-collapse" data-collapse aria-label="Collapse sidebar" aria-expanded="true" aria-controls={uid} inert={collapsed}>
        <Fragment set:html={icon("panelClose")} />
      </button>
      <button type="button" class="sb-expand" data-expand aria-label="Expand sidebar" aria-expanded="false" aria-controls={uid} inert={!collapsed}>
        <Fragment set:html={icon("panelOpen")} />
      </button>
    </div>

    <GlideMenu class="sb-group" rowSelector="[data-row]" highlightBg="var(--vs-hover-2)" highlightRadius="7px" highlightInset="8px" highlightClass="sb-highlight">
      {
        newChat && (
          <button type="button" class="sb-row" data-row data-new-chat title={collapsed ? "New chat" : undefined}>
            <span class="sb-icon" set:html={icon("edit")} />
            <span class="sb-copy sb-label">New chat</span>
          </button>
        )
      }
      {
        items.map((item) => {
          const current = item.key === activeNav;
          const Tag = item.href ? "a" : "button";
          return (
            <Tag
              class="sb-row"
              data-row
              data-nav={item.key}
              href={item.href}
              type={item.href ? undefined : "button"}
              aria-current={current ? "page" : undefined}
            >
              <span class="sb-icon" set:html={icon(item.icon)} />
              <span class="sb-copy sb-label">{item.label}</span>
              {item.count && <span class="sb-copy sb-count">{item.count}</span>}
            </Tag>
          );
        })
      }
    </GlideMenu>

    <div class="sb-chats" data-chats inert={collapsed}>
      <div class="sb-copy sb-chats-head">
        <div class="sb-chats-label" id={`${uid}-chats-label`}>
          <Fragment set:html={icon("chevronDown", 16)} />
          <span>{recentsLabel}</span>
        </div>
        <button type="button" class="sb-search-btn" data-search-open aria-label={`Search ${recentsLabel.toLowerCase()}`} aria-expanded="false" aria-controls={`${uid}-search`}>
          <Fragment set:html={icon("search", 16)} />
        </button>
        <div class="sb-search" id={`${uid}-search`} data-search>
          <span class="sb-search-icon" set:html={icon("search", 15)} />
          <input type="search" class="sb-search-input" data-search-input placeholder={`Search ${recentsLabel.toLowerCase()}`} aria-label={`Search ${recentsLabel.toLowerCase()} history`} tabindex="-1" />
          <button type="button" class="sb-search-close" data-search-close aria-label="Close search" tabindex="-1">
            <Fragment set:html={icon("x", 16)} />
          </button>
        </div>
      </div>

      <GlideMenu class="sb-group sb-recents" rowSelector="[data-row]" highlightBg="var(--vs-hover-2)" highlightRadius="7px" highlightInset="8px" highlightClass="sb-highlight" role="list" aria-labelledby={`${uid}-chats-label`}>
        {
          recents.map((item) => {
            const current = item.id === activeRecent;
            const Tag = item.href ? "a" : "button";
            return (
              <div role="listitem" class="sb-li" data-recent-item>
                <Tag
                  class="sb-row sb-row--recent"
                  data-row
                  data-recent={item.id}
                  href={item.href}
                  type={item.href ? undefined : "button"}
                  title={item.label}
                  aria-current={current ? "page" : undefined}
                >
                  <span class="sb-copy sb-label">{item.label}</span>
                </Tag>
              </div>
            );
          })
        }
      </GlideMenu>
      <p class="sb-copy sb-empty" data-empty hidden>No {recentsLabel.toLowerCase()} found</p>
      <p class="sb-sr" aria-live="polite" data-search-status></p>
    </div>

    {
      footerLabel && (
        <div class="sb-copy sb-footer" data-footer inert={collapsed}>
          {footerHref ? (
            <a class="sb-footer-btn" href={footerHref}>{footerLabel}</a>
          ) : (
            <button type="button" class="sb-footer-btn" data-footer-btn>
              {footerLabel}
            </button>
          )}
        </div>
      )
    }
  </div>

  <div class="sb-menu" id={`${uid}-ws-menu`} popover="manual" data-ws-menu>
    <GlideMenu class="sb-menu-list" role="menu" aria-label="Workspace" arrowKeys highlightBg="var(--vs-hover-2)" highlightRadius="8px">
      {
        allWorkspaces.map((ws) => (
          <button
            type="button"
            class="sb-menu-row sb-menu-row--ws"
            data-menu-row
            role="menuitemradio"
            aria-checked={ws.key === workspace.key ? "true" : "false"}
            data-ws={ws.key}
          >
            <span class="sb-monogram" aria-hidden="true">{ws.monogram ?? ws.name.charAt(0)}</span>
            <span class="sb-menu-label sb-menu-label--strong">{ws.name}</span>
            <span class="sb-menu-check" set:html={icon("check", 18)} />
          </button>
        ))
      }
      <div class="sb-menu-sep" role="separator"></div>
      {
        menuActions.map((a) => (
          <button type="button" class="sb-menu-row" data-menu-row role="menuitem" data-action={a.key}>
            <span class="sb-menu-icon" set:html={icon(a.icon, 16)} />
            <span class="sb-menu-label">{a.label}</span>
          </button>
        ))
      }
      <div class="sb-menu-sep" role="separator"></div>
      <button type="button" class="sb-menu-row" data-menu-row role="menuitem" data-action="sign-out">
        <span class="sb-menu-icon" set:html={icon("logout", 16)} />
        <span class="sb-menu-label">Sign out</span>
      </button>
    </GlideMenu>
  </div>
</nav>

<script>
  function init(root: HTMLElement) {
    const emit = (name: string, detail?: unknown) => root.dispatchEvent(new CustomEvent(name, { bubbles: true, detail }));
    const q = <T extends HTMLElement = HTMLElement>(sel: string) => root.querySelector<T>(sel);

    const wsTrigger = q<HTMLButtonElement>("[data-workspace-trigger]")!;
    const collapseBtn = q<HTMLButtonElement>("[data-collapse]")!;
    const expandBtn = q<HTMLButtonElement>("[data-expand]")!;
    const chats = q("[data-chats]")!;
    const footer = q("[data-footer]");
    const menu = q("[data-ws-menu]")!;
    const searchOpenBtn = q<HTMLButtonElement>("[data-search-open]")!;
    const searchInput = q<HTMLInputElement>("[data-search-input]")!;
    const searchClose = q<HTMLButtonElement>("[data-search-close]")!;
    const empty = q("[data-empty]")!;
    const status = q("[data-search-status]")!;
    const newChatBtn = q<HTMLButtonElement>("[data-new-chat]");

    /* ── collapse ──────────────────────────────────────── */
    const navRows = Array.from(root.querySelectorAll<HTMLElement>("[data-nav], [data-new-chat]"));
    const setCollapsed = (collapsed: boolean, moveFocus: boolean) => {
      root.dataset.collapsed = String(collapsed);
      collapseBtn.inert = collapsed;
      wsTrigger.inert = collapsed;
      chats.inert = collapsed;
      if (footer) footer.inert = collapsed;
      expandBtn.inert = !collapsed;
      collapseBtn.setAttribute("aria-expanded", String(!collapsed));
      expandBtn.setAttribute("aria-expanded", String(!collapsed));
      // Icon-only rows keep a tooltip while collapsed.
      navRows.forEach((row) => {
        if (collapsed) row.title = row.querySelector(".sb-label")?.textContent ?? "";
        else row.removeAttribute("title");
      });
      if (collapsed) {
        closeMenu(false);
        closeSearch(false);
      }
      if (moveFocus) (collapsed ? expandBtn : collapseBtn).focus();
      emit("vs-sidebar-toggle", { collapsed });
    };
    collapseBtn.addEventListener("click", () => setCollapsed(true, true));
    expandBtn.addEventListener("click", () => setCollapsed(false, true));

    /* ── current item ──────────────────────────────────── */
    const setCurrent = (row: HTMLElement | null) => {
      root.querySelectorAll("[data-row][aria-current]").forEach((el) => el.removeAttribute("aria-current"));
      row?.setAttribute("aria-current", "page");
    };
    root.querySelectorAll<HTMLElement>("[data-nav]").forEach((row) => {
      row.addEventListener("click", () => {
        if (row.tagName === "BUTTON") setCurrent(row);
        emit("vs-sidebar-navigate", { key: row.dataset.nav });
      });
    });
    root.querySelectorAll<HTMLElement>("[data-recent]").forEach((row) => {
      row.addEventListener("click", () => {
        if (row.tagName === "BUTTON") setCurrent(row);
        emit("vs-sidebar-pick", { id: row.dataset.recent, label: row.textContent?.trim() });
      });
    });
    newChatBtn?.addEventListener("click", () => {
      setCurrent(null);
      emit("vs-sidebar-new-chat");
    });
    root.querySelector("[data-footer-btn]")?.addEventListener("click", () => emit("vs-sidebar-footer"));

    /* ── workspace menu (top layer) ────────────────────── */
    const supportsPopover = typeof HTMLElement.prototype.showPopover === "function";
    let menuOpen = false;
    const openMenu = (viaKeyboard: boolean) => {
      menuOpen = true;
      wsTrigger.setAttribute("aria-expanded", "true");
      menu.classList.add("is-open");
      if (supportsPopover) menu.showPopover();
      positionMenu();
      menu.classList.remove("is-anim");
      void menu.offsetWidth;
      menu.classList.add("is-anim");
      if (viaKeyboard) menu.querySelector<HTMLElement>('[aria-checked="true"]')?.focus();
    };
    function positionMenu() {
      const rect = wsTrigger.getBoundingClientRect();
      const width = menu.offsetWidth;
      menu.style.left = `${Math.max(8, Math.min(rect.left, window.innerWidth - width - 8))}px`;
      menu.style.top = `${rect.bottom + 6}px`;
    }
    function closeMenu(restoreFocus: boolean) {
      if (!menuOpen) return;
      menuOpen = false;
      wsTrigger.setAttribute("aria-expanded", "false");
      menu.classList.remove("is-open", "is-anim");
      if (supportsPopover && menu.matches(":popover-open")) menu.hidePopover();
      if (restoreFocus) wsTrigger.focus();
    }
    wsTrigger.addEventListener("click", (e) => (menuOpen ? closeMenu(false) : openMenu(e.detail === 0)));
    wsTrigger.addEventListener("keydown", (e) => {
      if (e.key === "ArrowDown" && !menuOpen) {
        e.preventDefault();
        openMenu(true);
      }
    });
    menu.addEventListener("click", (e) => {
      const row = (e.target as Element).closest<HTMLElement>("[data-menu-row]");
      if (!row) return;
      if (row.dataset.ws) {
        menu.querySelectorAll("[data-ws]").forEach((el) => el.setAttribute("aria-checked", String(el === row)));
        const name = row.querySelector(".sb-menu-label")?.textContent ?? "";
        const label = root.querySelector(".sb-ws-name");
        if (label) label.textContent = name;
        emit("vs-sidebar-workspace", { key: row.dataset.ws });
      } else emit("vs-sidebar-menu", { action: row.dataset.action });
      closeMenu(true);
    });
    menu.addEventListener("keydown", (e) => {
      if (e.key === "Escape" || e.key === "Tab") {
        if (e.key === "Escape") e.preventDefault();
        closeMenu(e.key === "Escape");
      }
    });
    const onPointerDown = (e: PointerEvent) => {
      const t = e.target as Element;
      if (menuOpen && !t.closest("[data-ws-menu]") && !wsTrigger.contains(t)) closeMenu(false);
    };
    const onResize = () => menuOpen && positionMenu();
    document.addEventListener("pointerdown", onPointerDown);
    window.addEventListener("resize", onResize);
    window.addEventListener("scroll", onResize, { passive: true });

    /* ── chat search: the field grows right → left from the search control ── */
    const recentItems = Array.from(root.querySelectorAll<HTMLElement>("[data-recent-item]"));
    const filter = () => {
      const query = searchInput.value.trim().toLowerCase();
      let shown = 0;
      recentItems.forEach((item) => {
        const match = (item.textContent ?? "").toLowerCase().includes(query);
        item.hidden = !match;
        if (match) shown += 1;
      });
      empty.hidden = !(query && shown === 0);
      status.textContent = query ? (shown ? `${shown} ${shown === 1 ? "result" : "results"}` : empty.textContent ?? "") : "";
    };
    const openSearch = () => {
      root.dataset.search = "open";
      searchOpenBtn.setAttribute("aria-expanded", "true");
      searchOpenBtn.tabIndex = -1;
      searchInput.tabIndex = 0;
      searchClose.tabIndex = 0;
      searchInput.focus();
    };
    function closeSearch(restoreFocus: boolean) {
      if (root.dataset.search !== "open") return;
      delete root.dataset.search;
      searchOpenBtn.setAttribute("aria-expanded", "false");
      searchOpenBtn.tabIndex = 0;
      searchInput.tabIndex = -1;
      searchClose.tabIndex = -1;
      searchInput.value = "";
      filter();
      if (restoreFocus) searchOpenBtn.focus();
    }
    searchOpenBtn.addEventListener("click", openSearch);
    searchClose.addEventListener("click", () => closeSearch(true));
    searchInput.addEventListener("input", filter);
    searchInput.addEventListener("keydown", (e) => {
      if (e.key === "Escape") {
        e.preventDefault();
        closeSearch(true);
      }
    });

    return () => {
      closeMenu(false);
      document.removeEventListener("pointerdown", onPointerDown);
      window.removeEventListener("resize", onResize);
      window.removeEventListener("scroll", onResize);
    };
  }

  const teardowns = new Set<() => void>();
  const boot = () =>
    document.querySelectorAll<HTMLElement>(".vs-sidebar-nav:not([data-vs-ready])").forEach((root) => {
      root.dataset.vsReady = "";
      teardowns.add(init(root));
    });
  boot();
  document.addEventListener("astro:page-load", boot);
  document.addEventListener("astro:before-swap", () => {
    teardowns.forEach((fn) => fn());
    teardowns.clear();
  });
</script>

<style>
  .vs-sidebar-nav {
    --sb-expanded: 224px;
    --sb-collapsed: 52px;
    --sb-duration: 280ms;
    --sb-copy-duration: 180ms;
    --sb-copy-offset: 8px;
    --sb-search-duration: 180ms;
    --sb-easing: var(--vs-ease-link);
    position: relative;
    display: flex;
    flex-shrink: 0;
    width: var(--sb-expanded);
    height: var(--sb-h, 600px);
    overflow: hidden;
    color: var(--vs-ink);
    font-family: var(--vs-font-sans);
    text-align: left;
    transition: width var(--sb-duration) var(--sb-easing);
  }
  .vs-sidebar-nav[data-collapsed="true"] {
    width: var(--sb-collapsed);
  }

  .sb-sr {
    position: absolute;
    width: 1px;
    height: 1px;
    margin: -1px;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
  }
  button,
  a {
    font: inherit;
    color: inherit;
    background: none;
    border: 0;
    padding: 0;
    text-decoration: none;
    cursor: pointer;
  }
  button:focus-visible,
  a:focus-visible {
    outline: none;
    box-shadow: var(--vs-focus-ring);
  }

  /* one persistent 224px tree, clipped by the shell */
  .sb-tree {
    display: flex;
    width: var(--sb-expanded);
    min-height: 0;
    flex-shrink: 0;
    flex-direction: column;
  }

  .sb-copy {
    opacity: 1;
    transform: translateX(0);
    transition:
      opacity var(--sb-copy-duration) ease-out,
      transform var(--sb-copy-duration) var(--sb-easing);
  }
  [data-collapsed="true"] .sb-copy {
    pointer-events: none;
    opacity: 0;
    transform: translateX(calc(var(--sb-copy-offset) * -1));
  }

  /* ── header ─────────────────────────────────────────── */
  .sb-top {
    position: relative;
    height: 40px;
    margin-bottom: 10px;
    flex-shrink: 0;
  }
  .sb-workspace {
    position: absolute;
    top: 4px;
    left: 8px;
    display: flex;
    width: 164px;
    height: 32px;
    align-items: center;
    padding: 0 8px;
    border-radius: 8px;
    text-align: left;
    transition: background-color 100ms ease-out, transform 100ms ease-out;
  }
  .sb-workspace:hover,
  .sb-workspace[aria-expanded="true"] {
    background: var(--vs-hover-2);
  }
  .sb-workspace:active {
    transform: scale(0.99);
  }
  .sb-logo {
    display: flex;
    width: 20px;
    height: 20px;
    flex-shrink: 0;
    align-items: center;
    justify-content: center;
    color: var(--vs-ink);
    transition: opacity var(--sb-copy-duration) ease-out;
  }
  .sb-ws-name {
    min-width: 0;
    flex: 1;
    margin-left: 6px;
    overflow: hidden;
    color: var(--vs-ink-2);
    font-size: 14px;
    font-weight: 500;
    text-overflow: ellipsis;
    white-space: nowrap;
  }
  .sb-ws-chevron {
    display: flex;
    flex-shrink: 0;
    margin-left: 4px;
    color: var(--vs-ink-3);
  }
  .sb-collapse,
  .sb-expand {
    position: absolute;
    display: flex;
    align-items: center;
    justify-content: center;
    border-radius: 8px;
    color: var(--vs-ink-3);
    transition:
      opacity var(--sb-copy-duration) ease-out,
      background-color 150ms ease-out,
      color 150ms ease-out;
  }
  .sb-collapse {
    top: 4px;
    right: 8px;
    width: 32px;
    height: 32px;
  }
  .sb-expand {
    top: 2px;
    left: 8px;
    width: 36px;
    height: 36px;
    opacity: 0;
    pointer-events: none;
  }
  .sb-collapse:hover,
  .sb-expand:hover {
    background: var(--vs-hover-2);
    color: var(--vs-ink);
  }
  [data-collapsed="true"] .sb-workspace {
    pointer-events: none;
  }
  [data-collapsed="true"] .sb-logo,
  [data-collapsed="true"] .sb-collapse {
    pointer-events: none;
    opacity: 0;
  }
  [data-collapsed="true"] .sb-expand {
    pointer-events: auto;
    opacity: 1;
  }

  /* ── rows ───────────────────────────────────────────── */
  .sb-group {
    display: flex;
    flex-direction: column;
    gap: 1px;
  }
  .sb-row {
    position: relative;
    display: flex;
    width: 208px;
    height: 32px;
    margin: 0 8px;
    align-items: center;
    padding: 0 8px;
    border-radius: 8px;
    color: var(--vs-ink-2);
    text-align: left;
    transition:
      width var(--sb-copy-duration) var(--sb-easing),
      background-color 150ms ease-out,
      color 150ms ease-out,
      transform 150ms ease-out;
  }
  [data-collapsed="true"] .sb-row {
    width: 36px;
  }
  .sb-row:active {
    transform: scale(0.98);
  }
  .sb-row[aria-current="page"] {
    background: var(--vs-hover-2);
    color: var(--vs-ink);
  }
  /* The gliding highlight stands in for the current row's fill while it is out. */
  .sb-group[data-glide-active] .sb-row[aria-current="page"] {
    background: transparent;
  }
  .sb-row:focus-visible {
    box-shadow: inset 0 0 0 2px var(--vs-accent);
  }
  .sb-icon {
    display: flex;
    width: 20px;
    height: 20px;
    flex-shrink: 0;
    align-items: center;
    justify-content: center;
  }
  .sb-label {
    min-width: 0;
    flex: 1;
    overflow: hidden;
    font-size: 14px;
    font-weight: 500;
    text-overflow: ellipsis;
    white-space: nowrap;
  }
  .sb-icon + .sb-label {
    margin-left: 6px;
  }
  .sb-count {
    flex-shrink: 0;
    margin-right: 8px;
    color: var(--vs-ink-3);
    font-size: 12px;
    font-weight: 500;
    font-variant-numeric: tabular-nums;
  }
  .vs-sidebar-nav :global(.sb-highlight) {
    transition: width var(--sb-copy-duration) var(--sb-easing);
  }
  [data-collapsed="true"] :global(.sb-highlight) {
    right: auto;
    width: 36px;
  }

  /* ── chats ──────────────────────────────────────────── */
  .sb-chats {
    min-height: 0;
    flex: 1;
    margin-top: 12px;
    overflow-y: auto;
  }
  .sb-chats-head {
    position: relative;
    height: 32px;
    margin: 0 8px 4px;
  }
  .sb-chats-label {
    position: absolute;
    inset: 0;
    display: flex;
    align-items: center;
    gap: 6px;
    padding: 0 8px;
    color: var(--vs-ink-3);
    font-size: 12.5px;
    font-weight: 500;
    transition:
      opacity var(--sb-search-duration) var(--sb-easing),
      transform var(--sb-search-duration) var(--sb-easing);
  }
  .sb-search-btn {
    position: absolute;
    z-index: 1;
    top: 0;
    right: 0;
    display: flex;
    width: 32px;
    height: 32px;
    align-items: center;
    justify-content: center;
    border-radius: 8px;
    color: var(--vs-ink-3);
    transition:
      opacity var(--sb-search-duration) ease-out,
      background-color 150ms ease-out,
      color 150ms ease-out,
      transform 150ms ease-out;
  }
  .sb-search-btn:hover {
    background: var(--vs-hover-2);
    color: var(--vs-ink);
  }
  .sb-search-btn:active {
    transform: scale(0.96);
  }
  .sb-search {
    position: absolute;
    z-index: 2;
    top: 0;
    right: 0;
    display: flex;
    width: 28px;
    height: 32px;
    align-items: center;
    overflow: hidden;
    border-radius: 8px;
    background: var(--vs-field);
    box-shadow: var(--vs-shadow-hairline);
    color: var(--vs-ink-3);
    opacity: 0;
    pointer-events: none;
    transition:
      width var(--sb-search-duration) var(--sb-easing),
      opacity var(--sb-search-duration) var(--sb-easing);
  }
  .sb-search:focus-within {
    color: var(--vs-ink-2);
  }
  [data-search="open"] .sb-search {
    width: 100%;
    opacity: 1;
    pointer-events: auto;
  }
  [data-search="open"] .sb-chats-label {
    pointer-events: none;
    opacity: 0;
    transform: translateX(-4px);
  }
  [data-search="open"] .sb-search-btn {
    pointer-events: none;
    opacity: 0;
  }
  .sb-search-icon {
    display: flex;
    flex-shrink: 0;
    margin-left: 8px;
  }
  .sb-search-input {
    min-width: 0;
    flex: 1;
    margin-left: 6px;
    border: 0;
    background: transparent;
    color: var(--vs-ink);
    font: inherit;
    font-size: 13px;
    font-weight: 500;
    outline: none;
    -webkit-appearance: none;
    appearance: none;
  }
  .sb-search-input::placeholder {
    color: var(--vs-ink-3);
  }
  .sb-search-input::-webkit-search-cancel-button {
    display: none;
  }
  .sb-search-close {
    display: flex;
    width: 32px;
    height: 32px;
    flex-shrink: 0;
    align-items: center;
    justify-content: center;
    border-radius: 8px;
    color: var(--vs-ink-3);
    transition: background-color 150ms ease-out, color 150ms ease-out, transform 150ms ease-out;
  }
  .sb-search-close:hover {
    background: var(--vs-hover-2);
    color: var(--vs-ink);
  }
  .sb-search-close:focus-visible {
    box-shadow: inset 0 0 0 2px var(--vs-accent);
  }
  .sb-empty {
    margin: 0 8px;
    padding: 8px;
    color: var(--vs-ink-3);
    font-size: 12.5px;
  }

  /* ── footer ─────────────────────────────────────────── */
  .sb-footer {
    width: 208px;
    margin: 12px 8px 0;
    padding-top: 12px;
    border-top: 1px solid var(--vs-line);
  }
  .sb-footer-btn {
    display: flex;
    width: 100%;
    height: 32px;
    align-items: center;
    justify-content: center;
    gap: 6px;
    border-radius: var(--vs-radius-control);
    background: var(--vs-hover-2);
    color: var(--vs-ink);
    font-size: 12.5px;
    font-weight: 500;
    transition: background-color 150ms ease-out, transform 150ms ease-out;
  }
  .sb-footer-btn:hover {
    background: var(--vs-line-strong);
  }
  .sb-footer-btn:active {
    transform: scale(0.98);
  }

  /* ── workspace menu ─────────────────────────────────── */
  .sb-menu {
    position: fixed;
    inset: auto;
    z-index: 50;
    display: none;
    width: 256px;
    margin: 0;
    padding: 6px;
    border: 0;
    border-radius: 14px;
    overflow: visible;
    background: var(--vs-surface);
    color: var(--vs-ink);
    box-shadow: var(--vs-shadow-overlay);
    font-family: var(--vs-font-sans);
    text-align: left;
    transform-origin: top left;
  }
  .sb-menu.is-open {
    display: block;
  }
  .sb-menu.is-anim {
    animation: vs-pop-in 180ms var(--vs-ease-out) both;
  }
  .sb-menu-list {
    display: flex;
    flex-direction: column;
    gap: 1px;
  }
  .sb-menu-row {
    display: flex;
    width: 100%;
    height: 36px;
    align-items: center;
    gap: 6px;
    padding: 0 8px;
    border-radius: 8px;
    text-align: left;
  }
  .sb-menu-row--ws {
    height: 40px;
  }
  .sb-menu-row:focus-visible {
    box-shadow: none;
  }
  .sb-monogram {
    display: flex;
    width: 24px;
    height: 24px;
    flex-shrink: 0;
    align-items: center;
    justify-content: center;
    border-radius: 7px;
    background: var(--vs-ink);
    color: var(--vs-surface);
    font-size: 11px;
    font-weight: 600;
  }
  .sb-menu-icon {
    display: flex;
    width: 20px;
    height: 20px;
    flex-shrink: 0;
    align-items: center;
    justify-content: center;
    color: var(--vs-ink-2);
  }
  .sb-menu-label {
    min-width: 0;
    flex: 1;
    overflow: hidden;
    color: var(--vs-ink);
    font-size: 13.5px;
    text-overflow: ellipsis;
    white-space: nowrap;
  }
  .sb-menu-label--strong {
    font-weight: 500;
  }
  .sb-menu-check {
    display: flex;
    flex-shrink: 0;
    color: var(--vs-ink);
    visibility: hidden;
  }
  .sb-menu-row[aria-checked="true"] .sb-menu-check {
    visibility: visible;
  }
  .sb-menu-sep {
    height: 1px;
    margin: 4px 0;
    background: var(--vs-line);
  }

  @media (prefers-reduced-motion: reduce) {
    .vs-sidebar-nav,
    .sb-copy,
    .sb-row,
    .sb-search,
    .sb-chats-label,
    .vs-sidebar-nav :global(.sb-highlight) {
      transition: none;
    }
    .sb-menu.is-anim {
      animation: none;
    }
  }
  @media (forced-colors: active) {
    .sb-row[aria-current="page"] {
      outline: 1px solid Highlight;
    }
    .sb-menu {
      outline: 1px solid CanvasText;
    }
  }
</style>
```
