{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "app-sidebar",
  "title": "App Sidebar",
  "author": "ilinxa",
  "description": "App-shell sidebar with mobile drawer mode, twelve composition slots, prefab nav parts, and a headless state hook.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "avatar",
    "button",
    "dropdown-menu",
    "sheet"
  ],
  "files": [
    {
      "path": "src/registry/components/navigation/app-sidebar/app-sidebar.tsx",
      "content": "\"use client\";\n\nimport { PanelLeft, PanelLeftClose } from \"lucide-react\";\nimport {\n  useCallback,\n  useEffect,\n  useId,\n  useImperativeHandle,\n  useMemo,\n  useRef,\n} from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { Sheet, SheetContent, SheetTitle } from \"@/components/ui/sheet\";\nimport { useActiveDetection } from \"./hooks/use-active-detection\";\nimport { useMatchMedia, resolveBreakpointQuery } from \"./hooks/use-match-media\";\nimport { useSidebarReducer } from \"./hooks/use-sidebar-reducer\";\nimport { useStorageSync } from \"./hooks/use-storage-sync\";\nimport {\n  AppSidebarContext,\n  type AppSidebarContextValue,\n} from \"./contexts/sidebar-nav-context\";\nimport { buildHandle } from \"./lib/build-handle\";\nimport { deriveCssVars } from \"./lib/derive-css-vars\";\nimport { flattenEntriesForKeyboard } from \"./lib/flatten-entries\";\nimport { handleSidebarKeydown } from \"./lib/keyboard-handler\";\nimport { DefaultLink } from \"./parts/default-link\";\nimport { NavBrand } from \"./parts/nav-brand\";\nimport { NavPrimaryAction } from \"./parts/nav-primary-action\";\nimport { NavUser } from \"./parts/nav-user\";\nimport { SidebarEmptyState } from \"./parts/sidebar-empty-state\";\nimport { SidebarLoadingSkeleton } from \"./parts/sidebar-loading-skeleton\";\nimport { SidebarNavList } from \"./parts/sidebar-nav-list\";\nimport { SidebarSkipLink } from \"./parts/sidebar-skip-link\";\nimport type {\n  NavUserMenuItemSelectEvent,\n  AppSidebarEmptyReason,\n  AppSidebarHandle,\n  AppSidebarMobileOpenReason,\n  AppSidebarProps,\n} from \"./types\";\n\n/** Breakpoint → CSS class lookup (L44 — CSS-gated, not JS-gated rendering). */\nconst BREAKPOINT_DESKTOP_VISIBLE: Record<string, string> = {\n  sm: \"sm:flex\",\n  md: \"md:flex\",\n  lg: \"lg:flex\",\n  xl: \"xl:flex\",\n  \"2xl\": \"2xl:flex\",\n};\nconst BREAKPOINT_MOBILE_VISIBLE: Record<string, string> = {\n  sm: \"sm:hidden\",\n  md: \"md:hidden\",\n  lg: \"lg:hidden\",\n  xl: \"xl:hidden\",\n  \"2xl\": \"2xl:hidden\",\n};\n\n/**\n * C3 — items rendering + active detection.\n *\n * Items now actually render. Active item highlighted (full-fill paint;\n * activeVariant matrix lands C5). Sections render their items inline\n * (full section UI with header lands C4). Click sequence per L28 wired.\n */\nexport function AppSidebar(props: AppSidebarProps) {\n  const {\n    items,\n    currentPath,\n    isActive,\n    defaultMatch = \"exact\",\n    linkComponent = DefaultLink,\n    className,\n    id: idProp,\n    \"aria-label\": ariaLabel = \"Main navigation\",\n    defaultCollapsed,\n    defaultMobileOpen,\n    defaultCollapsedSectionIds,\n    isCollapsed,\n    isMobileOpen,\n    onCollapsedChange,\n    onMobileOpenChange,\n    onItemClick,\n    onItemNavigate,\n    onSectionToggle,\n    autoCloseMobileOnNavigate = true,\n    keepEmptySections = false,\n    permissions,\n    renderItem,\n    renderSection,\n    renderBadge,\n    renderTooltipContent,\n    renderLoading,\n    renderEmptyState,\n    loading = false,\n    state: externalState,\n    storageKey,\n    autoExpandActiveSection = true,\n    autoScrollActiveIntoView = true,\n    brandSlot,\n    brand,\n    headerSlot,\n    navAccessorySlot,\n    primaryActionSlot,\n    primaryAction,\n    footerSlot,\n    footer,\n    side = \"left\",\n    activeVariant = \"fill\",\n    collapsedWidth,\n    expandedWidth,\n    transitionDuration,\n    style,\n    mobileBreakpoint = \"lg\",\n    mobileDrawerSide,\n    drawerHeaderSlot,\n    skipLinkTarget,\n    skipLinkLabel = \"Skip to content\",\n    onPermissionDenied,\n    onSkipLinkActivated,\n    onBrandClick,\n    onPrimaryActionClick,\n    onFooterTriggerOpen,\n    onFooterMenuItemClick,\n    ref: externalRef,\n    // v0.2.0 — additive (L41–L52)\n    topSlot,\n    hrefTemplateValues,\n    resolveHref,\n    isOwner,\n    currentMaxMembers,\n    bypassFiltering,\n  } = props;\n\n  // L32: id defaults via useId() for <SidebarNavTrigger aria-controls>\n  const autoId = useId();\n  const sidebarId = idProp ?? `app-sidebar-${autoId}`;\n\n  // Section-collapse init priority (plan §8): explicit prop > per-section\n  // defaultCollapsed. Storage rehydration (C11) will outrank both via\n  // EXTERNAL_SYNC after mount.\n  const initialCollapsedSectionIds = useMemo(() => {\n    if (defaultCollapsedSectionIds) return defaultCollapsedSectionIds;\n    const fromItems: string[] = [];\n    for (const entry of items) {\n      if (entry.kind === \"section\" && entry.defaultCollapsed) {\n        fromItems.push(entry.id);\n      }\n    }\n    return fromItems;\n  }, [defaultCollapsedSectionIds, items]);\n\n  // Reducer + Defense-1 + Defense-2 wiring\n  const { state, dispatch } = useSidebarReducer({\n    defaultCollapsed,\n    defaultMobileOpen,\n    defaultCollapsedSectionIds: initialCollapsedSectionIds,\n    isCollapsed,\n    isMobileOpen,\n    onCollapsedChange,\n    onMobileOpenChange,\n  });\n\n  // localStorage opt-in (L23). Per L43: when external `state` provided,\n  // the hook owns storageKey; component's storageKey is ignored + dev warn.\n  const effectiveStorageKey = externalState ? undefined : storageKey;\n  if (\n    externalState &&\n    storageKey &&\n    process.env.NODE_ENV !== \"production\"\n  ) {\n    console.warn(\n      \"[app-sidebar] `storageKey` is ignored when `state` (lifted hook) is provided — the hook owns persistence (L43). Move storageKey to useAppSidebarState() options.\",\n    );\n  }\n  useStorageSync(state, dispatch, effectiveStorageKey);\n\n  // Items pipeline: derive visible entries → compute active item\n  const { visible, active } = useActiveDetection({\n    items,\n    currentPath,\n    isActive,\n    defaultMatch,\n    permissions,\n    keepEmptySections,\n    // v0.2.0 — three-gate intersection (L46) + bypass (Q21)\n    isOwner,\n    currentMaxMembers,\n    bypassFiltering,\n  });\n\n  // F1 — auto-expand section containing the active item (L48-b).\n  // When external state is provided, the hook (useAppSidebarState)\n  // owns F1; component-internal effect skipped to avoid double-firing.\n  useEffect(() => {\n    if (externalState) return;\n    if (!autoExpandActiveSection) return;\n    if (!active.sectionId) return;\n    if (!state.collapsedSectionIds.has(active.sectionId)) return;\n    dispatch({\n      type: \"SET_SECTION_COLLAPSED\",\n      sectionId: active.sectionId,\n      collapsed: false,\n    });\n  }, [\n    externalState,\n    autoExpandActiveSection,\n    active.sectionId,\n    state.collapsedSectionIds,\n    dispatch,\n  ]);\n\n  // Breakpoint resolution — CSS class names for the L44 CSS-gated render path.\n  // `useMatchMedia` returns the live mobile state for JS BEHAVIOR gating only\n  // (e.g., autoCloseMobileOnNavigate timing decisions — actual visual swap is\n  // CSS-driven, no SSR flash).\n  const desktopVisibleClass =\n    typeof mobileBreakpoint === \"string\" &&\n    mobileBreakpoint in BREAKPOINT_DESKTOP_VISIBLE\n      ? BREAKPOINT_DESKTOP_VISIBLE[mobileBreakpoint]\n      : \"lg:flex\";\n  const mobileVisibleClass =\n    typeof mobileBreakpoint === \"string\" &&\n    mobileBreakpoint in BREAKPOINT_MOBILE_VISIBLE\n      ? BREAKPOINT_MOBILE_VISIBLE[mobileBreakpoint]\n      : \"lg:hidden\";\n\n  const isMobileBehavior = useMatchMedia(resolveBreakpointQuery(mobileBreakpoint));\n\n  // closeMobile / toggleSection are wired through `finalHandle` so they\n  // mutate the EXTERNAL state when one is provided, or the internal\n  // reducer otherwise. Trade-off (L20 reasons): when external state,\n  // the auto-close path loses the \"item-click\" reason discriminator —\n  // `onMobileOpenChange` fires with \"imperative\" instead. Acceptable\n  // for v0.1; a future polish commit can add `setMobileOpenWithReason`\n  // to the handle if needed.\n\n  // Imperative handle — refreshed with items + active-detection results.\n  // v0.3.0 (C5, F5): delegated to the shared `buildHandle` factory.\n  // Identical factory consumed by `useAppSidebarState` so the two state\n  // paths can't drift apart.\n  const handle = useMemo<AppSidebarHandle>(\n    () => buildHandle({ state, dispatch, items, visible, active }),\n    [state, dispatch, items, visible, active],\n  );\n\n  // L30 — state prop precedence: external lifted state wins entirely over\n  // internal reducer state. Internal reducer still computed for hooks-rules\n  // compliance (L47), but its values flow through `finalCollapsed` /\n  // `finalMobileOpen` / etc. only when external state isn't supplied.\n  const finalCollapsed = externalState?.collapsed ?? state.collapsed;\n  const finalMobileOpen = externalState?.mobileOpen ?? state.mobileOpen;\n  const finalCollapsedSectionIds =\n    externalState?.collapsedSectionIds ?? state.collapsedSectionIds;\n  const finalActiveItem = externalState?.activeItem ?? active.item;\n  const finalVisibleEntries = externalState?.visibleEntries ?? visible.entries;\n  const finalHandle: AppSidebarHandle = externalState ?? handle;\n\n  // F-cross-13 (v0.3.2): `onPointerDownOutside` / `onEscapeKeyDown` are\n  // Radix-only SheetContent props — Base UI's DialogPopup rejects them. The\n  // public `onMobileOpenChange.reason` discriminator (\"outside-click\" |\n  // \"escape\") survives via own attribution: capture-phase document listeners\n  // record the reason (with a freshness stamp so a non-closing Escape can't\n  // mis-tag a later close); the cross-backend onOpenChange dispatch reads it.\n  const mobilePanelRef = useRef<HTMLDivElement | null>(null);\n  const mobileCloseReasonRef = useRef<{\n    reason: AppSidebarMobileOpenReason;\n    t: number;\n  } | null>(null);\n  useEffect(() => {\n    if (!finalMobileOpen) return;\n    const onPointerDown = (e: PointerEvent) => {\n      const panel = mobilePanelRef.current;\n      if (panel && e.target instanceof Node && !panel.contains(e.target)) {\n        mobileCloseReasonRef.current = { reason: \"outside-click\", t: Date.now() };\n      }\n    };\n    const onKeyDown = (e: KeyboardEvent) => {\n      if (e.key === \"Escape\") {\n        mobileCloseReasonRef.current = { reason: \"escape\", t: Date.now() };\n      }\n    };\n    document.addEventListener(\"pointerdown\", onPointerDown, true);\n    document.addEventListener(\"keydown\", onKeyDown, true);\n    return () => {\n      document.removeEventListener(\"pointerdown\", onPointerDown, true);\n      document.removeEventListener(\"keydown\", onKeyDown, true);\n      mobileCloseReasonRef.current = null;\n    };\n  }, [finalMobileOpen]);\n\n  // Flattened keyboard traversal sequence (L37). Section headers (collapsible\n  // only) interleave with their items; items hide when section is collapsed.\n  const keyboardFlat = useMemo(\n    () =>\n      flattenEntriesForKeyboard(finalVisibleEntries, finalCollapsedSectionIds),\n    [finalVisibleEntries, finalCollapsedSectionIds],\n  );\n  const keyboardEntryId = keyboardFlat.length > 0 ? keyboardFlat[0].id : null;\n\n  // Reducer-driven focus state. Even when external `state` is provided,\n  // focus tracking lives in the internal reducer — focus is transient UI,\n  // not persistable consumer state.\n  const focusedItemId = state.focusedItemId;\n\n  // L38 — onPermissionDenied diff-firing. Fire once per item initially +\n  // again for any item NEWLY entering the filtered set on subsequent renders.\n  // Comparison runs on each render via the ref-tracked previous set.\n  const prevFilteredRef = useRef<ReadonlySet<string> | null>(null);\n  useEffect(() => {\n    const currentFiltered = visible.filteredByPermission;\n    const currentIds = new Set(currentFiltered.map((f) => f.item.id));\n    if (onPermissionDenied) {\n      const prevIds = prevFilteredRef.current;\n      for (const entry of currentFiltered) {\n        if (prevIds === null || !prevIds.has(entry.item.id)) {\n          onPermissionDenied({\n            item: entry.item,\n            requiredPermission: entry.requiredPermission,\n          });\n        }\n      }\n    }\n    prevFilteredRef.current = currentIds;\n  }, [visible.filteredByPermission, onPermissionDenied]);\n\n  // Programmatic focus follow-up — when reducer's focusedItemId changes,\n  // move DOM focus to the matching row (via `data-nav-id`). Layout effect\n  // so focus lands before the browser paints, avoiding visible flashes.\n  useEffect(() => {\n    if (!focusedItemId) return;\n    if (typeof document === \"undefined\") return;\n    const nav = document.getElementById(sidebarId);\n    if (!nav) return;\n    // v0.3.0 (C3, F3): CSS.escape is universal in modern browsers\n    // (Chrome 46+ / Edge 79+ / Firefox 31+ / Safari 10+) — the SSR guard\n    // above already gates this code to the browser. The old `replace`\n    // fallback only escaped `\"` and `\\`, missing `:`, `]`, leading digits,\n    // and dozens of other selector-illegal characters.\n    const escaped = window.CSS.escape(focusedItemId);\n    const target = nav.querySelector<HTMLElement>(\n      `[data-nav-id=\"${escaped}\"]`,\n    );\n    if (target && document.activeElement !== target) {\n      target.focus();\n    }\n  }, [focusedItemId, sidebarId]);\n\n  const handleKeyDown = useCallback(\n    (event: React.KeyboardEvent<HTMLElement>) => {\n      handleSidebarKeydown(event, {\n        flat: keyboardFlat,\n        focusedId: focusedItemId,\n        setFocusedId: (id) => dispatch({ type: \"FOCUS_ITEM\", itemId: id }),\n        toggleSection: (id) => finalHandle.toggleSection(id),\n        isSectionCollapsed: (id) => finalCollapsedSectionIds.has(id),\n      });\n    },\n    [keyboardFlat, focusedItemId, dispatch, finalHandle, finalCollapsedSectionIds],\n  );\n\n  // F2 — auto-scroll active item into view on mount + currentPath change (L48-c)\n  useEffect(() => {\n    if (!autoScrollActiveIntoView) return;\n    if (typeof document === \"undefined\") return;\n    if (!finalActiveItem) return;\n    const nav = document.getElementById(sidebarId);\n    if (!nav) return;\n    const activeEl = nav.querySelector('[data-active=\"true\"]');\n    if (!(activeEl instanceof HTMLElement)) return;\n    const reducedMotion =\n      typeof window !== \"undefined\" &&\n      window.matchMedia?.(\"(prefers-reduced-motion: reduce)\").matches;\n    activeEl.scrollIntoView({\n      block: \"nearest\",\n      behavior: reducedMotion ? \"auto\" : \"smooth\",\n    });\n  }, [autoScrollActiveIntoView, finalActiveItem, sidebarId]);\n\n  // Helpers used by render — wired through finalHandle so they mutate\n  // external state (when provided) or internal reducer (default).\n  // v0.3.0 (C2, L54): closeMobile accepts a reason for the discriminator\n  // (default \"imperative\" if unspecified — preserves v0.2.x callers).\n  const closeMobile = useCallback(\n    (reason?: AppSidebarMobileOpenReason) => {\n      if (!isMobileBehavior) return;\n      finalHandle.closeMobile(reason);\n    },\n    [finalHandle, isMobileBehavior],\n  );\n\n  const toggleSection = useCallback(\n    (sectionId: string) => {\n      finalHandle.toggleSection(sectionId);\n    },\n    [finalHandle],\n  );\n\n  // Attach the imperative handle to the consumer-supplied ref (React 19\n  // ref-as-prop pattern). When external state provided, ref exposes the\n  // external handle so trigger toggles drive the right state machine.\n  useImperativeHandle(externalRef, () => finalHandle, [finalHandle]);\n\n  const contextValue = useMemo<AppSidebarContextValue>(\n    () => ({\n      state: { ...state, collapsed: finalCollapsed, mobileOpen: finalMobileOpen, collapsedSectionIds: finalCollapsedSectionIds },\n      dispatch,\n      handle: finalHandle,\n      sidebarId,\n    }),\n    [state, dispatch, finalHandle, sidebarId, finalCollapsed, finalMobileOpen, finalCollapsedSectionIds],\n  );\n\n  const cssVars = useMemo(\n    () => deriveCssVars({ collapsedWidth, expandedWidth, transitionDuration }),\n    [collapsedWidth, expandedWidth, transitionDuration],\n  );\n\n  const defaultAccessory = (\n    <button\n      type=\"button\"\n      onClick={() => finalHandle.toggleCollapse()}\n      aria-label={finalCollapsed ? \"Expand sidebar\" : \"Collapse sidebar\"}\n      aria-expanded={!finalCollapsed}\n      aria-controls={sidebarId}\n      className={cn(\n        \"inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md\",\n        \"text-muted-foreground hover:bg-muted hover:text-foreground\",\n        \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card\",\n      )}\n    >\n      {finalCollapsed ? (\n        <PanelLeft className=\"h-4 w-4\" aria-hidden=\"true\" />\n      ) : (\n        <PanelLeftClose className=\"h-4 w-4\" aria-hidden=\"true\" />\n      )}\n    </button>\n  );\n\n  // Shared inner chrome — used by BOTH desktop <nav> and mobile <Sheet>.\n  // Single source of truth for the header / list / footer composition.\n  //\n  // Header-row visibility rules:\n  //   desktop: ALWAYS renders (defaultAccessory always available unless\n  //            consumer explicitly passes navAccessorySlot={null} to hide it\n  //            AND supplies neither header nor brand → only then suppressed)\n  //   mobile: renders only when drawerHeaderSlot OR headerSlot OR resolvedBrand\n  //           supplied (mobile drawer has no collapse toggle — sheet close\n  //           button handles that)\n  // Slot-vs-config resolution (L13): slot wins over shorthand config.\n  // Component-level events (onBrandClick / onPrimaryActionClick /\n  // onFooterTriggerOpen / onFooterMenuItemClick) are wired into the resolved\n  // prefabs here so they fire regardless of whether the consumer used the\n  // shorthand config or supplied their own slot. Slot consumers wire their\n  // own callbacks directly — the component-level callbacks only apply to\n  // the prefab path.\n  const resolvedBrand = useMemo(() => {\n    if (brandSlot) return brandSlot;\n    if (!brand) return null;\n    const brandElement = <NavBrand {...brand} />;\n    if (!onBrandClick) return brandElement;\n    return (\n      <span\n        onClick={(event) => onBrandClick({ event })}\n        className=\"contents\"\n      >\n        {brandElement}\n      </span>\n    );\n  }, [brandSlot, brand, onBrandClick]);\n\n  const resolvedPrimaryAction = useMemo(() => {\n    if (primaryActionSlot) return primaryActionSlot;\n    if (!primaryAction) return null;\n    if (!onPrimaryActionClick) return <NavPrimaryAction {...primaryAction} />;\n    return (\n      <NavPrimaryAction\n        {...primaryAction}\n        onClick={(event) => {\n          primaryAction.onClick?.(event);\n          onPrimaryActionClick({ event });\n        }}\n      />\n    );\n  }, [primaryActionSlot, primaryAction, onPrimaryActionClick]);\n\n  const resolvedFooter = useMemo(() => {\n    if (footerSlot) return footerSlot;\n    if (!footer) return null;\n    const wireMenuItem = onFooterMenuItemClick\n      ? (() => {\n          const wired = footer.menuItems.map((entry) => {\n            if (entry.kind === \"separator\") return entry;\n            const item = entry;\n            return {\n              ...item,\n              // v0.3.0 (C4, F10): widened event arg matches NavUserMenuItem.onClick.\n              onClick: (event: NavUserMenuItemSelectEvent) => {\n                item.onClick?.(event);\n                onFooterMenuItemClick({ menuItem: item, event });\n              },\n            };\n          });\n          return wired;\n        })()\n      : footer.menuItems;\n    return (\n      <NavUser\n        {...footer}\n        menuItems={wireMenuItem}\n        onTriggerOpen={(args) => {\n          footer.onTriggerOpen?.(args);\n          onFooterTriggerOpen?.(args);\n        }}\n      />\n    );\n  }, [footerSlot, footer, onFooterTriggerOpen, onFooterMenuItemClick]);\n\n  const desktopAccessory =\n    navAccessorySlot === null ? null : (navAccessorySlot ?? defaultAccessory);\n  const showMobileHeader = !!drawerHeaderSlot || !!headerSlot || !!resolvedBrand;\n\n  // L39 — loading/empty branching precedence:\n  //   loading=true → renderLoading slot OR default skeleton\n  //   else items.length === 0 → renderEmptyState({reason: \"no-items\"})\n  //   else visibleEntries.length === 0 AND filtered count > 0\n  //     → renderEmptyState({reason: \"all-filtered-by-permission\"})\n  //   else visibleEntries.length === 0 AND hidden count > 0\n  //     → renderEmptyState({reason: \"all-hidden\"})\n  //   else → normal list render\n  //\n  // v0.3.0 (C6, F7): wrapped in useCallback so consumers in non-React-Compiler\n  // environments (registry installs into apps without the compiler) still get\n  // stable references and avoid the cascade of <SidebarNavList> re-renders on\n  // unrelated parent state changes.\n  const renderListBody = useCallback(\n    (mode: \"desktop\" | \"mobile\") => {\n      const collapsed = mode === \"mobile\" ? false : finalCollapsed;\n\n      if (loading) {\n        const defaultRender = (\n          <SidebarLoadingSkeleton isCollapsed={collapsed} />\n        );\n        if (renderLoading) {\n          return renderLoading({ isCollapsed: collapsed, defaultRender });\n        }\n        return defaultRender;\n      }\n\n      if (finalVisibleEntries.length === 0) {\n        // When external state is provided, internal `visible.*` diagnostic\n        // counts are best-effort (may not match externalState.items). Reason\n        // resolution is lossy in that path — defaults to \"no-items\".\n        const reason: AppSidebarEmptyReason =\n          items.length === 0\n            ? \"no-items\"\n            : visible.filteredByPermission.length > 0\n              ? \"all-filtered-by-permission\"\n              : visible.hiddenItemCount > 0\n                ? \"all-hidden\"\n                : \"no-items\";\n        if (renderEmptyState) {\n          return renderEmptyState({ reason });\n        }\n        return <SidebarEmptyState reason={reason} />;\n      }\n\n      return (\n        <SidebarNavList\n          entries={finalVisibleEntries}\n          activeItemId={finalActiveItem?.id ?? null}\n          focusedItemId={focusedItemId}\n          keyboardEntryId={keyboardEntryId}\n          isCollapsed={collapsed}\n          linkComponent={linkComponent}\n          activeVariant={activeVariant}\n          autoCloseMobileOnNavigate={autoCloseMobileOnNavigate}\n          isMobileOpen={finalMobileOpen}\n          onCloseMobile={closeMobile}\n          collapsedSectionIds={finalCollapsedSectionIds}\n          onToggleSection={toggleSection}\n          onItemClick={onItemClick}\n          onItemNavigate={onItemNavigate}\n          onSectionToggle={onSectionToggle}\n          renderItem={renderItem}\n          renderSection={renderSection}\n          renderBadge={renderBadge}\n          renderTooltipContent={renderTooltipContent}\n          hrefTemplateValues={hrefTemplateValues}\n          resolveHref={resolveHref}\n        />\n      );\n    },\n    [\n      finalCollapsed,\n      loading,\n      renderLoading,\n      finalVisibleEntries,\n      items.length,\n      visible.filteredByPermission.length,\n      visible.hiddenItemCount,\n      renderEmptyState,\n      finalActiveItem?.id,\n      focusedItemId,\n      keyboardEntryId,\n      linkComponent,\n      activeVariant,\n      autoCloseMobileOnNavigate,\n      finalMobileOpen,\n      closeMobile,\n      finalCollapsedSectionIds,\n      toggleSection,\n      onItemClick,\n      onItemNavigate,\n      onSectionToggle,\n      renderItem,\n      renderSection,\n      renderBadge,\n      renderTooltipContent,\n      hrefTemplateValues,\n      resolveHref,\n    ],\n  );\n\n  // v0.3.0 (C6, F7): wrapped in useCallback — see note on renderListBody.\n  const renderInnerChrome = useCallback(\n    (mode: \"desktop\" | \"mobile\") => {\n    const headerCollapsedDesktop = mode === \"desktop\" && finalCollapsed;\n    // v0.2 — when topSlot is supplied with NO brand/headerSlot, the brand\n    // row would otherwise render empty just to host the collapse toggle\n    // (desktopAccessory), leaving a wasteful gap below topSlot. Merge the\n    // accessory into the topSlot row instead (expanded desktop only —\n    // collapsed mode keeps its own vertical-stack layout for the toggle).\n    const mergeAccessoryIntoTopSlot =\n      mode === \"desktop\" &&\n      !headerCollapsedDesktop &&\n      !!topSlot &&\n      !headerSlot &&\n      !resolvedBrand &&\n      desktopAccessory !== null;\n    const shouldShowBrandRow =\n      mode === \"desktop\"\n        ? !!headerSlot ||\n          !!resolvedBrand ||\n          (desktopAccessory !== null && !mergeAccessoryIntoTopSlot)\n        : showMobileHeader;\n    return (\n    <>\n      {/* v0.2.0 — topSlot above the brand zone (L41). Geographically distinct\n       *  from headerSlot (which renders INSIDE the brand row). Zero layout\n       *  shift when null/undefined per success #10. PQ3: unlabeled wrapper —\n       *  ARIA semantics are the consumer's responsibility.\n       *\n       *  When `mergeAccessoryIntoTopSlot` is true (topSlot present, no brand\n       *  row content of its own), the collapse toggle moves into this row\n       *  pinned to the right — eliminates the empty brand-row gap. */}\n      {topSlot ? (\n        <div\n          className={cn(\n            \"ilinxa-sidebar-top-slot border-b border-border\",\n            // Collapsed desktop: 80px-wide sidebar — center the topSlot\n            // content so icon-only widgets (the canonical AccountSwitcher\n            // 40×40 trigger) line up with the nav-row icons below. Without\n            // this the icon sits left-aligned in p-3 padding (12+40+24 in\n            // an 80px column) and visually drifts from the rest.\n            headerCollapsedDesktop\n              ? \"flex items-center justify-center px-2 py-2\"\n              : \"p-3\",\n            mergeAccessoryIntoTopSlot && \"flex items-center gap-2\",\n          )}\n        >\n          {mergeAccessoryIntoTopSlot ? (\n            <>\n              <div className=\"min-w-0 flex-1\">{topSlot}</div>\n              <div className=\"shrink-0\">{desktopAccessory}</div>\n            </>\n          ) : (\n            topSlot\n          )}\n        </div>\n      ) : null}\n\n      {/* Brand / header zone.\n       *\n       * Layout:\n       *   - expanded desktop / mobile drawer: horizontal flex — brand on the\n       *     left, toggle on the right (`ml-auto`).\n       *   - collapsed desktop: stack vertically and center — there isn't\n       *     room for both brand (32px) and toggle (32px) plus gap inside\n       *     an 80px-wide column without the two visually overlapping. */}\n      {shouldShowBrandRow && (\n        <div\n          className={cn(\n            \"flex border-b border-border min-h-14\",\n            headerCollapsedDesktop\n              ? \"flex-col items-center gap-1 px-2 py-2\"\n              : \"items-center gap-2 p-3\",\n          )}\n        >\n          {mode === \"mobile\" && drawerHeaderSlot ? (\n            <div className=\"contents\">{drawerHeaderSlot}</div>\n          ) : (\n            <>\n              {headerSlot && <div className=\"contents\">{headerSlot}</div>}\n              {resolvedBrand && (\n                <div\n                  className={cn(\n                    headerCollapsedDesktop\n                      ? \"flex shrink-0 items-center justify-center\"\n                      : \"flex-1 min-w-0\",\n                  )}\n                >\n                  {resolvedBrand}\n                </div>\n              )}\n              {!resolvedBrand && !headerSlot && !headerCollapsedDesktop && (\n                <div className=\"flex-1\" aria-hidden />\n              )}\n              {mode === \"desktop\" &&\n                desktopAccessory !== null &&\n                !mergeAccessoryIntoTopSlot && (\n                  <div\n                    className={cn(\n                      headerCollapsedDesktop ? \"shrink-0\" : \"ml-auto shrink-0\",\n                    )}\n                  >\n                    {desktopAccessory}\n                  </div>\n                )}\n            </>\n          )}\n        </div>\n      )}\n\n      {/* Nav list — loading/empty branching per L39 */}\n      <div className=\"flex flex-1 flex-col gap-2 overflow-hidden p-3\">\n        {renderListBody(mode)}\n        {resolvedPrimaryAction && (\n          <div className=\"pt-2\">{resolvedPrimaryAction}</div>\n        )}\n      </div>\n\n      {/* Footer zone */}\n      {resolvedFooter && (\n        <div className=\"border-t border-border p-3\">{resolvedFooter}</div>\n      )}\n    </>\n    );\n    },\n    [\n      finalCollapsed,\n      topSlot,\n      headerSlot,\n      resolvedBrand,\n      desktopAccessory,\n      showMobileHeader,\n      drawerHeaderSlot,\n      renderListBody,\n      resolvedPrimaryAction,\n      resolvedFooter,\n    ],\n  );\n\n  const drawerSide = mobileDrawerSide ?? side;\n\n  return (\n    <AppSidebarContext.Provider value={contextValue}>\n      {/* Desktop render path — CSS-hidden BELOW breakpoint (L44) */}\n      <nav\n        id={sidebarId}\n        aria-label={ariaLabel}\n        data-component=\"app-sidebar\"\n        data-collapsed={finalCollapsed}\n        data-mobile-open={finalMobileOpen}\n        data-side={side}\n        onKeyDown={handleKeyDown}\n        style={{ ...cssVars, ...style }}\n        className={cn(\n          \"relative hidden h-full flex-col bg-card\",\n          desktopVisibleClass,\n          // Side-aware border\n          side === \"left\" ? \"border-r\" : \"border-l\",\n          \"border-border\",\n          // CSS-var-driven width + motion-safe transition\n          \"data-[collapsed=false]:w-(--ilinxa-sidebar-w-expanded)\",\n          \"data-[collapsed=true]:w-(--ilinxa-sidebar-w-collapsed)\",\n          \"motion-safe:transition-[width] motion-safe:duration-(--ilinxa-sidebar-transition-duration)\",\n          // RTL hook\n          \"rtl:border-x-0\",\n          side === \"left\" ? \"rtl:border-l\" : \"rtl:border-r\",\n          className,\n        )}\n      >\n        {skipLinkTarget && (\n          <SidebarSkipLink\n            target={skipLinkTarget}\n            label={skipLinkLabel}\n            onActivated={onSkipLinkActivated}\n          />\n        )}\n        {renderInnerChrome(\"desktop\")}\n      </nav>\n\n      {/* Mobile render path — CSS-hidden ABOVE breakpoint */}\n      <div\n        className={mobileVisibleClass}\n        data-component=\"app-sidebar-mobile-wrapper\"\n        style={cssVars}\n      >\n        <Sheet\n          open={finalMobileOpen}\n          // F-cross-13 defensive: Radix passes (open: boolean); Base UI may pass\n          // undefined or different shape. Runtime-check before mutating.\n          // Routed through finalHandle so external state (when provided)\n          // is the mutation target.\n          //\n          // v0.3.2 (F-cross-13): close reasons come from the document-level\n          // attribution refs above (the Radix-only SheetContent props are\n          // gone). A stamp older than 500ms is stale — e.g. an Escape that\n          // didn't close — and falls back to the default reason.\n          onOpenChange={(nextOpen: boolean | undefined) => {\n            if (typeof nextOpen !== \"boolean\") return;\n            if (nextOpen) {\n              finalHandle.openMobile();\n              return;\n            }\n            const stamped = mobileCloseReasonRef.current;\n            mobileCloseReasonRef.current = null;\n            if (stamped && Date.now() - stamped.t < 500) {\n              finalHandle.closeMobile(stamped.reason);\n            } else {\n              finalHandle.closeMobile();\n            }\n          }}\n        >\n          <SheetContent\n            ref={mobilePanelRef}\n            side={drawerSide}\n            // NOTE: do NOT add `relative` here — it would tailwind-merge\n            // away Radix's `fixed inset-y-0 {side}-0`, leaving the panel\n            // unpositioned and invisible against the overlay backdrop.\n            className=\"flex w-72 flex-col bg-card p-0\"\n            style={cssVars}\n            aria-describedby={undefined}\n            onKeyDown={handleKeyDown}\n          >\n            <SheetTitle className=\"sr-only\">{ariaLabel}</SheetTitle>\n            {skipLinkTarget && (\n              <SidebarSkipLink\n                target={skipLinkTarget}\n                label={skipLinkLabel}\n                onActivated={onSkipLinkActivated}\n              />\n            )}\n            {renderInnerChrome(\"mobile\")}\n          </SheetContent>\n        </Sheet>\n      </div>\n    </AppSidebarContext.Provider>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/app-sidebar/app-sidebar.tsx"
    },
    {
      "path": "src/registry/components/navigation/app-sidebar/index.ts",
      "content": "export { AppSidebar } from \"./app-sidebar\";\nexport { AppSidebarTrigger } from \"./parts/sidebar-nav-trigger\";\nexport { NavBadge } from \"./parts/nav-badge\";\nexport { NavBrand } from \"./parts/nav-brand\";\nexport { NavPrimaryAction } from \"./parts/nav-primary-action\";\nexport { NavUser } from \"./parts/nav-user\";\nexport { useAppSidebarState } from \"./hooks/use-sidebar-nav-state\";\nexport { useFilteredNavSections } from \"./hooks/use-filtered-nav-sections\";\nexport type { UseFilteredNavSectionsOpts } from \"./hooks/use-filtered-nav-sections\";\n\nexport type {\n  // Items schema\n  NavItem,\n  NavSection,\n  NavSeparator,\n  NavEntry,\n  BasicNavItems,\n  SidebarNavItems,\n\n  // Link primitive\n  NavLinkProps,\n  NavLinkComponent,\n\n  // Prefab part configs\n  NavBadgeConfig,\n  NavBrandConfig,\n  NavPrimaryActionConfig,\n  NavUserMenuItem,\n  NavUserMenuItemSelectEvent, // v0.3.0 (L56) — event arg type for NavUserMenuItem.onClick\n  NavUserConfig,\n\n  // Main component\n  AppSidebarProps,\n  AppSidebarHandle,\n  AppSidebarStateValue,\n  AppSidebarMobileOpenReason,\n  AppSidebarEmptyReason,\n  AppSidebarEventArgs,\n\n  // Render-prop slot args\n  AppSidebarRenderItemArgs,\n  AppSidebarRenderBadgeArgs,\n  AppSidebarRenderTooltipContentArgs,\n  AppSidebarRenderSectionArgs,\n  AppSidebarRenderLoadingArgs,\n  AppSidebarRenderEmptyStateArgs,\n\n  // Companion + hook options\n  AppSidebarTriggerProps,\n  UseAppSidebarStateOptions,\n\n  // v0.2.0 — additive\n  NavContext,\n} from \"./types\";\n",
      "type": "registry:component",
      "target": "components/app-sidebar/index.ts"
    },
    {
      "path": "src/registry/components/navigation/app-sidebar/types.ts",
      "content": "import type { ComponentType, CSSProperties, ReactNode, Ref } from \"react\";\n\n// ─────────────────────────────────────────────────────────────────────────\n// Items schema (L4 + L5 + L36 + L48)\n// ─────────────────────────────────────────────────────────────────────────\n\nexport interface NavItem {\n  kind?: \"item\";\n  id: string;\n  label: string;\n  icon?: ReactNode | ComponentType<{ className?: string }>;\n  href?: string;\n  onClick?: (event: React.MouseEvent) => void;\n  badge?: number | string | NavBadgeConfig;\n  match?: \"exact\" | \"prefix\";\n  shortcut?: string;\n  description?: string;\n  accessory?: ReactNode;\n  tooltipContent?: ReactNode;\n  target?: \"_blank\" | \"_self\" | \"_parent\" | \"_top\";\n  rel?: string;\n  permission?: string;\n  /**\n   * v0.2.0 — When `true`, item is hidden in the filter pass unless the\n   * sidebar's `isOwner` prop is also `true`. Default `false`. Works\n   * alongside `permission` and `minMembers` — all three gates pass\n   * independently (intersection per L46).\n   */\n  ownerOnly?: boolean;\n  /**\n   * v0.2.0 — When set, item is hidden unless sidebar's `currentMaxMembers`\n   * prop is `>=` this value. Default unset (no min). Useful for plan-tier\n   * gating (Members tab visible only when seat capacity ≥ N).\n   */\n  minMembers?: number;\n  disabled?: boolean;\n  hidden?: boolean;\n  className?: string;\n  \"data-testid\"?: string;\n}\n\nexport interface NavSection {\n  kind: \"section\";\n  id: string;\n  title?: string;\n  icon?: ReactNode | ComponentType<{ className?: string }>;\n  collapsible?: boolean;\n  defaultCollapsed?: boolean;\n  items: ReadonlyArray<NavItem>;\n  permission?: string;\n  hidden?: boolean;\n}\n\nexport interface NavSeparator {\n  kind: \"separator\";\n  id?: string;\n}\n\nexport type NavEntry = NavItem | NavSection | NavSeparator;\nexport type BasicNavItems = ReadonlyArray<NavItem>;\nexport type SidebarNavItems = ReadonlyArray<NavEntry>;\n\n// ─────────────────────────────────────────────────────────────────────────\n// NavContext discriminated union (v0.2.0 — L48 + I-6)\n// ─────────────────────────────────────────────────────────────────────────\n\n/**\n * Discriminated union for app-shell context types — exported helper for\n * consumers building multi-tenant SaaS surfaces. Type-only export; library\n * does NOT ship a `useNavContext` hook because the URL→context derivation\n * is coupled to the consumer's router (Next.js, TanStack Router, plain\n * `location` — all different). Consumers type their derivation function\n * with `(): NavContext` and TypeScript narrows correctly across the\n * discriminant.\n *\n * Source of the 5-case shape: migration analysis §8.2 (the kasder\n * socialmedia-adv-nav-system app-shell). Consumers needing different\n * context shapes type their own; this is a documented helper, not\n * mandatory. R13 acknowledges the opinionated shape.\n */\nexport type NavContext =\n  | { type: \"personal\" }\n  | { type: \"business\"; slug: string; accountId: string; accountName: string }\n  | { type: \"platform\"; accountId: string }\n  | { type: \"governance\" }\n  | { type: \"cms\"; mode: \"platform\" }\n  | { type: \"cms\"; mode: \"business\"; slug: string; accountId: string; accountName: string };\n\n// ─────────────────────────────────────────────────────────────────────────\n// Link primitive (L10 + L15)\n// ─────────────────────────────────────────────────────────────────────────\n\nexport interface NavLinkProps {\n  href: string;\n  className?: string;\n  \"aria-current\"?: \"page\" | undefined;\n  \"aria-label\"?: string;\n  \"aria-disabled\"?: boolean | \"true\" | \"false\" | undefined;\n  \"data-active\"?: boolean;\n  children?: ReactNode;\n  onClick?: (e: React.MouseEvent) => void;\n  onMouseEnter?: (e: React.MouseEvent) => void;\n  onFocus?: (e: React.FocusEvent) => void;\n  target?: string;\n  rel?: string;\n  tabIndex?: number;\n  ref?: Ref<HTMLAnchorElement>;\n  [key: `data-${string}`]: unknown;\n}\n\nexport type NavLinkComponent = ComponentType<NavLinkProps>;\n\n// ─────────────────────────────────────────────────────────────────────────\n// Prefab part configs (L14, L15)\n// ─────────────────────────────────────────────────────────────────────────\n\nexport interface NavBadgeConfig {\n  value: number | string | ReactNode;\n  max?: number;\n  variant?: \"number\" | \"dot\" | \"pulse\";\n  tone?: \"default\" | \"accent\" | \"destructive\" | \"muted\";\n  position?: \"inline-end\" | \"corner\";\n  showZero?: boolean;\n  className?: string;\n}\n\nexport interface NavBrandConfig {\n  logo?: ReactNode | { src: string; alt?: string };\n  label: string;\n  href?: string;\n  linkComponent?: NavLinkComponent;\n}\n\nexport interface NavPrimaryActionConfig {\n  icon: ReactNode | ComponentType<{ className?: string }>;\n  label: string;\n  onClick?: (event: React.MouseEvent) => void;\n  href?: string;\n  linkComponent?: NavLinkComponent;\n  variant?: \"default\" | \"outline\" | \"ghost\" | \"secondary\";\n  tone?: \"default\" | \"accent\" | \"destructive\";\n}\n\n/**\n * v0.3.0 — Event union for `NavUserMenuItem.onClick` callbacks. The underlying\n * primitive (`DropdownMenuItem.onSelect`) passes a plain `Event` for keyboard\n * activations and a `React.MouseEvent` for clicks. Use this alias to type\n * custom `onClick` handlers without spelling out the union:\n *\n * ```ts\n * const handler = (event: NavUserMenuItemSelectEvent) => {\n *   if (event instanceof MouseEvent) { console.log(event.clientX); }\n * };\n * ```\n */\nexport type NavUserMenuItemSelectEvent = Event | React.MouseEvent;\n\nexport interface NavUserMenuItem {\n  kind: \"item\";\n  icon?: ReactNode | ComponentType<{ className?: string }>;\n  label: string;\n  /**\n   * v0.3.0 — widened from `React.MouseEvent` to `Event | React.MouseEvent` to\n   * honestly type the event arg passed by Radix's `DropdownMenuItem.onSelect`\n   * (which may be a plain `Event` or `MouseEvent` depending on input modality\n   * and primitive vendor). v0.2.x consumers reading MouseEvent-only fields\n   * (e.g. `event.clientX`) narrow with `if (event instanceof MouseEvent) { … }`\n   * or cast at the call site.\n   */\n  onClick?: (event: NavUserMenuItemSelectEvent) => void;\n  href?: string;\n  linkComponent?: NavLinkComponent;\n  variant?: \"default\" | \"destructive\";\n  shortcut?: string;\n  disabled?: boolean;\n}\n\nexport interface NavUserConfig {\n  user: {\n    name: string;\n    handle?: string;\n    avatarUrl?: string;\n    avatarFallback?: string;\n    status?: \"online\" | \"offline\" | \"busy\" | \"away\" | \"invisible\";\n  };\n  menuItems: ReadonlyArray<NavUserMenuItem | { kind: \"separator\" }>;\n  onTriggerOpen?: (args: { open: boolean }) => void;\n}\n\n// ─────────────────────────────────────────────────────────────────────────\n// Events + state (L20 — object-args throughout)\n// ─────────────────────────────────────────────────────────────────────────\n\nexport type AppSidebarMobileOpenReason =\n  | \"trigger\"\n  | \"item-click\"\n  | \"outside-click\"\n  | \"escape\"\n  | \"imperative\";\n\nexport interface AppSidebarHandle {\n  // Collapse\n  toggleCollapse(): void;\n  setCollapsed(next: boolean): void;\n  isCollapsed(): boolean;\n\n  // Mobile drawer\n  // v0.3.0 (L54): optional `reason?` param propagates through to\n  // `onMobileOpenChange.reason`. Default `\"imperative\"` preserves v0.2.x\n  // call sites — `handle.closeMobile()` still works exactly as before.\n  /** Open the mobile drawer. Optional `reason` reaches `onMobileOpenChange.reason`. Default `\"imperative\"`. */\n  openMobile(reason?: AppSidebarMobileOpenReason): void;\n  /** Close the mobile drawer. Optional `reason` reaches `onMobileOpenChange.reason`. Default `\"imperative\"`. */\n  closeMobile(reason?: AppSidebarMobileOpenReason): void;\n  /** Toggle the mobile drawer. Optional `reason` reaches `onMobileOpenChange.reason`. Default `\"imperative\"`. */\n  toggleMobile(reason?: AppSidebarMobileOpenReason): void;\n  isMobileOpen(): boolean;\n\n  // Section state\n  toggleSection(sectionId: string): void;\n  expandSection(sectionId: string): void;\n  collapseSection(sectionId: string): void;\n  /** Expand every section (clears the entire collapsed-set). */\n  expandAllSections(): void;\n  /**\n   * Collapse every currently-VISIBLE section.\n   *\n   * v0.3.0 NOTE: operates on sections that survive the permission /\n   * ownerOnly / minMembers filter pass — sections hidden by gates are not\n   * touched. To collapse a section regardless of visibility, call\n   * `collapseSection(id)` directly with its id.\n   */\n  collapseAllSections(): void;\n  isSectionCollapsed(sectionId: string): boolean;\n\n  // Items + active\n  getItems(): ReadonlyArray<NavEntry>;\n  getItemById(id: string): NavItem | undefined;\n  getActiveItem(): NavItem | undefined;\n\n  // Focus\n  focusItem(id: string): void;\n  focusFirstItem(): void;\n  focusLastItem(): void;\n\n  // Snapshot\n  getState(): AppSidebarStateValue;\n}\n\nexport interface AppSidebarStateValue extends AppSidebarHandle {\n  collapsed: boolean;\n  mobileOpen: boolean;\n  collapsedSectionIds: ReadonlySet<string>;\n  activeItemId: string | null;\n  activeItem: NavItem | null;\n  visibleEntries: ReadonlyArray<NavEntry>;\n}\n\n// ─────────────────────────────────────────────────────────────────────────\n// Empty-state reason (L48)\n// ─────────────────────────────────────────────────────────────────────────\n\nexport type AppSidebarEmptyReason =\n  | \"no-items\"\n  | \"all-filtered-by-permission\"\n  | \"all-hidden\"\n  | \"all-filtered-by-loading\";\n\n// ─────────────────────────────────────────────────────────────────────────\n// Render-prop slot arg shapes (L29)\n// ─────────────────────────────────────────────────────────────────────────\n\nexport interface AppSidebarRenderItemArgs {\n  item: NavItem;\n  isActive: boolean;\n  isCollapsed: boolean;\n  isFocused: boolean;\n  isDisabled: boolean;\n  sectionId: string | null;\n  indexInSection: number;\n  defaultRender: ReactNode;\n}\n\nexport interface AppSidebarRenderBadgeArgs {\n  item: NavItem;\n  badge: NavBadgeConfig;\n  position: \"inline-end\" | \"corner\";\n  defaultRender: ReactNode;\n}\n\nexport interface AppSidebarRenderTooltipContentArgs {\n  item: NavItem;\n  isActive: boolean;\n}\n\nexport interface AppSidebarRenderSectionArgs {\n  section: NavSection;\n  isCollapsed: boolean;\n  visibleItemCount: number;\n  defaultRender: ReactNode;\n}\n\nexport interface AppSidebarRenderLoadingArgs {\n  isCollapsed: boolean;\n  defaultRender: ReactNode;\n}\n\nexport interface AppSidebarRenderEmptyStateArgs {\n  reason: AppSidebarEmptyReason;\n}\n\n// ─────────────────────────────────────────────────────────────────────────\n// Event arg shapes (L20)\n// ─────────────────────────────────────────────────────────────────────────\n\nexport interface AppSidebarEventArgs {\n  collapsedChange: { collapsed: boolean };\n  mobileOpenChange: { open: boolean; reason: AppSidebarMobileOpenReason };\n  itemClick: { item: NavItem; isActive: boolean; event: React.MouseEvent };\n  itemHover: { item: NavItem; event: React.MouseEvent };\n  itemFocus: { item: NavItem; event: React.FocusEvent };\n  itemNavigate: { item: NavItem };\n  activeItemChange: { item: NavItem | null; previousItem: NavItem | null };\n  sectionToggle: { section: NavSection; collapsed: boolean };\n  permissionDenied: { item: NavItem; requiredPermission: string };\n  brandClick: { event: React.MouseEvent };\n  primaryActionClick: { event: React.MouseEvent };\n  footerTriggerOpen: { open: boolean };\n  // v0.3.0 (C4, F10): event widened to NavUserMenuItemSelectEvent to match\n  // the widened NavUserMenuItem.onClick. Same callback chain — must be\n  // self-consistent. Consumers narrow with `event instanceof MouseEvent`.\n  footerMenuItemClick: { menuItem: NavUserMenuItem; event: NavUserMenuItemSelectEvent };\n  skipLinkActivated: { event: React.MouseEvent };\n  mount: { initialState: AppSidebarStateValue };\n}\n\n// ─────────────────────────────────────────────────────────────────────────\n// Main component props (the full surface)\n// ─────────────────────────────────────────────────────────────────────────\n\nexport interface AppSidebarProps {\n  // Items\n  items: ReadonlyArray<NavEntry>;\n\n  // Active detection\n  currentPath: string;\n  isActive?: (item: NavItem, currentPath: string) => boolean;\n  defaultMatch?: \"exact\" | \"prefix\";\n\n  // Link primitive\n  linkComponent?: NavLinkComponent;\n\n  // Collapse (uncontrolled / controlled / lifted via state)\n  defaultCollapsed?: boolean;\n  isCollapsed?: boolean;\n  /**\n   * Fired when the collapsed state changes.\n   *\n   * v0.3.0 NOTE: does NOT fire during localStorage rehydration on mount. The\n   * persisted collapsed state is already reflected in the initial render via\n   * the storage-read effect; firing the callback at that point would be\n   * confusing (the consumer didn't request the change). The callback only\n   * fires on user-initiated transitions: toggle button, controlled-prop\n   * change, or imperative handle call (`handle.toggleCollapse()` /\n   * `setCollapsed(next)`).\n   */\n  onCollapsedChange?: (args: AppSidebarEventArgs[\"collapsedChange\"]) => void;\n\n  // Mobile drawer (L8 + L24 + L44)\n  defaultMobileOpen?: boolean;\n  isMobileOpen?: boolean;\n  onMobileOpenChange?: (args: AppSidebarEventArgs[\"mobileOpenChange\"]) => void;\n  mobileBreakpoint?: \"sm\" | \"md\" | \"lg\" | \"xl\" | \"2xl\" | (string & {});\n  mobileDrawerSide?: \"left\" | \"right\";\n  autoCloseMobileOnNavigate?: boolean;\n\n  // Lifted state (L30 — wins over individual props above)\n  state?: AppSidebarStateValue;\n\n  // Layout\n  side?: \"left\" | \"right\";\n  collapsedWidth?: string;\n  expandedWidth?: string;\n  transitionDuration?: string;\n  activeVariant?: \"fill\" | \"left-bar\" | \"right-bar\" | \"outline\" | \"subtle\";\n\n  // Sections (L48-b)\n  autoExpandActiveSection?: boolean;\n  defaultCollapsedSectionIds?: ReadonlyArray<string>;\n  keepEmptySections?: boolean;\n\n  // Active item view (L48-c)\n  autoScrollActiveIntoView?: boolean;\n\n  // Persistence (L23)\n  storageKey?: string;\n\n  // Permissions (L22)\n  permissions?: ReadonlySet<string>;\n\n  // Slots — named (L14)\n  headerSlot?: ReactNode;\n  brandSlot?: ReactNode;\n  brand?: NavBrandConfig;\n  navAccessorySlot?: ReactNode;\n  primaryActionSlot?: ReactNode;\n  primaryAction?: NavPrimaryActionConfig;\n  footerSlot?: ReactNode;\n  footer?: NavUserConfig;\n  drawerHeaderSlot?: ReactNode;\n\n  // Slots — render-prop (L13)\n  renderItem?: (args: AppSidebarRenderItemArgs) => ReactNode;\n  renderBadge?: (args: AppSidebarRenderBadgeArgs) => ReactNode;\n  renderTooltipContent?: (args: AppSidebarRenderTooltipContentArgs) => ReactNode;\n  renderSection?: (args: AppSidebarRenderSectionArgs) => ReactNode;\n  renderLoading?: (args: AppSidebarRenderLoadingArgs) => ReactNode;\n  renderEmptyState?: (args: AppSidebarRenderEmptyStateArgs) => ReactNode;\n\n  // Loading\n  loading?: boolean;\n\n  // Events\n  onItemClick?: (args: AppSidebarEventArgs[\"itemClick\"]) => void;\n  onItemHover?: (args: AppSidebarEventArgs[\"itemHover\"]) => void;\n  onItemFocus?: (args: AppSidebarEventArgs[\"itemFocus\"]) => void;\n  onItemNavigate?: (args: AppSidebarEventArgs[\"itemNavigate\"]) => void;\n  onActiveItemChange?: (args: AppSidebarEventArgs[\"activeItemChange\"]) => void;\n  onSectionToggle?: (args: AppSidebarEventArgs[\"sectionToggle\"]) => void;\n  onPermissionDenied?: (args: AppSidebarEventArgs[\"permissionDenied\"]) => void;\n  onBrandClick?: (args: AppSidebarEventArgs[\"brandClick\"]) => void;\n  onPrimaryActionClick?: (args: AppSidebarEventArgs[\"primaryActionClick\"]) => void;\n  onFooterTriggerOpen?: (args: AppSidebarEventArgs[\"footerTriggerOpen\"]) => void;\n  onFooterMenuItemClick?: (args: AppSidebarEventArgs[\"footerMenuItemClick\"]) => void;\n  onSkipLinkActivated?: (args: AppSidebarEventArgs[\"skipLinkActivated\"]) => void;\n  onMount?: (args: AppSidebarEventArgs[\"mount\"]) => void;\n  onUnmount?: () => void;\n\n  // Standard\n  className?: string;\n  style?: CSSProperties;\n  \"aria-label\"?: string;\n  id?: string;\n  skipLinkTarget?: string;\n  skipLinkLabel?: string;\n  ref?: Ref<AppSidebarHandle>;\n\n  // ───────────────────────────────────────────────────────────────────────\n  // v0.2.0 — additive expansion (L41–L52). Zero breaking changes for v0.1.x\n  // consumers; every new prop is optional and defaults to v0.1 behavior.\n  // ───────────────────────────────────────────────────────────────────────\n\n  /**\n   * v0.2.0 — Slot above the brand row. Geographically distinct from v0.1's\n   * `headerSlot` (which is INSIDE the brand row, to the LEFT of brand).\n   * Hierarchy top → bottom: `topSlot` → `headerSlot` → `brandSlot` →\n   * `navAccessorySlot`. Consumer Fragment-stacks if multiple widgets\n   * needed (L41). Canonical occupant: `<AccountSwitcher>`.\n   */\n  topSlot?: ReactNode;\n\n  /**\n   * v0.2.0 — Map of placeholder values for href substitution. When present,\n   * every `{key}` substring in any `NavItem.href` is replaced with\n   * `templateValues[key]` via `String.prototype.replaceAll`. Items whose\n   * href has no `{...}` placeholders render unchanged. Combines with\n   * `resolveHref` — `resolveHref` wins when both provided (L43).\n   *\n   * Dev-warns when an href references a `{xxx}` placeholder not present\n   * in the map (only at the substitution site; tree-shaken in prod).\n   */\n  hrefTemplateValues?: Record<string, string>;\n\n  /**\n   * v0.2.0 — Escape-hatch callback for href resolution. When provided,\n   * wins precedence over `hrefTemplateValues`. Called per item per render\n   * — should be a stable function (`useCallback`). Return value is the\n   * final href string (strict `string`; consumer suppresses href by\n   * removing the item from `items`).\n   */\n  resolveHref?: (\n    item: NavItem,\n    templateValues: Record<string, string> | undefined,\n  ) => string;\n\n  /**\n   * v0.2.0 — Whether the current user is an owner. Fed into the filter\n   * pass; default `false` → all `ownerOnly` items hidden. Raw scalar, not\n   * opaque membership object (L52).\n   */\n  isOwner?: boolean;\n\n  /**\n   * v0.2.0 — Current plan-tier seat capacity. Fed into the filter pass;\n   * default `Infinity` → all `minMembers` items visible.\n   */\n  currentMaxMembers?: number;\n\n  /**\n   * v0.2.0 — When `true`, bypass the three permission gates (`permission`\n   * / `ownerOnly` / `minMembers`) at BOTH section AND item levels (Finding\n   * 4 — prevents \"section disappears, items remain\" inconsistency).\n   * `hidden: true` is still respected (Q21). Use case: personal-context\n   * shortcuts, admin overrides, debug views.\n   */\n  bypassFiltering?: boolean;\n}\n\n// ─────────────────────────────────────────────────────────────────────────\n// Companion component (L17)\n// ─────────────────────────────────────────────────────────────────────────\n\nexport interface AppSidebarTriggerProps {\n  controls?: Ref<AppSidebarHandle> | AppSidebarHandle | null;\n  className?: string;\n  children?: ReactNode;\n  \"aria-label\"?: string;\n  asChild?: boolean;\n}\n\n// ─────────────────────────────────────────────────────────────────────────\n// Headless hook options (L16)\n// ─────────────────────────────────────────────────────────────────────────\n\nexport interface UseAppSidebarStateOptions {\n  defaultCollapsed?: boolean;\n  defaultMobileOpen?: boolean;\n  defaultCollapsedSectionIds?: ReadonlyArray<string>;\n  items?: ReadonlyArray<NavEntry>;\n  currentPath?: string;\n  isActive?: (item: NavItem, currentPath: string) => boolean;\n  defaultMatch?: \"exact\" | \"prefix\";\n  permissions?: ReadonlySet<string>;\n  storageKey?: string;\n  autoExpandActiveSection?: boolean;\n}\n",
      "type": "registry:component",
      "target": "components/app-sidebar/types.ts"
    },
    {
      "path": "src/registry/components/navigation/app-sidebar/contexts/sidebar-nav-context.tsx",
      "content": "\"use client\";\n\nimport { createContext, useContext } from \"react\";\nimport type { SidebarReducerAction, SidebarReducerState } from \"../lib/sidebar-reducer\";\nimport type { AppSidebarHandle } from \"../types\";\n\n/**\n * Context shape consumed by <AppSidebarTrigger> (C7), prefab parts\n * (<NavBrand>, <NavUser>, <NavBadge>) (C6/C9), and any consumer-rendered\n * descendant that wants to read sidebar state without prop drilling.\n *\n * L40: each <AppSidebar> creates its own provider scoped to its subtree,\n * so multi-instance pages get isolated contexts automatically. <Trigger>\n * reads the NEAREST provider (standard React context behavior).\n *\n * The context value is `null` when there's no provider above — descendants\n * MUST handle this (e.g., <Trigger> falls back to disabled + dev warn;\n * <NavBadge> falls back to position=\"inline-end\" per L46).\n */\nexport interface AppSidebarContextValue {\n  // Latest reducer state — descendants read here, not via prop drilling\n  state: SidebarReducerState;\n\n  // Dispatch — descendants use this to mutate (e.g., <Trigger> dispatches TOGGLE_MOBILE)\n  dispatch: React.Dispatch<SidebarReducerAction>;\n\n  // Imperative handle — same surface as the <AppSidebar ref={...}>\n  // (descendants like <Trigger> use this when they're given an explicit `controls` prop)\n  handle: AppSidebarHandle;\n\n  // Element id of the sidebar — `<Trigger aria-controls>` hooks into this\n  sidebarId: string;\n}\n\nexport const AppSidebarContext = createContext<AppSidebarContextValue | null>(null);\nAppSidebarContext.displayName = \"AppSidebarContext\";\n\n/** Internal helper — returns the context value or null. Used by prefab parts + trigger. */\nexport function useAppSidebarContextOrNull(): AppSidebarContextValue | null {\n  return useContext(AppSidebarContext);\n}\n",
      "type": "registry:component",
      "target": "components/app-sidebar/contexts/sidebar-nav-context.tsx"
    },
    {
      "path": "src/registry/components/navigation/app-sidebar/hooks/use-active-detection.ts",
      "content": "import { useMemo } from \"react\";\nimport { computeActiveItem, type ActiveItemResult } from \"../lib/compute-active-item\";\nimport { deriveVisibleEntries, type VisibleEntriesResult } from \"../lib/derive-visible-entries\";\nimport type { NavEntry, NavItem } from \"../types\";\n\ninterface UseActiveDetectionOptions {\n  items: ReadonlyArray<NavEntry>;\n  currentPath: string;\n  isActive?: (item: NavItem, currentPath: string) => boolean;\n  defaultMatch?: \"exact\" | \"prefix\";\n  permissions?: ReadonlySet<string>;\n  keepEmptySections?: boolean;\n  // v0.2.0 — gates threaded to deriveVisibleEntries (L44 / L45 / Q21).\n  isOwner?: boolean;\n  currentMaxMembers?: number;\n  bypassFiltering?: boolean;\n}\n\nexport interface UseActiveDetectionResult {\n  visible: VisibleEntriesResult;\n  active: ActiveItemResult;\n}\n\n/**\n * Memoize the items[] → visible-entries → active-item pipeline.\n *\n * Reference stability of `items` is critical (L34) — non-memoized items[]\n * invalidate this memo every render. Guide.md teaches consumers to memoize.\n *\n * v0.2.0 — also threads `isOwner`, `currentMaxMembers`, `bypassFiltering`\n * into the deriveVisibleEntries call (L44–L46 + Q21).\n */\nexport function useActiveDetection(\n  options: UseActiveDetectionOptions,\n): UseActiveDetectionResult {\n  const visible = useMemo(\n    () =>\n      deriveVisibleEntries({\n        items: options.items,\n        permissions: options.permissions,\n        keepEmptySections: options.keepEmptySections,\n        isOwner: options.isOwner,\n        currentMaxMembers: options.currentMaxMembers,\n        bypassFiltering: options.bypassFiltering,\n      }),\n    [\n      options.items,\n      options.permissions,\n      options.keepEmptySections,\n      options.isOwner,\n      options.currentMaxMembers,\n      options.bypassFiltering,\n    ],\n  );\n\n  const active = useMemo(\n    () =>\n      computeActiveItem({\n        entries: visible.entries,\n        currentPath: options.currentPath,\n        isActive: options.isActive,\n        defaultMatch: options.defaultMatch,\n      }),\n    [visible.entries, options.currentPath, options.isActive, options.defaultMatch],\n  );\n\n  return { visible, active };\n}\n",
      "type": "registry:component",
      "target": "components/app-sidebar/hooks/use-active-detection.ts"
    },
    {
      "path": "src/registry/components/navigation/app-sidebar/hooks/use-match-media.ts",
      "content": "import { useCallback, useSyncExternalStore } from \"react\";\n\n/**\n * SSR-safe matchMedia hook via `useSyncExternalStore`.\n *\n * Server snapshot returns `false` (matches the SSR HTML). Client snapshot\n * reads `window.matchMedia(query).matches`. Subscribe wires the change\n * event. This avoids the React-19 cascading-render antipattern\n * (setState-in-effect) — `useSyncExternalStore` is the right primitive\n * for synchronizing with browser APIs.\n *\n * IMPORTANT (L44): Mobile-vs-desktop RENDERING is gated by CSS classes\n * (`hidden lg:flex` etc.), NOT by this hook. This hook is for JS BEHAVIOR\n * gating only — e.g., deciding whether `autoCloseMobileOnNavigate` should\n * trigger when an item is clicked. The SSR-default `false` is harmless\n * because CSS owns the visual decision.\n */\nexport function useMatchMedia(query: string): boolean {\n  const subscribe = useCallback(\n    (callback: () => void) => {\n      if (typeof window === \"undefined\" || !window.matchMedia) return () => {};\n      const mql = window.matchMedia(query);\n      if (typeof mql.addEventListener === \"function\") {\n        mql.addEventListener(\"change\", callback);\n        return () => mql.removeEventListener(\"change\", callback);\n      }\n      // Safari < 14 fallback\n      mql.addListener(callback);\n      return () => mql.removeListener(callback);\n    },\n    [query],\n  );\n\n  const getSnapshot = useCallback(() => {\n    if (typeof window === \"undefined\" || !window.matchMedia) return false;\n    return window.matchMedia(query).matches;\n  }, [query]);\n\n  const getServerSnapshot = useCallback(() => false, []);\n\n  return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);\n}\n\n// Tailwind v4 default breakpoints (px). Used to translate the enum\n// values `\"sm\" | \"md\" | \"lg\" | \"xl\" | \"2xl\"` to CSS media queries.\nexport const TAILWIND_BREAKPOINTS = {\n  sm: 640,\n  md: 768,\n  lg: 1024,\n  xl: 1280,\n  \"2xl\": 1536,\n} as const;\n\n/** Resolve a `mobileBreakpoint` prop value to a CSS media query string. */\nexport function resolveBreakpointQuery(\n  bp: \"sm\" | \"md\" | \"lg\" | \"xl\" | \"2xl\" | (string & {}),\n): string {\n  if (bp in TAILWIND_BREAKPOINTS) {\n    const px = TAILWIND_BREAKPOINTS[bp as keyof typeof TAILWIND_BREAKPOINTS];\n    return `(max-width: ${px - 1}px)`;\n  }\n  // Raw CSS query passed through (PQ7 union with raw string)\n  return bp;\n}\n",
      "type": "registry:component",
      "target": "components/app-sidebar/hooks/use-match-media.ts"
    },
    {
      "path": "src/registry/components/navigation/app-sidebar/hooks/use-sidebar-nav-state.ts",
      "content": "\"use client\";\n\nimport { useEffect, useMemo } from \"react\";\nimport type {\n  NavEntry,\n  AppSidebarHandle,\n  AppSidebarStateValue,\n  UseAppSidebarStateOptions,\n} from \"../types\";\nimport { buildHandle } from \"../lib/build-handle\";\nimport { useActiveDetection } from \"./use-active-detection\";\nimport { useSidebarReducer } from \"./use-sidebar-reducer\";\nimport { useStorageSync } from \"./use-storage-sync\";\n\n/**\n * Headless state hook — public API (L16).\n *\n * Returns a AppSidebarStateValue (superset of AppSidebarHandle plus\n * live state fields). Consumers using slots heavily OR building their\n * own UI lift state via this hook and pass it back to `<AppSidebar>`\n * via the `state` prop (which wins over individual props per L30).\n *\n * `<AppSidebar>` calls this hook internally too — the public hook is\n * the durable composition seam, not a parallel state machine.\n */\nexport function useAppSidebarState(\n  options: UseAppSidebarStateOptions = {},\n): AppSidebarStateValue {\n  const items = options.items ?? EMPTY_ITEMS;\n\n  // Section-collapse init priority: explicit prop > per-section\n  // defaultCollapsed field. Storage rehydration outranks both via\n  // EXTERNAL_SYNC after mount.\n  const initialCollapsedSectionIds = useMemo(() => {\n    if (options.defaultCollapsedSectionIds) {\n      return options.defaultCollapsedSectionIds;\n    }\n    const fromItems: string[] = [];\n    for (const entry of items) {\n      if (entry.kind === \"section\" && entry.defaultCollapsed) {\n        fromItems.push(entry.id);\n      }\n    }\n    return fromItems;\n  }, [options.defaultCollapsedSectionIds, items]);\n\n  const { state, dispatch } = useSidebarReducer({\n    defaultCollapsed: options.defaultCollapsed,\n    defaultMobileOpen: options.defaultMobileOpen,\n    defaultCollapsedSectionIds: initialCollapsedSectionIds,\n  });\n\n  // localStorage opt-in\n  useStorageSync(state, dispatch, options.storageKey);\n\n  // Active detection (filtered visible entries + active item)\n  const { visible, active } = useActiveDetection({\n    items,\n    currentPath: options.currentPath ?? \"\",\n    isActive: options.isActive,\n    defaultMatch: options.defaultMatch,\n    permissions: options.permissions,\n  });\n\n  // F1 — auto-expand section containing the active item (L48-b)\n  const autoExpandActiveSection = options.autoExpandActiveSection ?? true;\n  useEffect(() => {\n    if (!autoExpandActiveSection) return;\n    if (!active.sectionId) return;\n    if (!state.collapsedSectionIds.has(active.sectionId)) return;\n    dispatch({\n      type: \"SET_SECTION_COLLAPSED\",\n      sectionId: active.sectionId,\n      collapsed: false,\n    });\n  }, [\n    autoExpandActiveSection,\n    active.sectionId,\n    state.collapsedSectionIds,\n    dispatch,\n  ]);\n\n  // v0.3.0 (C5, F5): delegated to the shared `buildHandle` factory. Identical\n  // factory consumed by `app-sidebar.tsx` so the two state paths can't drift.\n  const handle = useMemo<AppSidebarHandle>(\n    () => buildHandle({ state, dispatch, items, visible, active }),\n    [state, dispatch, items, visible, active],\n  );\n\n  return useMemo<AppSidebarStateValue>(\n    () => ({\n      ...handle,\n      collapsed: state.collapsed,\n      mobileOpen: state.mobileOpen,\n      collapsedSectionIds: state.collapsedSectionIds,\n      activeItemId: active.item?.id ?? null,\n      activeItem: active.item,\n      visibleEntries: visible.entries,\n    }),\n    [handle, state, active, visible],\n  );\n}\n\nconst EMPTY_ITEMS: ReadonlyArray<NavEntry> = [];\n",
      "type": "registry:component",
      "target": "components/app-sidebar/hooks/use-sidebar-nav-state.ts"
    },
    {
      "path": "src/registry/components/navigation/app-sidebar/hooks/use-sidebar-reducer.ts",
      "content": "import { useReducer, useEffect, useMemo, useRef, useCallback } from \"react\";\nimport {\n  type SidebarReducerAction,\n  type SidebarReducerInitOptions,\n  type SidebarReducerState,\n  createInitialState,\n  sidebarReducer,\n} from \"../lib/sidebar-reducer\";\nimport type { AppSidebarEventArgs } from \"../types\";\n\nexport interface UseSidebarReducerOptions extends SidebarReducerInitOptions {\n  // Controlled-mode props (when provided, drive state via EXTERNAL_SYNC)\n  isCollapsed?: boolean;\n  isMobileOpen?: boolean;\n\n  // Defense 1 — microtask-defer for these\n  onCollapsedChange?: (args: AppSidebarEventArgs[\"collapsedChange\"]) => void;\n  onMobileOpenChange?: (args: AppSidebarEventArgs[\"mobileOpenChange\"]) => void;\n}\n\nexport interface UseSidebarReducerResult {\n  state: SidebarReducerState;\n  dispatch: React.Dispatch<SidebarReducerAction>;\n}\n\n/**\n * Internal reducer hook.\n *\n * Three-defenses controlled-mode wiring (L7):\n *   • Defense 1 — microtask-defer onCollapsedChange / onMobileOpenChange callbacks\n *     so consumers that synchronously sync to other state can't re-enter the reducer.\n *   • Defense 2 — content-equality short-circuit on EXTERNAL_SYNC (handled inside\n *     reducer's `EXTERNAL_SYNC` case via `lastSyncedSnapshot` check).\n *   • Defense 3 — N/A for discrete boolean state (no continuous flow to suppress).\n *\n * NOT exported from index.ts — purely internal.\n */\nexport function useSidebarReducer(\n  options: UseSidebarReducerOptions = {},\n): UseSidebarReducerResult {\n  const [state, dispatch] = useReducer(\n    sidebarReducer,\n    {\n      defaultCollapsed: options.defaultCollapsed,\n      defaultMobileOpen: options.defaultMobileOpen,\n      defaultCollapsedSectionIds: options.defaultCollapsedSectionIds,\n    },\n    createInitialState,\n  );\n\n  // Latest-ref pattern for callbacks (avoid stale closures in microtask defers)\n  const onCollapsedChangeRef = useRef(options.onCollapsedChange);\n  const onMobileOpenChangeRef = useRef(options.onMobileOpenChange);\n  useEffect(() => {\n    onCollapsedChangeRef.current = options.onCollapsedChange;\n    onMobileOpenChangeRef.current = options.onMobileOpenChange;\n  });\n\n  // Controlled-mode → reducer sync (Defense 2 short-circuit inside reducer)\n  useEffect(() => {\n    if (options.isCollapsed === undefined && options.isMobileOpen === undefined) {\n      return;\n    }\n    dispatch({\n      type: \"EXTERNAL_SYNC\",\n      collapsed: options.isCollapsed ?? state.collapsed,\n      mobileOpen: options.isMobileOpen ?? state.mobileOpen,\n    });\n    // Intentionally NOT including state.* — this effect ONLY fires on controlled\n    // prop changes; internal state changes shouldn't loop back through here.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [options.isCollapsed, options.isMobileOpen]);\n\n  // Defense 1 — fire change callbacks via microtask defer when state mutates.\n  // Skipped when the transition was EXTERNAL_SYNC-originated (consumer already\n  // knows). v0.3.1 (review 5.4): \"sync-originated\" is now detected by snapshot\n  // IDENTITY (EXTERNAL_SYNC mints a new `lastSyncedSnapshot` object on every\n  // transition it causes) instead of comparing values against the snapshot —\n  // the old value-compare also swallowed INTERNAL toggles that happened to land\n  // back on the last-synced value (e.g. the 2nd click while a constant\n  // controlled prop holds the rendered value), so the consumer never got the\n  // change request.\n  const prevCollapsedRef = useRef(state.collapsed);\n  const prevMobileOpenRef = useRef(state.mobileOpen);\n  const prevSnapshotRef = useRef(state.lastSyncedSnapshot);\n  useEffect(() => {\n    const syncOriginated = prevSnapshotRef.current !== state.lastSyncedSnapshot;\n    prevSnapshotRef.current = state.lastSyncedSnapshot;\n    if (prevCollapsedRef.current !== state.collapsed) {\n      const next = state.collapsed;\n      prevCollapsedRef.current = next;\n      // Skip if this transition came from EXTERNAL_SYNC (consumer originated it)\n      if (!(syncOriginated && state.lastSyncedSnapshot.collapsed === next)) {\n        queueMicrotask(() => {\n          onCollapsedChangeRef.current?.({ collapsed: next });\n        });\n      }\n    }\n    if (prevMobileOpenRef.current !== state.mobileOpen) {\n      const next = state.mobileOpen;\n      prevMobileOpenRef.current = next;\n      if (!(syncOriginated && state.lastSyncedSnapshot.mobileOpen === next)) {\n        // v0.3.0 (C2, L53): read the reason the dispatching action wrote into\n        // reducer state. EXTERNAL_SYNC resets to \"imperative\" on transition;\n        // SET/TOGGLE write the explicit reason; the no-op guard in the reducer\n        // prevents re-entry from Sheet's onOpenChange from overwriting.\n        const reason = state.lastMobileOpenReason ?? \"imperative\";\n        queueMicrotask(() => {\n          onMobileOpenChangeRef.current?.({\n            open: next,\n            reason,\n          });\n        });\n      }\n    }\n  }, [state.collapsed, state.mobileOpen, state.lastSyncedSnapshot, state.lastMobileOpenReason]);\n\n  // Stable dispatch (useReducer's dispatch is already stable; wrap for symmetry)\n  const stableDispatch = useCallback<React.Dispatch<SidebarReducerAction>>(\n    (action) => dispatch(action),\n    [],\n  );\n\n  // v0.3.1 (review 5.4) — controlled props win at READ time, not only on prop\n  // CHANGE. Previously a controlled `isCollapsed`/`isMobileOpen` was only\n  // honored via the EXTERNAL_SYNC effect above (which fires on prop change),\n  // so an internal toggle could diverge the rendered state from a CONSTANT\n  // controlled prop and the prop never won it back. The raw reducer state\n  // still mutates on internal dispatches (so the Defense-1 effect above still\n  // fires the change-request callbacks and the consumer can update its state);\n  // what the component RENDERS is the prop whenever the prop is defined.\n  const derivedState = useMemo<SidebarReducerState>(() => {\n    if (\n      options.isCollapsed === undefined &&\n      options.isMobileOpen === undefined\n    ) {\n      return state;\n    }\n    return {\n      ...state,\n      collapsed: options.isCollapsed ?? state.collapsed,\n      mobileOpen: options.isMobileOpen ?? state.mobileOpen,\n    };\n  }, [state, options.isCollapsed, options.isMobileOpen]);\n\n  return { state: derivedState, dispatch: stableDispatch };\n}\n",
      "type": "registry:component",
      "target": "components/app-sidebar/hooks/use-sidebar-reducer.ts"
    },
    {
      "path": "src/registry/components/navigation/app-sidebar/hooks/use-storage-sync.ts",
      "content": "\"use client\";\n\nimport { useEffect, useRef } from \"react\";\nimport type { SidebarReducerAction, SidebarReducerState } from \"../lib/sidebar-reducer\";\nimport {\n  STORAGE_SCHEMA_VERSION,\n  type StoredState,\n  isStoredState,\n} from \"../lib/storage-schema\";\n\n/**\n * Opt-in localStorage persistence for collapse + collapsed-sections state.\n *\n * Rules (L23):\n *  • No-op when `storageKey` is undefined or `window` is unavailable (SSR).\n *  • Read on mount via useEffect — never during render (SSR-safe).\n *  • Write on state change, debounced 50ms (rapid section-toggle spam\n *    doesn't thrash localStorage).\n *  • mobileOpen is NOT persisted (transient UI, not user-preference).\n *  • Schema-versioned JSON; mismatch silently drops the stored payload.\n *  • Quota / serialization failures silently swallowed.\n */\nexport function useStorageSync(\n  state: SidebarReducerState,\n  dispatch: React.Dispatch<SidebarReducerAction>,\n  storageKey: string | undefined,\n): void {\n  const hasReadRef = useRef(false);\n  const writeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n\n  // Read once on mount (or when storageKey changes)\n  useEffect(() => {\n    if (!storageKey || typeof window === \"undefined\") return;\n    if (hasReadRef.current) return;\n    hasReadRef.current = true;\n    try {\n      const raw = window.localStorage.getItem(storageKey);\n      if (!raw) return;\n      const parsed: unknown = JSON.parse(raw);\n      if (!isStoredState(parsed)) return;\n      dispatch({\n        type: \"EXTERNAL_SYNC\",\n        collapsed: parsed.collapsed,\n        mobileOpen: state.mobileOpen,\n      });\n      // v0.3.1 (review): whole-set REPLACE, not per-id collapse replay.\n      // The old per-id SET-collapsed loop could only ADD collapses — a\n      // section the user had EXPANDED (i.e. removed from the stored array,\n      // but present in defaultCollapsedSectionIds) re-collapsed on every\n      // reload because its default-collapsed state was never cleared.\n      dispatch({\n        type: \"COLLAPSE_ALL_SECTIONS\",\n        allSectionIds: parsed.collapsedSectionIds,\n      });\n    } catch {\n      // Corrupted storage — silently fall back to defaults.\n    }\n    // Intentionally only re-runs when storageKey changes.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [storageKey]);\n\n  // Debounced write on state change\n  useEffect(() => {\n    if (!storageKey || typeof window === \"undefined\") return;\n    if (!hasReadRef.current) return; // don't write before initial read\n    if (writeTimerRef.current) clearTimeout(writeTimerRef.current);\n    writeTimerRef.current = setTimeout(() => {\n      const payload: StoredState = {\n        v: STORAGE_SCHEMA_VERSION,\n        collapsed: state.collapsed,\n        collapsedSectionIds: [...state.collapsedSectionIds],\n      };\n      try {\n        window.localStorage.setItem(storageKey, JSON.stringify(payload));\n      } catch {\n        // Quota or serialization failure — silently ignored.\n      }\n    }, 50);\n\n    return () => {\n      if (writeTimerRef.current) clearTimeout(writeTimerRef.current);\n    };\n  }, [storageKey, state.collapsed, state.collapsedSectionIds]);\n}\n",
      "type": "registry:component",
      "target": "components/app-sidebar/hooks/use-storage-sync.ts"
    },
    {
      "path": "src/registry/components/navigation/app-sidebar/hooks/use-filtered-nav-sections.ts",
      "content": "import { useMemo } from \"react\";\nimport { deriveVisibleEntries } from \"../lib/derive-visible-entries\";\nimport type { NavEntry } from \"../types\";\n\nexport interface UseFilteredNavSectionsOpts {\n  sections: ReadonlyArray<NavEntry>;\n  permissions?: ReadonlySet<string>;\n  isOwner?: boolean;\n  currentMaxMembers?: number;\n  bypassFiltering?: boolean;\n}\n\n/**\n * Pure helper hook — returns the filtered `NavEntry[]` with all three\n * gates applied (permission ∩ ownerOnly ∩ minMembers per L46) and empty\n * sections dropped. `bypassFiltering: true` skips the three permission\n * gates at BOTH section + item levels; `hidden: true` is unconditionally\n * respected (Q21).\n *\n * **Memoized over 5 inputs (Q16)** — returns referentially-stable sections\n * when inputs don't change by reference (or value, for boolean/number).\n * Mitigates R14: downstream `<NavSection>` memoization holds.\n *\n * **Consumer-side memo guidance (Finding 6 / R16):** `permissions` is a\n * `ReadonlySet<string>` — wrap construction in `useMemo([source])` on the\n * caller side, else the hook's memo will invalidate every render.\n *\n * Items-only return per PQ1 — diagnostic struct stays internal to\n * `<AppSidebar>`. NOT coupled to the `<AppSidebar>` component; consumers\n * rendering their own arbitrary sidebar UI use this standalone.\n */\nexport function useFilteredNavSections(\n  opts: UseFilteredNavSectionsOpts,\n): ReadonlyArray<NavEntry> {\n  const { sections, permissions, isOwner, currentMaxMembers, bypassFiltering } = opts;\n\n  return useMemo(\n    () =>\n      deriveVisibleEntries({\n        items: sections,\n        permissions,\n        isOwner,\n        currentMaxMembers,\n        bypassFiltering,\n      }).entries,\n    [sections, permissions, isOwner, currentMaxMembers, bypassFiltering],\n  );\n}\n",
      "type": "registry:component",
      "target": "components/app-sidebar/hooks/use-filtered-nav-sections.ts"
    },
    {
      "path": "src/registry/components/navigation/app-sidebar/lib/active-variant-classes.ts",
      "content": "import type { AppSidebarProps } from \"../types\";\n\ntype Variant = NonNullable<AppSidebarProps[\"activeVariant\"]>;\n\n/**\n * Per-variant Tailwind class composition (L12 + L17).\n *\n * Returns the class string for the link element based on:\n *  - the chosen `activeVariant`\n *  - whether the row is active or not\n *\n * renderItem slot (L13) bypasses this helper entirely — the slot decides\n * how to paint active state.\n */\nexport function getActiveVariantClasses(\n  variant: Variant | undefined,\n  isActive: boolean,\n): string {\n  if (!isActive) {\n    return \"text-foreground hover:bg-muted\";\n  }\n\n  switch (variant ?? \"fill\") {\n    case \"fill\":\n      return \"bg-(--ilinxa-nav-active-bg) text-(--ilinxa-nav-active-fg)\";\n\n    case \"left-bar\":\n      return [\n        \"relative text-(--ilinxa-nav-active-bg)\",\n        \"before:absolute before:left-0 before:top-1.5 before:bottom-1.5\",\n        \"before:w-(--ilinxa-nav-active-bar-w) before:rounded-r-full\",\n        \"before:bg-(--ilinxa-nav-active-bg)\",\n      ].join(\" \");\n\n    case \"right-bar\":\n      return [\n        \"relative text-(--ilinxa-nav-active-bg)\",\n        \"after:absolute after:right-0 after:top-1.5 after:bottom-1.5\",\n        \"after:w-(--ilinxa-nav-active-bar-w) after:rounded-l-full\",\n        \"after:bg-(--ilinxa-nav-active-bg)\",\n      ].join(\" \");\n\n    case \"outline\":\n      return \"ring-2 ring-inset ring-(--ilinxa-nav-active-bg) text-(--ilinxa-nav-active-bg)\";\n\n    case \"subtle\":\n      return \"bg-accent/30 text-foreground font-semibold\";\n\n    default:\n      return \"bg-(--ilinxa-nav-active-bg) text-(--ilinxa-nav-active-fg)\";\n  }\n}\n",
      "type": "registry:component",
      "target": "components/app-sidebar/lib/active-variant-classes.ts"
    },
    {
      "path": "src/registry/components/navigation/app-sidebar/lib/badge-format.ts",
      "content": "/**\n * Format a NavBadge `value` for display.\n *\n * - Numbers > max render as \"{max}+\" (cap overflow)\n * - Numbers ≤ max render as `String(value)`\n * - Non-numbers pass through unchanged (consumer supplied a string / ReactNode)\n * - Zero is filtered upstream by `<NavBadge>` via `showZero` flag — not handled here.\n */\nexport function formatBadgeValue(\n  value: number | string,\n  max: number,\n): string {\n  if (typeof value === \"number\") {\n    return value > max ? `${max}+` : String(value);\n  }\n  return value;\n}\n",
      "type": "registry:component",
      "target": "components/app-sidebar/lib/badge-format.ts"
    },
    {
      "path": "src/registry/components/navigation/app-sidebar/lib/build-handle.ts",
      "content": "import type { ActiveItemResult } from \"./compute-active-item\";\nimport type { VisibleEntriesResult } from \"./derive-visible-entries\";\nimport { flattenEntriesForKeyboard } from \"./flatten-entries\";\nimport type {\n  SidebarReducerAction,\n  SidebarReducerState,\n} from \"./sidebar-reducer\";\nimport type {\n  NavEntry,\n  NavItem,\n  AppSidebarHandle,\n  AppSidebarStateValue,\n} from \"../types\";\n\n/**\n * Build the imperative handle exposed to consumers (`ref` + `<AppSidebar state={…}>`).\n *\n * Shared by:\n *  - `app-sidebar.tsx` — the component's own `useImperativeHandle` target.\n *  - `hooks/use-sidebar-nav-state.ts` — the headless `useAppSidebarState()` hook.\n *\n * v0.3.0 (C5, F5): extracted from two near-identical inline builders that had\n * silently drifted between v0.2 bumps. Single source of truth.\n *\n * NOT exported from `index.ts` — internal-only. Consumers interact via the\n * handle object or the public `useAppSidebarState()` return value.\n */\nexport function buildHandle(deps: {\n  state: SidebarReducerState;\n  dispatch: React.Dispatch<SidebarReducerAction>;\n  items: ReadonlyArray<NavEntry>;\n  visible: VisibleEntriesResult;\n  active: ActiveItemResult;\n}): AppSidebarHandle {\n  const { state, dispatch, items, visible, active } = deps;\n\n  // Lookup table for getItemById — uses visible.entries so consumers don't\n  // resolve items hidden by permission / ownerOnly / minMembers gates.\n  const itemsLookup = new Map<string, NavItem>();\n  for (const entry of visible.entries) {\n    if (entry.kind === \"section\") {\n      for (const child of entry.items) itemsLookup.set(child.id, child);\n    } else if (entry.kind !== \"separator\") {\n      itemsLookup.set(entry.id, entry);\n    }\n  }\n\n  const methods: Omit<AppSidebarHandle, \"getState\"> = {\n    // Collapse\n    toggleCollapse: () => dispatch({ type: \"TOGGLE_COLLAPSED\" }),\n    setCollapsed: (next) =>\n      dispatch({ type: \"SET_COLLAPSED\", collapsed: next }),\n    isCollapsed: () => state.collapsed,\n\n    // Mobile drawer — v0.3.0 (C2, L54) reason? plumbing.\n    openMobile: (reason) =>\n      dispatch({\n        type: \"SET_MOBILE_OPEN\",\n        open: true,\n        reason: reason ?? \"imperative\",\n      }),\n    closeMobile: (reason) =>\n      dispatch({\n        type: \"SET_MOBILE_OPEN\",\n        open: false,\n        reason: reason ?? \"imperative\",\n      }),\n    // TOGGLE_MOBILE (NOT a translated SET) so the reducer reads FRESH\n    // state.mobileOpen — handles rapid same-tick double-clicks correctly\n    // (a translated SET would use closure-captured stale state and the\n    // reducer's no-op guard would drop the second dispatch).\n    toggleMobile: (reason) =>\n      dispatch({ type: \"TOGGLE_MOBILE\", reason }),\n    isMobileOpen: () => state.mobileOpen,\n\n    // Section state — v0.3.0 (C5, F8): no allSectionIds payload on EXPAND.\n    toggleSection: (sectionId) =>\n      dispatch({ type: \"TOGGLE_SECTION\", sectionId }),\n    expandSection: (sectionId) =>\n      dispatch({ type: \"SET_SECTION_COLLAPSED\", sectionId, collapsed: false }),\n    collapseSection: (sectionId) =>\n      dispatch({ type: \"SET_SECTION_COLLAPSED\", sectionId, collapsed: true }),\n    expandAllSections: () => dispatch({ type: \"EXPAND_ALL_SECTIONS\" }),\n    collapseAllSections: () => {\n      // v0.3.0 (C5, F9): source from visible.entries (post-filter) so we\n      // don't collapse sections the user can't see.\n      const ids = visible.entries\n        .filter((e) => \"kind\" in e && e.kind === \"section\")\n        .map((e) => (e as { id: string }).id);\n      dispatch({ type: \"COLLAPSE_ALL_SECTIONS\", allSectionIds: ids });\n    },\n    isSectionCollapsed: (id) => state.collapsedSectionIds.has(id),\n\n    // Items + active\n    getItems: () => items,\n    getItemById: (id) => itemsLookup.get(id),\n    getActiveItem: () => active.item ?? undefined,\n\n    // Focus — v0.3.1 (review): focusFirstItem/focusLastItem were public\n    // no-ops (both dispatched `itemId: null`). Now resolved against the\n    // same flattened keyboard-traversal sequence the arrow keys use\n    // (visible entries minus collapsed-section items / disabled items).\n    focusItem: (itemId) => dispatch({ type: \"FOCUS_ITEM\", itemId }),\n    focusFirstItem: () => {\n      const flat = flattenEntriesForKeyboard(\n        visible.entries,\n        state.collapsedSectionIds,\n      );\n      if (flat.length > 0) {\n        dispatch({ type: \"FOCUS_ITEM\", itemId: flat[0].id });\n      }\n    },\n    focusLastItem: () => {\n      const flat = flattenEntriesForKeyboard(\n        visible.entries,\n        state.collapsedSectionIds,\n      );\n      if (flat.length > 0) {\n        dispatch({ type: \"FOCUS_ITEM\", itemId: flat[flat.length - 1].id });\n      }\n    },\n  };\n\n  const handleObj: AppSidebarHandle = {\n    ...methods,\n    getState: (): AppSidebarStateValue => ({\n      ...handleObj,\n      collapsed: state.collapsed,\n      mobileOpen: state.mobileOpen,\n      collapsedSectionIds: state.collapsedSectionIds,\n      activeItemId: active.item?.id ?? null,\n      activeItem: active.item,\n      visibleEntries: visible.entries,\n    }),\n  };\n  return handleObj;\n}\n",
      "type": "registry:component",
      "target": "components/app-sidebar/lib/build-handle.ts"
    },
    {
      "path": "src/registry/components/navigation/app-sidebar/lib/compute-active-item.ts",
      "content": "import type { NavEntry, NavItem } from \"../types\";\n\nexport interface ActiveItemResult {\n  item: NavItem | null;\n  // Section ID the active item lives inside (null = top-level)\n  sectionId: string | null;\n}\n\ninterface ComputeOptions {\n  // Already-filtered visible entries (post-permissions, post-hidden)\n  entries: ReadonlyArray<NavEntry>;\n  currentPath: string;\n  isActive?: (item: NavItem, currentPath: string) => boolean;\n  defaultMatch?: \"exact\" | \"prefix\";\n}\n\n/**\n * Compute the active NavItem given a currentPath.\n *\n * Resolution order (L9):\n *   1. `isActive` predicate (if supplied) wins for every item.\n *   2. Per-item `match: \"exact\" | \"prefix\"` fallback.\n *   3. `defaultMatch` (default \"exact\") for items without `match`.\n *\n * Tie-break (L42): longest matching `item.href` wins for prefix matches.\n */\nexport function computeActiveItem(opts: ComputeOptions): ActiveItemResult {\n  const { entries, currentPath, isActive, defaultMatch = \"exact\" } = opts;\n\n  // Flatten: track each NavItem alongside its containing sectionId (null if top-level).\n  const flat: { item: NavItem; sectionId: string | null }[] = [];\n  for (const entry of entries) {\n    if (entry.kind === \"section\") {\n      for (const child of entry.items) {\n        if (child.disabled) continue;\n        flat.push({ item: child, sectionId: entry.id });\n      }\n    } else if (entry.kind === \"separator\") {\n      continue;\n    } else {\n      if (entry.disabled) continue;\n      flat.push({ item: entry, sectionId: null });\n    }\n  }\n\n  if (isActive) {\n    // Predicate wins; first match in flat order.\n    for (const { item, sectionId } of flat) {\n      if (isActive(item, currentPath)) return { item, sectionId };\n    }\n    return { item: null, sectionId: null };\n  }\n\n  // Exact pass first — any exact match wins immediately (cheaper than longest-prefix)\n  for (const { item, sectionId } of flat) {\n    const mode = item.match ?? defaultMatch;\n    if (mode === \"exact\" && item.href === currentPath) {\n      return { item, sectionId };\n    }\n  }\n\n  // Prefix pass — collect all prefix candidates, pick longest href.\n  let best: { item: NavItem; sectionId: string | null; length: number } | null = null;\n  for (const { item, sectionId } of flat) {\n    const mode = item.match ?? defaultMatch;\n    if (mode !== \"prefix\" || !item.href) continue;\n    // True prefix: currentPath starts with href + (next char is \"/\" or end)\n    if (!currentPath.startsWith(item.href)) continue;\n    const after = currentPath.charAt(item.href.length);\n    if (after !== \"\" && after !== \"/\") continue;\n    const length = item.href.length;\n    if (!best || length > best.length) best = { item, sectionId, length };\n  }\n\n  if (best) return { item: best.item, sectionId: best.sectionId };\n  return { item: null, sectionId: null };\n}\n",
      "type": "registry:component",
      "target": "components/app-sidebar/lib/compute-active-item.ts"
    },
    {
      "path": "src/registry/components/navigation/app-sidebar/lib/derive-avatar-fallback.ts",
      "content": "/**\n * Derive 2-character avatar fallback initials from a name (L35).\n *\n * Examples:\n *   \"Ahmet Kaya\"      → \"AK\"\n *   \"Slack\"           → \"SL\"\n *   \"  spaced  out  \" → \"SO\"\n *   \"\"                → \"?\"\n *   \"🙃 emoji\"         → \"🙃E\"  (preserves leading char graceful for emoji)\n */\nexport function deriveAvatarFallback(name: string | undefined | null): string {\n  if (!name) return \"?\";\n  const words = name.trim().split(/\\s+/).filter(Boolean);\n  if (words.length === 0) return \"?\";\n  if (words.length === 1) {\n    return words[0].slice(0, 2).toUpperCase();\n  }\n  return (words[0][0] + words[1][0]).toUpperCase();\n}\n",
      "type": "registry:component",
      "target": "components/app-sidebar/lib/derive-avatar-fallback.ts"
    },
    {
      "path": "src/registry/components/navigation/app-sidebar/lib/derive-css-vars.ts",
      "content": "import type { CSSProperties } from \"react\";\nimport type { AppSidebarProps } from \"../types\";\n\n/**\n * Build the inline-style object that sets the component's CSS variables\n * on the <nav> root. Consumer CSS at any ancestor scope wins via cascade;\n * consumer props win over defaults (L11 + L16).\n */\nexport function deriveCssVars(\n  props: Pick<\n    AppSidebarProps,\n    \"collapsedWidth\" | \"expandedWidth\" | \"transitionDuration\"\n  >,\n): CSSProperties {\n  return {\n    \"--ilinxa-sidebar-w-collapsed\": props.collapsedWidth ?? \"5rem\",\n    \"--ilinxa-sidebar-w-expanded\": props.expandedWidth ?? \"16rem\",\n    \"--ilinxa-sidebar-transition-duration\": props.transitionDuration ?? \"300ms\",\n    \"--ilinxa-sidebar-row-h\": \"2.75rem\",\n    \"--ilinxa-sidebar-row-gap\": \"0.25rem\",\n    \"--ilinxa-sidebar-px\": \"0.75rem\",\n    \"--ilinxa-nav-active-bg\": \"var(--primary)\",\n    \"--ilinxa-nav-active-fg\": \"var(--primary-foreground)\",\n    \"--ilinxa-nav-active-bar-w\": \"3px\",\n    \"--ilinxa-nav-badge-size\": \"1.25rem\",\n    \"--ilinxa-nav-indent-step\": \"0.75rem\",\n  } as CSSProperties;\n}\n",
      "type": "registry:component",
      "target": "components/app-sidebar/lib/derive-css-vars.ts"
    },
    {
      "path": "src/registry/components/navigation/app-sidebar/lib/derive-visible-entries.ts",
      "content": "import type { NavEntry, NavItem, NavSection } from \"../types\";\n\nexport interface VisibleEntriesResult {\n  // Filtered + ordered for render. Sections retain their inner items[]\n  // already-filtered. Separators pass through.\n  entries: ReadonlyArray<NavEntry>;\n\n  // Diagnostic — which items dropped due to which gate. Used by\n  // onPermissionDenied diff-firing (L38).\n  filteredByPermission: ReadonlyArray<{ item: NavItem; requiredPermission: string }>;\n\n  // Total NavItem count BEFORE filtering (for the empty-state \"no-items\" branch).\n  totalItemCount: number;\n\n  // Count of items that exist but were hidden (manual `hidden: true`).\n  hiddenItemCount: number;\n}\n\ninterface DeriveOptions {\n  items: ReadonlyArray<NavEntry>;\n  permissions?: ReadonlySet<string>;\n  keepEmptySections?: boolean;\n  /**\n   * v0.2.0 — Whether the current user is an owner. Used by `ownerOnly`\n   * gate (L44). Default `false` (matches v0.1 behavior — no items have\n   * `ownerOnly` in v0.1.x so default is a no-op for legacy callers).\n   */\n  isOwner?: boolean;\n  /**\n   * v0.2.0 — Current plan-tier seat capacity. Used by `minMembers` gate\n   * (L45). Default `Infinity` (matches v0.1 behavior — no items have\n   * `minMembers` in v0.1.x so default is a no-op for legacy callers).\n   */\n  currentMaxMembers?: number;\n  /**\n   * v0.2.0 — When `true`, bypass ALL permission gates (permission /\n   * ownerOnly / minMembers) at BOTH section AND item levels (Q21 +\n   * re-validation Finding 4). `hidden: true` is unconditionally respected.\n   * Default `false`.\n   */\n  bypassFiltering?: boolean;\n}\n\n/**\n * Filter and shape the items[] for render.\n *\n * Gating order per NavItem:\n *   1. hidden === true → drop (no diagnostic, ALWAYS respected — Q21)\n *   2. If !bypassFiltering: permission ∩ ownerOnly ∩ minMembers (L46)\n *\n * NavSection gates the same way at the section level (whole-group drop) —\n * `bypassFiltering` applies at BOTH levels coherently per Finding 4 from\n * the GATE 2 re-validation (else \"section disappears, items remain\"\n * inconsistency). Empty sections after item filter auto-hide unless\n * keepEmptySections.\n *\n * v0.1 callers (without isOwner / currentMaxMembers / bypassFiltering)\n * see byte-identical behavior because the new gates only fire when the\n * corresponding optional NavItem fields are present + the bypass flag is\n * false; defaults are explicit per Finding 5.\n */\nexport function deriveVisibleEntries(opts: DeriveOptions): VisibleEntriesResult {\n  const {\n    items,\n    permissions,\n    keepEmptySections = false,\n    isOwner = false,\n    currentMaxMembers = Infinity,\n    bypassFiltering = false,\n  } = opts;\n\n  const out: NavEntry[] = [];\n  const filteredByPermission: { item: NavItem; requiredPermission: string }[] = [];\n  let totalItemCount = 0;\n  let hiddenItemCount = 0;\n\n  const passesItemGates = (item: NavItem): { pass: boolean; missingPermission?: string } => {\n    if (bypassFiltering) return { pass: true };\n    if (item.permission && !(permissions?.has(item.permission) ?? false)) {\n      return { pass: false, missingPermission: item.permission };\n    }\n    if (item.ownerOnly && !isOwner) return { pass: false };\n    if (item.minMembers !== undefined && currentMaxMembers < item.minMembers) {\n      return { pass: false };\n    }\n    return { pass: true };\n  };\n\n  for (const entry of items) {\n    if (entry.kind === \"separator\") {\n      out.push(entry);\n      continue;\n    }\n\n    if (entry.kind === \"section\") {\n      const section = entry as NavSection;\n      if (section.hidden) continue;\n      // Section perm gate — also skipped by bypassFiltering per Finding 4.\n      if (\n        !bypassFiltering &&\n        section.permission &&\n        !(permissions?.has(section.permission) ?? false)\n      ) {\n        continue;\n      }\n      const visibleInner: NavItem[] = [];\n      for (const child of section.items) {\n        totalItemCount += 1;\n        if (child.hidden) {\n          hiddenItemCount += 1;\n          continue;\n        }\n        const gate = passesItemGates(child);\n        if (!gate.pass) {\n          if (gate.missingPermission) {\n            filteredByPermission.push({ item: child, requiredPermission: gate.missingPermission });\n          }\n          continue;\n        }\n        visibleInner.push(child);\n      }\n      if (visibleInner.length === 0 && !keepEmptySections) continue;\n      out.push({ ...section, items: visibleInner });\n      continue;\n    }\n\n    // Top-level NavItem\n    const item = entry as NavItem;\n    totalItemCount += 1;\n    if (item.hidden) {\n      hiddenItemCount += 1;\n      continue;\n    }\n    const gate = passesItemGates(item);\n    if (!gate.pass) {\n      if (gate.missingPermission) {\n        filteredByPermission.push({ item, requiredPermission: gate.missingPermission });\n      }\n      continue;\n    }\n    out.push(item);\n  }\n\n  return {\n    entries: out,\n    filteredByPermission,\n    totalItemCount,\n    hiddenItemCount,\n  };\n}\n",
      "type": "registry:component",
      "target": "components/app-sidebar/lib/derive-visible-entries.ts"
    },
    {
      "path": "src/registry/components/navigation/app-sidebar/lib/flatten-entries.ts",
      "content": "import type { NavEntry } from \"../types\";\n\n/**\n * Linear focus-traversal slot in the keyboard nav sequence.\n *\n * - `kind: \"section-header\"` — a focusable section header (the section was\n *   declared `collapsible: true`). Non-collapsible section headers render\n *   as a static `<h6>` and are excluded from focus traversal (L37).\n * - `kind: \"item\"` — a NavItem row. `sectionId` is the parent section id\n *   when the item lives inside a section, or `null` for top-level items.\n *\n * The `id` field is the row's stable id (item.id or section.id) and is\n * what the reducer's `FOCUS_ITEM` action stores.\n */\nexport interface SidebarKeyboardEntry {\n  id: string;\n  kind: \"section-header\" | \"item\";\n  sectionId: string | null;\n}\n\n/**\n * Flatten the rendered NavEntry tree into the keyboard traversal sequence.\n *\n * Rules (per plan §17.2 + L37):\n *  - Separators are skipped.\n *  - Collapsible section headers are included; non-collapsible headers are\n *    not focusable so they're omitted.\n *  - When a section is collapsed, its items are not in the sequence.\n *  - Disabled items are skipped (they're not interactive).\n */\nexport function flattenEntriesForKeyboard(\n  entries: ReadonlyArray<NavEntry>,\n  collapsedSectionIds: ReadonlySet<string>,\n): ReadonlyArray<SidebarKeyboardEntry> {\n  const out: SidebarKeyboardEntry[] = [];\n  for (const entry of entries) {\n    if (entry.kind === \"separator\") continue;\n    if (entry.kind === \"section\") {\n      if (entry.collapsible) {\n        out.push({ id: entry.id, kind: \"section-header\", sectionId: entry.id });\n      }\n      if (collapsedSectionIds.has(entry.id)) continue;\n      for (const child of entry.items) {\n        if (child.disabled) continue;\n        out.push({ id: child.id, kind: \"item\", sectionId: entry.id });\n      }\n      continue;\n    }\n    if (entry.disabled) continue;\n    out.push({ id: entry.id, kind: \"item\", sectionId: null });\n  }\n  return out;\n}\n",
      "type": "registry:component",
      "target": "components/app-sidebar/lib/flatten-entries.ts"
    },
    {
      "path": "src/registry/components/navigation/app-sidebar/lib/href-resolver.ts",
      "content": "import type { NavItem } from \"../types\";\n\nexport interface HrefResolverOpts {\n  templateValues?: Record<string, string>;\n  resolveHref?: (item: NavItem, values: Record<string, string> | undefined) => string;\n}\n\n/**\n * Resolve a NavItem's href to its final string form (v0.2.0).\n *\n * Precedence per L43:\n *   1. `resolveHref` callback (if provided) — return value is final\n *   2. `{key}` substitution from `templateValues` (when item.href has placeholders)\n *   3. `item.href` as-is\n *\n * Returns `undefined` when the item has no href (consumer rendered as label).\n *\n * Dev-mode warns when `item.href` references `{xxx}` placeholders not present\n * in `templateValues` (Q19; missing-only — unused values are common and\n * silently ignored). Warning is NODE_ENV-gated so it tree-shakes from prod\n * bundles.\n */\nexport function resolveItemHref(\n  item: NavItem,\n  opts: HrefResolverOpts,\n): string | undefined {\n  const { templateValues, resolveHref } = opts;\n\n  if (resolveHref) {\n    return resolveHref(item, templateValues);\n  }\n\n  if (!item.href) return undefined;\n  if (!templateValues) return item.href;\n\n  return substituteTemplate(item.href, templateValues);\n}\n\n/**\n * Set-based dedup per re-validation Finding 3 — distinct placeholders\n * trigger ONE `replaceAll` call each + one dev-warn entry each, regardless\n * of how many times the placeholder appears in the href.\n */\nfunction substituteTemplate(\n  href: string,\n  values: Record<string, string>,\n): string {\n  const placeholders = new Set<string>();\n  for (const [, key] of href.matchAll(/\\{([^}]+)\\}/g)) {\n    placeholders.add(key);\n  }\n  if (placeholders.size === 0) return href;\n\n  const missingKeys = new Set<string>();\n  let result = href;\n  for (const key of placeholders) {\n    if (key in values) {\n      result = result.replaceAll(`{${key}}`, values[key]!);\n    } else if (process.env.NODE_ENV !== \"production\") {\n      missingKeys.add(key);\n    }\n  }\n\n  if (missingKeys.size > 0 && process.env.NODE_ENV !== \"production\") {\n    console.warn(\n      `[app-sidebar] href \"${href}\" references placeholder${missingKeys.size === 1 ? \"\" : \"s\"} ` +\n        `{${Array.from(missingKeys).join(\"}, {\")}} not present in hrefTemplateValues. ` +\n        `Substitution skipped for missing keys.`,\n    );\n  }\n\n  return result;\n}\n",
      "type": "registry:component",
      "target": "components/app-sidebar/lib/href-resolver.ts"
    },
    {
      "path": "src/registry/components/navigation/app-sidebar/lib/keyboard-handler.ts",
      "content": "import type { KeyboardEvent } from \"react\";\nimport type { SidebarKeyboardEntry } from \"./flatten-entries\";\n\nexport interface SidebarKeyboardContext {\n  flat: ReadonlyArray<SidebarKeyboardEntry>;\n  focusedId: string | null;\n  setFocusedId: (id: string) => void;\n  toggleSection: (sectionId: string) => void;\n  isSectionCollapsed: (sectionId: string) => boolean;\n}\n\n/**\n * Arrow/Home/End/Enter/Esc dispatch over the flattened keyboard sequence.\n *\n * Behaviors (plan §17.2 + L37):\n *  - ArrowDown / ArrowUp — move focus across the sequence (wraps at edges).\n *  - Home / End — jump to first / last entry.\n *  - ArrowRight on a collapsed section header → expand.\n *  - ArrowLeft on an expanded section header → collapse.\n *  - Enter / Space — left to native button/link click on the focused row.\n *\n * The handler only updates the reducer's focusedItemId; the host effect in\n * `<AppSidebar>` programmatically focuses the matching DOM node on each\n * change so screen reader + keyboard user expectations line up.\n */\nexport function handleSidebarKeydown(\n  event: KeyboardEvent,\n  ctx: SidebarKeyboardContext,\n): void {\n  const { flat, focusedId, setFocusedId, toggleSection, isSectionCollapsed } = ctx;\n  if (flat.length === 0) return;\n\n  const currentIdx = focusedId ? flat.findIndex((e) => e.id === focusedId) : -1;\n\n  switch (event.key) {\n    case \"ArrowDown\": {\n      event.preventDefault();\n      const next =\n        currentIdx >= 0 && currentIdx + 1 < flat.length\n          ? flat[currentIdx + 1]\n          : flat[0];\n      setFocusedId(next.id);\n      return;\n    }\n    case \"ArrowUp\": {\n      event.preventDefault();\n      const prev =\n        currentIdx > 0 ? flat[currentIdx - 1] : flat[flat.length - 1];\n      setFocusedId(prev.id);\n      return;\n    }\n    case \"Home\": {\n      event.preventDefault();\n      setFocusedId(flat[0].id);\n      return;\n    }\n    case \"End\": {\n      event.preventDefault();\n      setFocusedId(flat[flat.length - 1].id);\n      return;\n    }\n    case \"ArrowRight\": {\n      if (currentIdx < 0) return;\n      const current = flat[currentIdx];\n      if (current.kind === \"section-header\" && isSectionCollapsed(current.id)) {\n        event.preventDefault();\n        toggleSection(current.id);\n      }\n      return;\n    }\n    case \"ArrowLeft\": {\n      if (currentIdx < 0) return;\n      const current = flat[currentIdx];\n      if (current.kind === \"section-header\" && !isSectionCollapsed(current.id)) {\n        event.preventDefault();\n        toggleSection(current.id);\n      }\n      return;\n    }\n  }\n}\n",
      "type": "registry:component",
      "target": "components/app-sidebar/lib/keyboard-handler.ts"
    },
    {
      "path": "src/registry/components/navigation/app-sidebar/lib/sidebar-reducer.ts",
      "content": "import type { AppSidebarMobileOpenReason } from \"../types\";\n\n// ─────────────────────────────────────────────────────────────────────────\n// Internal reducer state + actions\n// (Internal shape — NOT exported via index.ts. Consumers interact with the\n// component via the imperative handle + public AppSidebarStateValue.)\n// ─────────────────────────────────────────────────────────────────────────\n\nexport interface SidebarReducerState {\n  collapsed: boolean;\n  mobileOpen: boolean;\n  collapsedSectionIds: ReadonlySet<string>;\n  focusedItemId: string | null;\n  // L7 Defense-2 content-equality short-circuit anchor for EXTERNAL_SYNC.\n  // Updated only on EXTERNAL_SYNC; never on internal transitions.\n  lastSyncedSnapshot: {\n    collapsed: boolean;\n    mobileOpen: boolean;\n  };\n  // v0.3.0 (C2, L53) — reason of the most-recent mobile-drawer transition.\n  // Drained by Defense-1 microtask effect for the onMobileOpenChange callback.\n  // OVERWRITES on each transition (no explicit clear). EXTERNAL_SYNC resets to\n  // \"imperative\" on transition-causing syncs to prevent stale carry-over.\n  lastMobileOpenReason: AppSidebarMobileOpenReason | null;\n}\n\nexport type SidebarReducerAction =\n  | { type: \"SET_COLLAPSED\"; collapsed: boolean }\n  | { type: \"TOGGLE_COLLAPSED\" }\n  | { type: \"SET_MOBILE_OPEN\"; open: boolean; reason: AppSidebarMobileOpenReason }\n  // v0.3.0 (C2, L54): TOGGLE_MOBILE gains optional `reason?` so the reducer\n  // can read fresh state.mobileOpen (handles rapid same-tick double-clicks)\n  // while still propagating the discriminator. Default is \"imperative\".\n  | { type: \"TOGGLE_MOBILE\"; reason?: AppSidebarMobileOpenReason }\n  | { type: \"SET_SECTION_COLLAPSED\"; sectionId: string; collapsed: boolean }\n  | { type: \"TOGGLE_SECTION\"; sectionId: string }\n  // v0.3.0 (C5, F8): drop unused allSectionIds payload (reducer always cleared\n  // the set regardless of payload).\n  | { type: \"EXPAND_ALL_SECTIONS\" }\n  | { type: \"COLLAPSE_ALL_SECTIONS\"; allSectionIds: ReadonlyArray<string> }\n  | { type: \"FOCUS_ITEM\"; itemId: string | null }\n  | { type: \"EXTERNAL_SYNC\"; collapsed: boolean; mobileOpen: boolean }\n  | { type: \"REPLACE_STATE\"; state: SidebarReducerState };\n\nexport interface SidebarReducerInitOptions {\n  defaultCollapsed?: boolean;\n  defaultMobileOpen?: boolean;\n  defaultCollapsedSectionIds?: ReadonlyArray<string>;\n}\n\nexport function createInitialState(\n  options: SidebarReducerInitOptions = {},\n): SidebarReducerState {\n  const collapsed = options.defaultCollapsed ?? false;\n  const mobileOpen = options.defaultMobileOpen ?? false;\n  return {\n    collapsed,\n    mobileOpen,\n    collapsedSectionIds: new Set(options.defaultCollapsedSectionIds ?? []),\n    focusedItemId: null,\n    lastSyncedSnapshot: { collapsed, mobileOpen },\n    lastMobileOpenReason: null,\n  };\n}\n\nexport function sidebarReducer(\n  state: SidebarReducerState,\n  action: SidebarReducerAction,\n): SidebarReducerState {\n  switch (action.type) {\n    case \"SET_COLLAPSED\":\n      if (state.collapsed === action.collapsed) return state;\n      return { ...state, collapsed: action.collapsed };\n\n    case \"TOGGLE_COLLAPSED\":\n      return { ...state, collapsed: !state.collapsed };\n\n    case \"SET_MOBILE_OPEN\":\n      // No-op guard preserves prior lastMobileOpenReason on duplicate dispatch\n      // (load-bearing for the L53 \"no double-fire on re-entry\" guarantee).\n      if (state.mobileOpen === action.open) return state;\n      return {\n        ...state,\n        mobileOpen: action.open,\n        lastMobileOpenReason: action.reason,\n      };\n\n    case \"TOGGLE_MOBILE\":\n      // Reducer reads FRESH state.mobileOpen — translating to SET at the\n      // handle would use a stale closure value and the no-op guard would\n      // drop rapid same-tick double-clicks (R21).\n      return {\n        ...state,\n        mobileOpen: !state.mobileOpen,\n        lastMobileOpenReason: action.reason ?? \"imperative\",\n      };\n\n    case \"SET_SECTION_COLLAPSED\": {\n      const has = state.collapsedSectionIds.has(action.sectionId);\n      if (action.collapsed && has) return state;\n      if (!action.collapsed && !has) return state;\n      const next = new Set(state.collapsedSectionIds);\n      if (action.collapsed) next.add(action.sectionId);\n      else next.delete(action.sectionId);\n      return { ...state, collapsedSectionIds: next };\n    }\n\n    case \"TOGGLE_SECTION\": {\n      const next = new Set(state.collapsedSectionIds);\n      if (next.has(action.sectionId)) next.delete(action.sectionId);\n      else next.add(action.sectionId);\n      return { ...state, collapsedSectionIds: next };\n    }\n\n    case \"EXPAND_ALL_SECTIONS\":\n      // v0.3.0 (C5, F8): action no longer carries allSectionIds — the field\n      // was always ignored here (we just clear the set unconditionally).\n      if (state.collapsedSectionIds.size === 0) return state;\n      return { ...state, collapsedSectionIds: new Set() };\n\n    case \"COLLAPSE_ALL_SECTIONS\": {\n      const next = new Set(action.allSectionIds);\n      // No-op if identical (rare)\n      if (\n        next.size === state.collapsedSectionIds.size &&\n        [...next].every((id) => state.collapsedSectionIds.has(id))\n      ) {\n        return state;\n      }\n      return { ...state, collapsedSectionIds: next };\n    }\n\n    case \"FOCUS_ITEM\":\n      if (state.focusedItemId === action.itemId) return state;\n      return { ...state, focusedItemId: action.itemId };\n\n    case \"EXTERNAL_SYNC\": {\n      // L7 Defense 2: content-equality short-circuit\n      if (\n        state.lastSyncedSnapshot.collapsed === action.collapsed &&\n        state.lastSyncedSnapshot.mobileOpen === action.mobileOpen\n      ) {\n        return state;\n      }\n      // v0.3.0 (C2, R23): when mobileOpen actually changes via EXTERNAL_SYNC\n      // (controlled-prop change from consumer), reset lastMobileOpenReason\n      // to \"imperative\" — otherwise a stale \"item-click\" / \"escape\" /\n      // \"outside-click\" from a prior in-app transition would leak into the\n      // callback for this prop-driven transition. When EXTERNAL_SYNC only\n      // changes `collapsed`, preserve the mobile reason untouched.\n      const mobileChanged = state.mobileOpen !== action.mobileOpen;\n      return {\n        ...state,\n        collapsed: action.collapsed,\n        mobileOpen: action.mobileOpen,\n        lastSyncedSnapshot: {\n          collapsed: action.collapsed,\n          mobileOpen: action.mobileOpen,\n        },\n        lastMobileOpenReason: mobileChanged\n          ? \"imperative\"\n          : state.lastMobileOpenReason,\n      };\n    }\n\n    case \"REPLACE_STATE\":\n      return action.state;\n\n    default: {\n      // Exhaustive check — TypeScript flags unhandled action types at compile time\n      const _exhaustive: never = action;\n      void _exhaustive;\n      return state;\n    }\n  }\n}\n",
      "type": "registry:component",
      "target": "components/app-sidebar/lib/sidebar-reducer.ts"
    },
    {
      "path": "src/registry/components/navigation/app-sidebar/lib/storage-schema.ts",
      "content": "/**\n * Versioned schema for localStorage persistence (L23).\n *\n * Bump STORAGE_SCHEMA_VERSION when the shape changes; the type-guard\n * silently drops mismatched payloads so older data doesn't corrupt the\n * runtime state.\n */\nexport const STORAGE_SCHEMA_VERSION = 1;\n\nexport interface StoredState {\n  v: 1;\n  collapsed: boolean;\n  collapsedSectionIds: string[];\n}\n\nexport function isStoredState(value: unknown): value is StoredState {\n  if (typeof value !== \"object\" || value === null) return false;\n  const v = value as Partial<StoredState>;\n  return (\n    v.v === STORAGE_SCHEMA_VERSION &&\n    typeof v.collapsed === \"boolean\" &&\n    Array.isArray(v.collapsedSectionIds) &&\n    v.collapsedSectionIds.every((id) => typeof id === \"string\")\n  );\n}\n",
      "type": "registry:component",
      "target": "components/app-sidebar/lib/storage-schema.ts"
    },
    {
      "path": "src/registry/components/navigation/app-sidebar/parts/default-link.tsx",
      "content": "import { forwardRef } from \"react\";\nimport type { NavLinkProps } from \"../types\";\n\n/**\n * Default linkComponent — vanilla `<a href>` wrapper.\n *\n * Consumers using Next.js / React Router / TanStack Router pass their own\n * linkComponent. See usage.tsx for one-liner adapters.\n */\nexport const DefaultLink = forwardRef<HTMLAnchorElement, NavLinkProps>(\n  function DefaultLink({ href, children, ...rest }, ref) {\n    return (\n      <a ref={ref} href={href} {...rest}>\n        {children}\n      </a>\n    );\n  },\n);\n",
      "type": "registry:component",
      "target": "components/app-sidebar/parts/default-link.tsx"
    },
    {
      "path": "src/registry/components/navigation/app-sidebar/parts/icon.tsx",
      "content": "import { isValidElement, type ComponentType, type ReactNode } from \"react\";\nimport { cn } from \"@/lib/utils\";\n\ninterface IconProps {\n  icon: ReactNode | ComponentType<{ className?: string }> | undefined;\n  className?: string;\n}\n\n/**\n * Renders either:\n *  - a React component (e.g., lucide-react `Home`) — invoked with className\n *  - a ReactNode (JSX element, string emoji, image, custom mark) — rendered as-is\n *  - undefined — renders nothing\n *\n * Convention: icon components receive a className with sizing (`h-5 w-5` or\n * similar). ReactNode icons are expected to size themselves.\n */\nexport function Icon({ icon, className }: IconProps) {\n  if (icon === undefined || icon === null) return null;\n\n  if (isValidElement(icon)) {\n    return <>{icon}</>;\n  }\n\n  // Plain function components AND forwardRef objects (lucide-react icons in\n  // v0.475+ ship as `forwardRef` objects whose typeof === \"object\", not\n  // \"function\" — the previous typeof-only check rendered the object as a\n  // child and crashed the static-prerender path).\n  const isComponentObject =\n    typeof icon === \"object\" && icon !== null && \"$$typeof\" in icon;\n  if (typeof icon === \"function\" || isComponentObject) {\n    const IconComponent = icon as ComponentType<{ className?: string }>;\n    return <IconComponent className={cn(\"h-5 w-5 shrink-0\", className)} />;\n  }\n\n  // String / number / fragment — render as-is\n  return <>{icon}</>;\n}\n",
      "type": "registry:component",
      "target": "components/app-sidebar/parts/icon.tsx"
    },
    {
      "path": "src/registry/components/navigation/app-sidebar/parts/nav-badge.tsx",
      "content": "\"use client\";\n\nimport { isValidElement, type ReactNode } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { useAppSidebarContextOrNull } from \"../contexts/sidebar-nav-context\";\nimport { formatBadgeValue } from \"../lib/badge-format\";\nimport type { NavBadgeConfig } from \"../types\";\n\n/**\n * Shared NavBadge — exported via index.ts AND imported by\n * `bottom-tab-bar-01` via relative path `../app-sidebar/parts/nav-badge`\n * per F-S1 cross-procomp lock.\n *\n * Position resolution (L33):\n *   1. Explicit `position` prop wins\n *   2. Otherwise: context auto-resolve — \"corner\" when sidebar collapsed,\n *      \"inline-end\" when expanded\n *   3. Default \"inline-end\" if no context (L46 — standalone use, e.g.,\n *      inside bottom-tab-bar-01 which doesn't have SidebarNav context)\n *\n * Zero-value skip per L33: returns null when value === 0 && !showZero.\n */\nexport function NavBadge({\n  value,\n  max = 9,\n  variant = \"number\",\n  tone = \"destructive\",\n  position,\n  showZero = false,\n  className,\n}: NavBadgeConfig & { className?: string }) {\n  const ctx = useAppSidebarContextOrNull();\n  const resolvedPosition =\n    position ?? (ctx?.state.collapsed ? \"corner\" : \"inline-end\");\n\n  // Skip-render for zero (L33)\n  if (typeof value === \"number\" && value === 0 && !showZero) return null;\n\n  // Tone → token mapping\n  const toneClasses: Record<NonNullable<NavBadgeConfig[\"tone\"]>, string> = {\n    default: \"bg-muted text-muted-foreground\",\n    accent: \"bg-accent text-accent-foreground\",\n    destructive: \"bg-destructive text-destructive-foreground\",\n    muted: \"bg-muted/60 text-muted-foreground\",\n  };\n\n  // Position → layout classes. RTL flip: in collapsed-mode the badge corner\n  // anchors on the icon's inline-end edge — physically -right-2 in LTR and\n  // -left-2 in RTL. The deeper offset (-8px instead of -4px) lets the badge\n  // sit on the icon's outer corner with only a slight overlap, so the\n  // underlying icon glyph stays readable instead of being masked.\n  const positionClasses =\n    resolvedPosition === \"corner\"\n      ? \"absolute -top-2 -right-2 rtl:right-auto rtl:-left-2\"\n      : \"relative inline-flex\";\n\n  if (variant === \"dot\") {\n    return (\n      <span\n        aria-hidden={typeof value !== \"string\" && !isValidElement(value)}\n        className={cn(\n          positionClasses,\n          \"h-2 w-2 rounded-full\",\n          toneClasses[tone],\n          className,\n        )}\n      />\n    );\n  }\n\n  if (variant === \"pulse\") {\n    return (\n      <span\n        className={cn(positionClasses, \"h-2 w-2\", className)}\n        aria-hidden=\"true\"\n      >\n        <span\n          className={cn(\n            \"absolute inset-0 rounded-full motion-safe:animate-ping\",\n            toneClasses[tone],\n            \"opacity-75\",\n          )}\n        />\n        <span\n          className={cn(\n            \"relative inline-flex h-2 w-2 rounded-full\",\n            toneClasses[tone],\n          )}\n        />\n      </span>\n    );\n  }\n\n  // variant === \"number\"\n  const display: ReactNode =\n    typeof value === \"number\" || typeof value === \"string\"\n      ? formatBadgeValue(value, max)\n      : value;\n\n  // Corner-position badges run smaller + tighter than inline-end ones so they\n  // don't mask the icon glyph they're decorating. Inline-end keeps the\n  // theme-token sizing for visual parity with shadcn Badge.\n  const sizeClasses =\n    resolvedPosition === \"corner\"\n      ? \"min-w-4 h-4 px-1 text-[9px] ring-2 ring-card\"\n      : \"min-w-(--ilinxa-nav-badge-size) h-(--ilinxa-nav-badge-size) px-1.5 text-[10px]\";\n\n  return (\n    <span\n      className={cn(\n        positionClasses,\n        \"inline-flex items-center justify-center\",\n        sizeClasses,\n        \"font-semibold leading-none rounded-full\",\n        toneClasses[tone],\n        className,\n      )}\n    >\n      {display}\n    </span>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/app-sidebar/parts/nav-badge.tsx"
    },
    {
      "path": "src/registry/components/navigation/app-sidebar/parts/nav-brand.tsx",
      "content": "\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport { useAppSidebarContextOrNull } from \"../contexts/sidebar-nav-context\";\nimport type { NavBrandConfig } from \"../types\";\nimport { DefaultLink } from \"./default-link\";\n\n/**\n * Default brand zone (logo + label + optional href).\n *\n * Collapse-aware: when ancestor sidebar is collapsed (read via context),\n * the label hides and only the logo renders.\n *\n * Use via:\n *   <AppSidebar brand={{ logo: <Logo/>, label: \"Acme\", href: \"/\" }} />\n * Or directly inside brandSlot:\n *   <AppSidebar brandSlot={<NavBrand label=\"Acme\" />} />\n */\nexport function NavBrand({\n  logo,\n  label,\n  href,\n  linkComponent,\n  className,\n}: NavBrandConfig & { className?: string }) {\n  const ctx = useAppSidebarContextOrNull();\n  const isCollapsed = ctx?.state.collapsed ?? false;\n\n  const renderLogo = () => {\n    if (!logo) return null;\n    if (typeof logo === \"object\" && logo !== null && \"src\" in logo) {\n      return (\n        <img\n          src={logo.src}\n          alt={logo.alt ?? label}\n          className=\"h-8 w-8 rounded-md object-contain\"\n        />\n      );\n    }\n    return <span className=\"flex h-8 w-8 items-center justify-center\">{logo}</span>;\n  };\n\n  const content = (\n    <span className={cn(\"flex items-center gap-2 min-w-0\", className)}>\n      {renderLogo()}\n      {!isCollapsed && (\n        <span className=\"truncate text-base font-semibold text-foreground\">\n          {label}\n        </span>\n      )}\n    </span>\n  );\n\n  if (!href) {\n    return content;\n  }\n\n  const LinkComponent = linkComponent ?? DefaultLink;\n  return (\n    <LinkComponent\n      href={href}\n      className=\"-m-1 rounded-md p-1 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n      aria-label={label}\n    >\n      {content}\n    </LinkComponent>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/app-sidebar/parts/nav-brand.tsx"
    },
    {
      "path": "src/registry/components/navigation/app-sidebar/parts/nav-primary-action.tsx",
      "content": "\"use client\";\n\nimport { Button, buttonVariants } from \"@/components/ui/button\";\nimport { cn } from \"@/lib/utils\";\nimport { useAppSidebarContextOrNull } from \"../contexts/sidebar-nav-context\";\nimport type { NavPrimaryActionConfig } from \"../types\";\nimport { DefaultLink } from \"./default-link\";\nimport { Icon } from \"./icon\";\nimport { TooltipWrapper } from \"./tooltip-wrapper\";\n\n/**\n * Default primary-action button (e.g., \"Create post\" / \"New project\").\n *\n * Collapses to icon-only when ancestor sidebar collapses; label hidden +\n * tooltip exposed so the action stays accessible and the icon stays\n * centered.\n *\n * Use via:\n *   <AppSidebar primaryAction={{\n *     icon: PlusSquare,\n *     label: \"Share\",\n *     onClick: () => openComposer(),\n *   }} />\n * Or directly inside primaryActionSlot:\n *   <AppSidebar primaryActionSlot={<NavPrimaryAction ... />} />\n */\nexport function NavPrimaryAction({\n  icon,\n  label,\n  onClick,\n  href,\n  linkComponent,\n  variant = \"default\",\n  tone = \"default\",\n  className,\n}: NavPrimaryActionConfig & { className?: string }) {\n  const ctx = useAppSidebarContextOrNull();\n  const isCollapsed = ctx?.state.collapsed ?? false;\n\n  const innerContent = (\n    <>\n      <Icon icon={icon} className={cn(\"h-4 w-4 shrink-0\", isCollapsed && \"\")} />\n      {!isCollapsed && <span className=\"truncate\">{label}</span>}\n    </>\n  );\n\n  const buttonClasses = cn(\n    \"w-full gap-2\",\n    isCollapsed && \"px-0\",\n    tone === \"accent\" && \"bg-(--ilinxa-nav-active-bg) text-(--ilinxa-nav-active-fg) hover:bg-(--ilinxa-nav-active-bg)/90\",\n    tone === \"destructive\" && \"bg-destructive text-destructive-foreground hover:bg-destructive/90\",\n    className,\n  );\n\n  // href present → render as <a> via linkComponent; otherwise <button>.\n  // v0.3.2 (F-cross-13 path-b): no `<Button asChild>` — Base UI's Button has\n  // no asChild (and the CLI's render-rewrite breaks mixed consumers). The\n  // link IS the button: buttonVariants classes applied to the anchor directly.\n  const buttonEl = href ? (\n    (() => {\n      const LinkComponent = linkComponent ?? DefaultLink;\n      return (\n        <LinkComponent\n          href={href}\n          onClick={onClick}\n          aria-label={isCollapsed ? label : undefined}\n          className={cn(buttonVariants({ variant }), buttonClasses)}\n        >\n          {innerContent}\n        </LinkComponent>\n      );\n    })()\n  ) : (\n    <Button\n      type=\"button\"\n      variant={variant}\n      onClick={onClick}\n      aria-label={isCollapsed ? label : undefined}\n      className={buttonClasses}\n    >\n      {innerContent}\n    </Button>\n  );\n\n  return (\n    <TooltipWrapper\n      content={<span className=\"font-medium\">{label}</span>}\n      side=\"right\"\n      disabled={!isCollapsed}\n    >\n      {buttonEl}\n    </TooltipWrapper>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/app-sidebar/parts/nav-primary-action.tsx"
    },
    {
      "path": "src/registry/components/navigation/app-sidebar/parts/nav-user.tsx",
      "content": "\"use client\";\n\nimport { useRef, useState } from \"react\";\nimport { Avatar, AvatarFallback, AvatarImage } from \"@/components/ui/avatar\";\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuSeparator,\n  DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport { cn } from \"@/lib/utils\";\nimport { useAppSidebarContextOrNull } from \"../contexts/sidebar-nav-context\";\nimport { deriveAvatarFallback } from \"../lib/derive-avatar-fallback\";\nimport type {\n  NavUserConfig,\n  NavUserMenuItem,\n  NavUserMenuItemSelectEvent,\n} from \"../types\";\nimport { DefaultLink } from \"./default-link\";\nimport { Icon } from \"./icon\";\n\nconst STATUS_DOT_CLASSES: Record<string, string> = {\n  online: \"bg-emerald-500\",\n  offline: \"bg-zinc-400\",\n  busy: \"bg-red-500\",\n  away: \"bg-amber-500\",\n};\n\n/**\n * User footer (avatar + identity + dropdown menu).\n *\n * Collapse-aware: identity text hidden when sidebar collapsed; only the\n * avatar shows. Dropdown align flips center↔end based on collapsed state.\n *\n * F-cross-13 defensive (R7 carrier #3 — DropdownMenu):\n *  - onOpenChange runtime-checks for boolean (Radix passes boolean;\n *    Base UI variants may pass undefined or different shape)\n *  - DropdownMenuItem.onSelect callbacks shaped to accept either Event\n *    (Radix) or undefined (Base UI fallback) — runtime-narrowed\n *  - v0.3.2 (path-b): zero `asChild` — Base UI primitives reject it. The\n *    DropdownMenuTrigger IS the footer button (native props only) and\n *    href menu rows nest the anchor INSIDE the item (see\n *    NavUserLinkMenuItem below).\n *\n * menuItems is a discriminated union (L15 + L22-b):\n *   { kind: \"item\"; ... } — clickable menu row\n *   { kind: \"separator\" } — divider\n */\nexport function NavUser({\n  user,\n  menuItems,\n  onTriggerOpen,\n  className,\n}: NavUserConfig & { className?: string }) {\n  const ctx = useAppSidebarContextOrNull();\n  const isCollapsed = ctx?.state.collapsed ?? false;\n  const [open, setOpen] = useState(false);\n\n  const initials = deriveAvatarFallback(user.name);\n  const statusDot = user.status && user.status !== \"invisible\"\n    ? STATUS_DOT_CLASSES[user.status]\n    : null;\n\n  // v0.3.2 (F-cross-13 path-b): no `asChild` — Base UI's trigger rejects it.\n  // The DropdownMenuTrigger IS the footer button: both backends render a\n  // native <button> and pass native DOM props straight through.\n  const trigger = (\n    <DropdownMenuTrigger\n      type=\"button\"\n      className={cn(\n        \"flex w-full items-center gap-3 rounded-md p-1.5\",\n        \"hover:bg-muted\",\n        \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card\",\n        isCollapsed && \"justify-center\",\n        className,\n      )}\n      aria-label={isCollapsed ? `${user.name} — open menu` : undefined}\n    >\n      <span className=\"relative inline-flex shrink-0\">\n        <Avatar className=\"h-9 w-9\">\n          {user.avatarUrl && (\n            <AvatarImage src={user.avatarUrl} alt={user.name} />\n          )}\n          <AvatarFallback>{user.avatarFallback ?? initials}</AvatarFallback>\n        </Avatar>\n        {statusDot && (\n          <span\n            aria-hidden=\"true\"\n            className={cn(\n              \"absolute -bottom-0.5 -right-0.5 inline-flex h-2.5 w-2.5 rounded-full ring-2 ring-card\",\n              // RTL flip — status dot stays on the avatar's inline-end side\n              \"rtl:right-auto rtl:-left-0.5\",\n              statusDot,\n            )}\n          />\n        )}\n      </span>\n      {!isCollapsed && (\n        <span className=\"flex min-w-0 flex-1 flex-col text-left\">\n          <span className=\"truncate text-sm font-medium text-foreground\">\n            {user.name}\n          </span>\n          {user.handle && (\n            <span className=\"truncate text-xs text-muted-foreground\">\n              {user.handle}\n            </span>\n          )}\n        </span>\n      )}\n    </DropdownMenuTrigger>\n  );\n\n  return (\n    <DropdownMenu\n      open={open}\n      // F-cross-13: runtime-check (Radix → boolean; Base UI → possible undefined)\n      onOpenChange={(next: boolean | undefined) => {\n        if (typeof next === \"boolean\") {\n          setOpen(next);\n          onTriggerOpen?.({ open: next });\n        }\n      }}\n    >\n      {trigger}\n      <DropdownMenuContent\n        align={isCollapsed ? \"center\" : \"end\"}\n        side=\"top\"\n        className=\"w-56\"\n      >\n        {menuItems.map((entry, i) => {\n          if (entry.kind === \"separator\") {\n            return <DropdownMenuSeparator key={`sep-${i}`} />;\n          }\n          const item = entry as NavUserMenuItem;\n          if (item.href) {\n            return (\n              <NavUserLinkMenuItem\n                key={item.label + i}\n                item={item}\n                href={item.href}\n              />\n            );\n          }\n          return (\n            <DropdownMenuItem\n              key={item.label + i}\n              disabled={item.disabled}\n              // v0.3.2 (F-cross-13): param is `unknown` — Radix types onSelect's\n              // arg as Event, Base UI as BaseUIEvent<SyntheticEvent>; naming\n              // either breaks the other backend's contravariance check. The\n              // runtime contract (v0.3.0 C4/F10 widening) is unchanged;\n              // consumers still narrow with `instanceof MouseEvent`.\n              onSelect={(eventArg: unknown) => {\n                item.onClick?.(eventArg as NavUserMenuItemSelectEvent);\n              }}\n              className={cn(\n                \"gap-2\",\n                item.variant === \"destructive\" && \"text-destructive focus:text-destructive\",\n              )}\n            >\n              <Icon icon={item.icon} className=\"h-4 w-4\" />\n              <span className=\"flex-1\">{item.label}</span>\n              {item.shortcut && (\n                <span className=\"ml-auto font-mono text-xs text-muted-foreground\">\n                  {item.shortcut}\n                </span>\n              )}\n            </DropdownMenuItem>\n          );\n        })}\n      </DropdownMenuContent>\n    </DropdownMenu>\n  );\n}\n\n/**\n * v0.3.2 (F-cross-13 path-b): link-flavored menu row. Previously\n * `<DropdownMenuItem asChild>` made the anchor the [role=menuitem] itself —\n * Base UI's DropdownMenuItem has no `asChild`, so the item stays the\n * menuitem host and the anchor renders INSIDE it, filling the whole row\n * (the item's padding moves onto the anchor via `p-0`). An anchor inside a\n * div[role=menuitem] is valid HTML and keeps href semantics — middle-click,\n * ctrl-click, copy-link — working in both backends. Keyboard activation\n * dispatches the synthetic click on the ITEM, not the anchor, so onSelect\n * forwards it via anchor.click() exactly once:\n *   - nativeClickRef — the anchor's own onClick marks pointer activations\n *     (navigation already happened natively; don't forward)\n *   - forwardingRef — re-entrancy guard: the forwarded click bubbles back\n *     into the item's select pipeline and would double-fire item.onClick\n * The anchor is looked up via querySelector so custom linkComponents work\n * whether or not they forward refs.\n */\nfunction NavUserLinkMenuItem({\n  item,\n  href,\n}: {\n  item: NavUserMenuItem;\n  href: string;\n}) {\n  const itemRef = useRef<HTMLDivElement | null>(null);\n  const nativeClickRef = useRef(false);\n  const forwardingRef = useRef(false);\n  const LinkComponent = item.linkComponent ?? DefaultLink;\n  return (\n    <DropdownMenuItem\n      ref={itemRef}\n      disabled={item.disabled}\n      // F-cross-13: `unknown` param — see the plain-item handler above.\n      onSelect={(eventArg: unknown) => {\n        if (forwardingRef.current) return;\n        item.onClick?.(eventArg as NavUserMenuItemSelectEvent);\n        if (!nativeClickRef.current) {\n          // Fall back to the row's root element for linkComponents that\n          // render no <a> (their own onClick navigation still runs).\n          const anchor =\n            itemRef.current?.querySelector(\"a\") ??\n            (itemRef.current?.firstElementChild instanceof HTMLElement\n              ? itemRef.current.firstElementChild\n              : null);\n          if (anchor) {\n            forwardingRef.current = true;\n            try {\n              anchor.click();\n            } finally {\n              forwardingRef.current = false;\n            }\n          }\n        }\n        nativeClickRef.current = false;\n      }}\n      className={cn(\n        // p-0 — padding moves onto the anchor so the whole row is the link\n        \"p-0\",\n        item.variant === \"destructive\" && \"text-destructive focus:text-destructive\",\n      )}\n    >\n      <LinkComponent\n        href={href}\n        // the menuitem is the focus stop; keep the anchor out of the tab order\n        tabIndex={-1}\n        onClick={() => {\n          nativeClickRef.current = true;\n        }}\n        className=\"flex w-full items-center gap-2 rounded-md px-1.5 py-1\"\n      >\n        <Icon icon={item.icon} className=\"h-4 w-4\" />\n        <span className=\"flex-1\">{item.label}</span>\n        {item.shortcut && (\n          <span className=\"ml-auto font-mono text-xs text-muted-foreground\">\n            {item.shortcut}\n          </span>\n        )}\n      </LinkComponent>\n    </DropdownMenuItem>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/app-sidebar/parts/nav-user.tsx"
    },
    {
      "path": "src/registry/components/navigation/app-sidebar/parts/sidebar-empty-state.tsx",
      "content": "\"use client\";\n\nimport { Inbox, ShieldOff, EyeOff } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\nimport type { AppSidebarEmptyReason } from \"../types\";\n\ninterface SidebarEmptyStateProps {\n  reason: AppSidebarEmptyReason;\n  className?: string;\n}\n\nconst REASON_COPY: Record<\n  AppSidebarEmptyReason,\n  { icon: React.ComponentType<{ className?: string }>; title: string; body: string }\n> = {\n  \"no-items\": {\n    icon: Inbox,\n    title: \"No items\",\n    body: \"Configure `items` prop to populate the navigation.\",\n  },\n  \"all-filtered-by-permission\": {\n    icon: ShieldOff,\n    title: \"Nothing visible\",\n    body: \"All items filtered by current permissions.\",\n  },\n  \"all-hidden\": {\n    icon: EyeOff,\n    title: \"All items hidden\",\n    body: \"Every item has `hidden: true`.\",\n  },\n  \"all-filtered-by-loading\": {\n    icon: Inbox,\n    title: \"No items\",\n    body: \"Loading completed with no items.\",\n  },\n};\n\n/**\n * Default empty-state renderer — per L48 reason branching.\n *\n * Override via `renderEmptyState` slot (L13 priority).\n * Each reason gets a distinct icon + title + body for diagnostic clarity.\n */\nexport function SidebarEmptyState({ reason, className }: SidebarEmptyStateProps) {\n  const { icon: IconComp, title, body } = REASON_COPY[reason];\n  return (\n    <div\n      role=\"status\"\n      className={cn(\n        \"flex flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-border p-6 text-center\",\n        className,\n      )}\n    >\n      <IconComp className=\"h-6 w-6 text-muted-foreground\" aria-hidden=\"true\" />\n      <p className=\"text-sm font-medium text-foreground\">{title}</p>\n      <p className=\"text-xs text-muted-foreground\">{body}</p>\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/app-sidebar/parts/sidebar-empty-state.tsx"
    },
    {
      "path": "src/registry/components/navigation/app-sidebar/parts/sidebar-loading-skeleton.tsx",
      "content": "\"use client\";\n\nimport { cn } from \"@/lib/utils\";\n\ninterface SidebarLoadingSkeletonProps {\n  isCollapsed: boolean;\n  rowCount?: number;\n  className?: string;\n}\n\n/**\n * Default loading skeleton — 6 shimmer rows by default.\n *\n * Width matches sidebar's collapsed/expanded mode automatically (inherits\n * the parent <nav>'s width). Shimmer animation gated motion-safe;\n * reduced-motion users see static muted rows.\n *\n * Override entirely via the `renderLoading` slot (L13 priority).\n */\nexport function SidebarLoadingSkeleton({\n  isCollapsed,\n  rowCount = 6,\n  className,\n}: SidebarLoadingSkeletonProps) {\n  return (\n    <ul\n      role=\"list\"\n      aria-busy=\"true\"\n      aria-label=\"Loading navigation items\"\n      className={cn(\"flex flex-col gap-1\", className)}\n    >\n      {Array.from({ length: rowCount }, (_, i) => (\n        <li key={i} className=\"list-none\">\n          <div\n            className={cn(\n              \"flex items-center gap-3 rounded-lg px-3 py-2.5\",\n              \"motion-safe:animate-pulse\",\n            )}\n            aria-hidden=\"true\"\n          >\n            <span className=\"h-5 w-5 shrink-0 rounded-md bg-muted\" />\n            {!isCollapsed && (\n              <span\n                className=\"h-4 rounded bg-muted\"\n                style={{\n                  // Vary the width slightly so the skeleton looks real\n                  width: `${60 + ((i * 7) % 30)}%`,\n                }}\n              />\n            )}\n          </div>\n        </li>\n      ))}\n    </ul>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/app-sidebar/parts/sidebar-loading-skeleton.tsx"
    },
    {
      "path": "src/registry/components/navigation/app-sidebar/parts/sidebar-nav-list.tsx",
      "content": "\"use client\";\n\nimport { Fragment } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport type {\n  NavEntry,\n  NavItem,\n  NavLinkComponent,\n  AppSidebarEventArgs,\n  AppSidebarMobileOpenReason,\n  AppSidebarProps,\n} from \"../types\";\nimport { SidebarNavRow } from \"./sidebar-nav-row\";\nimport { SidebarNavSection } from \"./sidebar-nav-section\";\nimport { SidebarNavSeparator } from \"./sidebar-nav-separator\";\n\ninterface SidebarNavListProps {\n  entries: ReadonlyArray<NavEntry>;\n  activeItemId: string | null;\n  focusedItemId: string | null;\n  /**\n   * Stable id of the first focusable item/section header in the rendered\n   * traversal — used as the roving tabindex anchor when nothing is focused.\n   * Null if no row is focusable (loading / empty).\n   */\n  keyboardEntryId: string | null;\n  isCollapsed: boolean;\n  linkComponent: NavLinkComponent;\n  activeVariant?: AppSidebarProps[\"activeVariant\"];\n  autoCloseMobileOnNavigate: boolean;\n  isMobileOpen: boolean;\n  // v0.3.0 (C2, L54): accepts reason for the discriminator. Click handler\n  // passes \"item-click\"; parent forwards to finalHandle.closeMobile(reason).\n  onCloseMobile: (reason: AppSidebarMobileOpenReason) => void;\n\n  // Section state — driven by reducer in parent\n  collapsedSectionIds: ReadonlySet<string>;\n  onToggleSection: (sectionId: string) => void;\n\n  // Consumer event hooks\n  onItemClick?: (args: AppSidebarEventArgs[\"itemClick\"]) => void;\n  onItemNavigate?: (args: AppSidebarEventArgs[\"itemNavigate\"]) => void;\n  onSectionToggle?: (args: AppSidebarEventArgs[\"sectionToggle\"]) => void;\n\n  // Slot priority\n  renderItem?: AppSidebarProps[\"renderItem\"];\n  renderSection?: AppSidebarProps[\"renderSection\"];\n  renderBadge?: AppSidebarProps[\"renderBadge\"];\n  renderTooltipContent?: AppSidebarProps[\"renderTooltipContent\"];\n\n  // v0.2.0 — href resolution (L42 + L43)\n  hrefTemplateValues?: AppSidebarProps[\"hrefTemplateValues\"];\n  resolveHref?: AppSidebarProps[\"resolveHref\"];\n}\n\n/**\n * Renders the NavEntry[] list — flat NavItems, NavSections, and NavSeparators.\n *\n * C3 surface: flat items + separators. Sections render their items in a\n * single ungrouped list (full section UI with title + collapsible header\n * lands C4 in parts/sidebar-nav-section.tsx).\n *\n * Click sequence per L28:\n *   1. consumer's item.onClick fires sync\n *   2. onItemClick (component-level) fires sync\n *   3. if event.defaultPrevented → stop\n *   4. queueMicrotask → onItemNavigate + autoCloseMobileOnNavigate\n *\n * Disabled items short-circuit at step 1 (L27).\n */\nexport function SidebarNavList({\n  entries,\n  activeItemId,\n  focusedItemId,\n  keyboardEntryId,\n  isCollapsed,\n  linkComponent,\n  activeVariant,\n  autoCloseMobileOnNavigate,\n  isMobileOpen,\n  onCloseMobile,\n  collapsedSectionIds,\n  onToggleSection,\n  onItemClick,\n  onItemNavigate,\n  onSectionToggle,\n  renderItem,\n  renderSection,\n  renderBadge,\n  renderTooltipContent,\n  hrefTemplateValues,\n  resolveHref,\n}: SidebarNavListProps) {\n  // Roving tabindex (L37). The \"entry point\" rule:\n  //   - Some row has the user's focus      → only that row is tabbable.\n  //   - Nothing focused yet                 → the keyboard entry row is tabbable.\n  //   - Disabled items always pass to -1.\n  const resolveRovingTabIndex = (itemId: string): 0 | -1 => {\n    if (focusedItemId) return focusedItemId === itemId ? 0 : -1;\n    return keyboardEntryId === itemId ? 0 : -1;\n  };\n  const handleItemClick =\n    (item: NavItem, sectionId: string | null, indexInSection: number) =>\n    (event: React.MouseEvent) => {\n      // L27 — disabled items short-circuit at step 1\n      if (item.disabled) {\n        event.preventDefault();\n        return;\n      }\n      // Step 1 — consumer's per-item onClick\n      item.onClick?.(event);\n      // Step 2 — component-level onItemClick\n      onItemClick?.({ item, isActive: activeItemId === item.id, event });\n      // Step 3 — short-circuit if cancelled\n      if (event.defaultPrevented) return;\n      // Step 4 — microtask defer the navigation-side effects\n      queueMicrotask(() => {\n        onItemNavigate?.({ item });\n        if (autoCloseMobileOnNavigate && isMobileOpen) {\n          onCloseMobile(\"item-click\");\n        }\n      });\n      // Intentionally allow native <a> navigation to proceed unless\n      // consumer called preventDefault in step 1 or 2.\n      void sectionId;\n      void indexInSection;\n    };\n\n  // v0.3.0 (C1, L55): renderRow always wraps in a SINGLE `<li>`. SidebarNavRow\n  // returns only the tooltip-wrapped link (no internal `<li>`). The renderItem\n  // slot path uses the SAME `<li>` wrapper — so consumer's\n  // `renderItem={({ defaultRender }) => defaultRender}` produces\n  // `<li><a>…</a></li>` instead of the v0.2.x `<li><li>…</li></li>` bug.\n  // Item-level `className` + `data-testid` are applied to this wrapper.\n  const renderRow = (item: NavItem, sectionId: string | null, indexInSection: number) => {\n    const isActive = activeItemId === item.id;\n    const isFocused = focusedItemId === item.id;\n    const rovingTabIndex = resolveRovingTabIndex(item.id);\n    const defaultRender = (\n      <SidebarNavRow\n        item={item}\n        isActive={isActive}\n        isCollapsed={isCollapsed}\n        isFocused={isFocused}\n        rovingTabIndex={rovingTabIndex}\n        linkComponent={linkComponent}\n        activeVariant={activeVariant}\n        onClick={handleItemClick(item, sectionId, indexInSection)}\n        renderBadge={renderBadge}\n        renderTooltipContent={renderTooltipContent}\n        hrefTemplateValues={hrefTemplateValues}\n        resolveHref={resolveHref}\n      />\n    );\n    const body = renderItem\n      ? renderItem({\n          item,\n          isActive,\n          isCollapsed,\n          isFocused,\n          isDisabled: item.disabled ?? false,\n          sectionId,\n          indexInSection,\n          defaultRender,\n        })\n      : defaultRender;\n    return (\n      <li\n        key={item.id}\n        className={cn(\"list-none\", item.className)}\n        data-testid={item[\"data-testid\"]}\n      >\n        {body}\n      </li>\n    );\n  };\n\n  return (\n    <ul\n      role=\"list\"\n      className={cn(\n        \"flex flex-1 flex-col gap-1 overflow-y-auto\",\n        // C5 hooks: motion-safe transitions will live here\n      )}\n    >\n      {entries.map((entry, i) => {\n        if (entry.kind === \"separator\") {\n          return <SidebarNavSeparator key={entry.id ?? `sep-${i}`} />;\n        }\n        if (entry.kind === \"section\") {\n          const isSectionCollapsed = collapsedSectionIds.has(entry.id);\n          const isSectionFocused = focusedItemId === entry.id;\n          const sectionRovingTabIndex =\n            entry.collapsible ? resolveRovingTabIndex(entry.id) : -1;\n          const handleSectionToggle = () => {\n            onToggleSection(entry.id);\n            onSectionToggle?.({ section: entry, collapsed: !isSectionCollapsed });\n          };\n          const defaultSection = (\n            <SidebarNavSection\n              key={entry.id}\n              section={entry}\n              isCollapsed={isSectionCollapsed}\n              isSidebarCollapsed={isCollapsed}\n              visibleItemCount={entry.items.length}\n              isFocused={isSectionFocused}\n              rovingTabIndex={sectionRovingTabIndex}\n              onToggle={handleSectionToggle}\n            >\n              {entry.items.map((child, idx) => renderRow(child, entry.id, idx))}\n            </SidebarNavSection>\n          );\n          if (!renderSection) return defaultSection;\n          // `renderSection` is expected to return an `<li>` (typically by\n          // forwarding `defaultRender` or composing its own list-item).\n          // Fragment carries the key without injecting an invalid DOM node\n          // between the `<ul>` parent and the consumer's `<li>`.\n          return (\n            <Fragment key={entry.id}>\n              {renderSection({\n                section: entry,\n                isCollapsed: isSectionCollapsed,\n                visibleItemCount: entry.items.length,\n                defaultRender: defaultSection,\n              })}\n            </Fragment>\n          );\n        }\n        // Top-level NavItem — renderRow returns an `<li>` directly with\n        // the key already applied, so no wrapper element is needed.\n        return renderRow(entry, null, i);\n      })}\n    </ul>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/app-sidebar/parts/sidebar-nav-list.tsx"
    },
    {
      "path": "src/registry/components/navigation/app-sidebar/parts/sidebar-nav-row.tsx",
      "content": "\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport { getActiveVariantClasses } from \"../lib/active-variant-classes\";\nimport { resolveItemHref } from \"../lib/href-resolver\";\nimport type {\n  NavBadgeConfig,\n  NavItem,\n  NavLinkComponent,\n  AppSidebarProps,\n} from \"../types\";\nimport { Icon } from \"./icon\";\nimport { NavBadge } from \"./nav-badge\";\nimport { TooltipWrapper } from \"./tooltip-wrapper\";\n\ninterface SidebarNavRowProps {\n  item: NavItem;\n  isActive: boolean;\n  isCollapsed: boolean;\n  isFocused: boolean;\n  /**\n   * Roving tabindex (L37). `0` when this row is the keyboard entry point\n   * (= focused row OR — when nothing is focused — the first focusable row);\n   * `-1` otherwise. Disabled items always pass through to `tabIndex={-1}`.\n   */\n  rovingTabIndex: 0 | -1;\n  linkComponent: NavLinkComponent;\n  activeVariant?: AppSidebarProps[\"activeVariant\"];\n  onClick: (event: React.MouseEvent) => void;\n  renderBadge?: AppSidebarProps[\"renderBadge\"];\n  renderTooltipContent?: AppSidebarProps[\"renderTooltipContent\"];\n  // v0.2.0 — href resolution (L42 + L43)\n  hrefTemplateValues?: AppSidebarProps[\"hrefTemplateValues\"];\n  resolveHref?: AppSidebarProps[\"resolveHref\"];\n}\n\n/**\n * Resolve a NavItem's badge config (shorthand number/string → full config).\n */\nfunction resolveBadgeConfig(\n  badge: NavItem[\"badge\"],\n): NavBadgeConfig | null {\n  if (badge === undefined || badge === null) return null;\n  if (typeof badge === \"number\" || typeof badge === \"string\") {\n    return { value: badge };\n  }\n  // Already a NavBadgeConfig (or arbitrary ReactNode — treat as value)\n  if (typeof badge === \"object\" && \"value\" in badge) {\n    return badge as NavBadgeConfig;\n  }\n  // Bare ReactNode badge\n  return { value: badge as NavBadgeConfig[\"value\"] };\n}\n\nexport function SidebarNavRow({\n  item,\n  isActive,\n  isCollapsed,\n  isFocused,\n  rovingTabIndex,\n  linkComponent: LinkComponent,\n  activeVariant,\n  onClick,\n  renderBadge,\n  renderTooltipContent,\n  hrefTemplateValues,\n  resolveHref,\n}: SidebarNavRowProps) {\n  const isDisabled = item.disabled ?? false;\n  // v0.2.0: resolveItemHref applies the precedence pipeline (callback wins\n  // over template-value substitution; fallback to item.href as-is). When\n  // neither v0.2 prop is provided, this is byte-identical to v0.1 behavior.\n  const resolvedHref = resolveItemHref(item, { templateValues: hrefTemplateValues, resolveHref });\n  const href = resolvedHref ?? \"#\";\n\n  const badgeConfig = resolveBadgeConfig(item.badge);\n  const defaultBadge = badgeConfig ? (\n    <NavBadge {...badgeConfig} />\n  ) : null;\n  const badgeNode = renderBadge && badgeConfig\n    ? renderBadge({\n        item,\n        badge: badgeConfig,\n        position: isCollapsed ? \"corner\" : \"inline-end\",\n        defaultRender: defaultBadge,\n      })\n    : defaultBadge;\n\n  // Tooltip content when collapsed — label + optional shortcut + optional description\n  const tooltipContent = renderTooltipContent\n    ? renderTooltipContent({ item, isActive })\n    : (item.tooltipContent ?? (\n        <div className=\"flex flex-col gap-0.5\">\n          <span className=\"font-medium\">{item.label}</span>\n          {item.shortcut && (\n            <span className=\"text-[10px] text-muted-foreground\">\n              {item.shortcut}\n            </span>\n          )}\n          {!isActive && item.description && (\n            <span className=\"text-[10px] text-muted-foreground/90\">\n              {item.description}\n            </span>\n          )}\n        </div>\n      ));\n\n  const linkEl = (\n    <LinkComponent\n      href={href}\n      onClick={onClick}\n      aria-current={isActive ? \"page\" : undefined}\n      aria-label={isCollapsed ? item.label : undefined}\n      aria-disabled={isDisabled || undefined}\n      data-active={isActive}\n      data-focused={isFocused || undefined}\n      data-nav-id={item.id}\n      tabIndex={isDisabled ? -1 : rovingTabIndex}\n      target={item.target}\n      rel={item.rel}\n      className={cn(\n        \"group relative flex items-center gap-3 rounded-lg px-3 py-2.5\",\n        \"text-sm font-medium\",\n        \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card\",\n        // Active vs inactive paint via L12 variant matrix\n        getActiveVariantClasses(activeVariant, isActive),\n        // Disabled (L27)\n        isDisabled && \"pointer-events-none cursor-not-allowed opacity-50\",\n        // Center icon when collapsed\n        isCollapsed && \"justify-center\",\n      )}\n    >\n      {(item.icon !== undefined && item.icon !== null) || isCollapsed ? (\n        <span className=\"relative inline-flex h-5 w-5 shrink-0 items-center justify-center\">\n          {item.icon !== undefined && item.icon !== null ? (\n            <Icon icon={item.icon} />\n          ) : (\n            // Fallback glyph when collapsed AND no explicit icon —\n            // first letter of the label, styled like an icon. Keeps the\n            // row visually anchored so the corner-badge has something to\n            // sit on top of.\n            <span\n              aria-hidden=\"true\"\n              className=\"inline-flex h-5 w-5 items-center justify-center rounded-sm bg-muted text-[10px] font-semibold uppercase text-muted-foreground\"\n            >\n              {item.label?.[0] ?? \"•\"}\n            </span>\n          )}\n          {/* Badge in corner position when collapsed */}\n          {isCollapsed && badgeNode}\n        </span>\n      ) : null}\n      {!isCollapsed && (\n        <span className=\"flex-1 truncate\">{item.label}</span>\n      )}\n      {!isCollapsed && item.shortcut && (\n        <span\n          className={cn(\n            \"rounded px-1.5 py-0.5 font-mono text-xs\",\n            isActive ? \"bg-primary-foreground/10\" : \"bg-muted text-muted-foreground\",\n          )}\n        >\n          {item.shortcut}\n        </span>\n      )}\n      {!isCollapsed && item.accessory && (\n        <span className=\"inline-flex items-center\">{item.accessory}</span>\n      )}\n      {/* Badge in inline-end position when expanded */}\n      {!isCollapsed && badgeNode}\n    </LinkComponent>\n  );\n\n  // v0.3.0 (C1, L55): SidebarNavRow returns ONLY the tooltip-wrapped link.\n  // The outer `<li>` is owned by SidebarNavList — consistent across the\n  // default path and the renderItem-slot path, so consumer's\n  // `renderItem={({ defaultRender }) => defaultRender}` no longer produces\n  // double-nested `<li><li>...</li></li>`.\n  return (\n    <TooltipWrapper\n      content={tooltipContent}\n      side=\"right\"\n      disabled={!isCollapsed}\n    >\n      {linkEl}\n    </TooltipWrapper>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/app-sidebar/parts/sidebar-nav-row.tsx"
    },
    {
      "path": "src/registry/components/navigation/app-sidebar/parts/sidebar-nav-section.tsx",
      "content": "\"use client\";\n\nimport { ChevronDown } from \"lucide-react\";\nimport { useId } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport type { NavSection } from \"../types\";\nimport { Icon } from \"./icon\";\n\ninterface SidebarNavSectionProps {\n  section: NavSection;\n  isCollapsed: boolean; // section's own collapse state (NOT sidebar's)\n  isSidebarCollapsed: boolean; // sidebar collapsed = icon-only mode\n  visibleItemCount: number;\n  isFocused: boolean;\n  /** Roving tabindex for the collapsible header button (L37). */\n  rovingTabIndex: 0 | -1;\n  children: React.ReactNode; // the section's items (already rendered by parent)\n  onToggle: () => void;\n}\n\n/**\n * Section header + collapsible body wrapper.\n *\n * Behavior:\n *  - collapsible=true → header is a <button aria-expanded aria-controls> that toggles\n *  - collapsible=false → header is a <h6 role=\"heading\"> (label only, not focusable)\n *\n * At sidebar-collapsed mode (icon-only), section title hides; only the icon\n * shows. If section has no icon, the section header collapses to a thin\n * separator-like divider so groups remain visually distinct.\n */\nexport function SidebarNavSection({\n  section,\n  isCollapsed,\n  isSidebarCollapsed,\n  visibleItemCount,\n  isFocused,\n  rovingTabIndex,\n  children,\n  onToggle,\n}: SidebarNavSectionProps) {\n  const bodyId = useId();\n  const collapsible = section.collapsible ?? false;\n\n  // Sidebar at icon-only mode: render condensed\n  if (isSidebarCollapsed) {\n    return (\n      <li className=\"list-none\">\n        {section.icon || section.title ? (\n          <div className=\"px-3 py-1.5\" role=\"presentation\" aria-hidden=\"true\">\n            {section.icon ? (\n              <Icon\n                icon={section.icon}\n                className=\"h-3.5 w-3.5 text-muted-foreground\"\n              />\n            ) : (\n              <div className=\"h-px w-full bg-border\" />\n            )}\n          </div>\n        ) : (\n          <div className=\"my-1 h-px w-full bg-border\" role=\"presentation\" />\n        )}\n        {!isCollapsed && (\n          // <ul> (not <div>) so the `<li>` rows produced by SidebarNavRow\n          // are valid direct children. Outer wrapper is also <li> (section\n          // root), so the structure is <li><ul><li>...</li></ul></li> —\n          // valid HTML for a nested list group.\n          <ul\n            id={bodyId}\n            role=\"group\"\n            aria-label={section.title}\n            className=\"flex flex-col gap-1\"\n          >\n            {children}\n          </ul>\n        )}\n      </li>\n    );\n  }\n\n  // Expanded sidebar — full section UI\n  const headerInner = (\n    <span className=\"flex flex-1 items-center gap-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground\">\n      {section.icon && <Icon icon={section.icon} className=\"h-3.5 w-3.5\" />}\n      <span className=\"truncate\">{section.title}</span>\n      {visibleItemCount > 0 && (\n        <span\n          className=\"ml-1 text-[10px] font-normal text-muted-foreground/70\"\n          aria-hidden=\"true\"\n        >\n          {visibleItemCount}\n        </span>\n      )}\n    </span>\n  );\n\n  return (\n    <li className=\"list-none\">\n      {collapsible ? (\n        <button\n          type=\"button\"\n          onClick={onToggle}\n          aria-expanded={!isCollapsed}\n          aria-controls={bodyId}\n          data-nav-id={section.id}\n          data-focused={isFocused || undefined}\n          tabIndex={rovingTabIndex}\n          className={cn(\n            \"flex w-full items-center gap-2 rounded-md px-3 py-1.5 text-left\",\n            \"hover:bg-muted/50\",\n            \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card\",\n          )}\n        >\n          {headerInner}\n          <ChevronDown\n            className={cn(\n              \"h-3.5 w-3.5 text-muted-foreground\",\n              \"motion-safe:transition-transform motion-safe:duration-200\",\n              isCollapsed && \"-rotate-90\",\n              // RTL: chevron rotates the OTHER way when collapsed so it\n              // still points \"outward\" from the open direction\n              isCollapsed && \"rtl:rotate-90\",\n            )}\n            aria-hidden=\"true\"\n          />\n        </button>\n      ) : section.title ? (\n        // Non-collapsible header — pure label, not focusable\n        <h6 className=\"px-3 py-1.5\">{headerInner}</h6>\n      ) : null}\n\n      {!isCollapsed && (\n        <ul\n          id={bodyId}\n          role=\"group\"\n          aria-label={section.title}\n          className=\"mt-0.5 flex flex-col gap-1\"\n        >\n          {children}\n        </ul>\n      )}\n    </li>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/app-sidebar/parts/sidebar-nav-section.tsx"
    },
    {
      "path": "src/registry/components/navigation/app-sidebar/parts/sidebar-nav-separator.tsx",
      "content": "import { cn } from \"@/lib/utils\";\n\ninterface SidebarNavSeparatorProps {\n  className?: string;\n}\n\n/**\n * Thin horizontal divider between nav groups.\n * Spec'd as separate part so the visual can be themed via consumer CSS.\n */\nexport function SidebarNavSeparator({ className }: SidebarNavSeparatorProps) {\n  return (\n    <li role=\"presentation\" className={cn(\"my-2 px-3\", className)}>\n      <div className=\"h-px w-full bg-border\" />\n    </li>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/app-sidebar/parts/sidebar-nav-separator.tsx"
    },
    {
      "path": "src/registry/components/navigation/app-sidebar/parts/sidebar-nav-trigger.tsx",
      "content": "\"use client\";\n\nimport { cloneElement, isValidElement } from \"react\";\nimport { PanelLeft } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\nimport { useAppSidebarContextOrNull } from \"../contexts/sidebar-nav-context\";\nimport type { AppSidebarHandle, AppSidebarTriggerProps } from \"../types\";\n\ntype SlotCloneProps = React.HTMLAttributes<HTMLElement> & {\n  type?: \"button\";\n  children?: React.ReactNode;\n};\n\n/**\n * Minimal local slot (v0.3.1 — replaces the former `radix-ui` Slot import,\n * which was undeclared in meta/registry.json AND forbidden by the validator;\n * it broke module resolution on Base-UI consumers). Clones the single child\n * element and merges the trigger props onto it: `className` joined, `style`\n * merged (child wins), event handlers composed (child first, then trigger),\n * any other overlapping prop child-wins. The child's own `ref` is preserved\n * by `cloneElement` — no ref is ever passed in `triggerProps`.\n */\nfunction SlotClone({ children, ...slotProps }: SlotCloneProps) {\n  if (!isValidElement(children)) {\n    if (process.env.NODE_ENV !== \"production\") {\n      console.warn(\n        \"[app-sidebar] <AppSidebarTrigger asChild> expects a single React element child — got none. Rendering nothing.\",\n      );\n    }\n    return null;\n  }\n  const childProps = children.props as Record<string, unknown>;\n  const merged: Record<string, unknown> = { ...slotProps };\n  for (const key of Object.keys(slotProps)) {\n    const slotValue = (slotProps as Record<string, unknown>)[key];\n    const childValue = childProps[key];\n    if (childValue === undefined) continue;\n    if (/^on[A-Z]/.test(key) && typeof slotValue === \"function\" && typeof childValue === \"function\") {\n      merged[key] = (...args: unknown[]) => {\n        (childValue as (...a: unknown[]) => void)(...args);\n        (slotValue as (...a: unknown[]) => void)(...args);\n      };\n    } else if (key === \"className\") {\n      merged[key] = cn(slotValue as string | undefined, childValue as string | undefined);\n    } else if (key === \"style\") {\n      merged[key] = {\n        ...(slotValue as React.CSSProperties),\n        ...(childValue as React.CSSProperties),\n      };\n    } else {\n      merged[key] = childValue;\n    }\n  }\n  return cloneElement(children, merged);\n}\n\n/**\n * Companion trigger — mount in your app header to open the mobile drawer.\n *\n * Resolution order (L17 + L40):\n *   1. Explicit `controls` ref/handle (escape hatch) — wins\n *   2. Nearest AppSidebarContext (works when trigger is a DESCENDANT\n *      of <AppSidebar>; siblings can't read it because the context\n *      provider sits inside the sidebar's own render tree)\n *   3. Neither → dev-only console.warn + no-op\n *\n * Resolution happens at CLICK TIME, not render time, because the ref's\n * `.current` populates after the sidebar's `useImperativeHandle` runs\n * (post-mount). Resolving at render time would see `null` on first paint.\n *\n * Visual: hamburger icon (PanelLeft) — opens the drawer. Closing is\n * handled by the drawer's own SheetContent close-X (shipped by shadcn's\n * Sheet primitive) + Esc + outside-click. The trigger button itself\n * does NOT flip its icon based on drawer state — keeps the component\n * stateless and avoids the cross-tree re-render problem for v0.1.\n * A future AppSidebarProvider wrapper will enable trigger state\n * reflection across React subtrees if needed.\n *\n * Override the icon entirely via `children` (custom node) or `asChild`\n * (compose onto consumer's own element via the local SlotClone above).\n */\nexport function AppSidebarTrigger({\n  controls,\n  className,\n  children,\n  \"aria-label\": ariaLabel,\n  asChild = false,\n}: AppSidebarTriggerProps) {\n  const ctx = useAppSidebarContextOrNull();\n\n  const handleClick = () => {\n    // Resolve handle at click time (not render time) — ref.current may\n    // still be null on first render before useImperativeHandle runs.\n    let resolved: AppSidebarHandle | null = null;\n    if (controls) {\n      if (typeof controls === \"object\" && controls !== null && \"current\" in controls) {\n        resolved = (controls as React.RefObject<AppSidebarHandle | null>).current;\n      } else {\n        resolved = controls as AppSidebarHandle;\n      }\n    }\n    if (!resolved && ctx) {\n      resolved = ctx.handle;\n    }\n    if (resolved) {\n      // v0.3.0 (C2, L54 + Q25): companion trigger always identifies as\n      // \"trigger\" so consumers wiring onMobileOpenChange to analytics can\n      // distinguish hamburger taps from other close paths.\n      resolved.toggleMobile(\"trigger\");\n    } else if (process.env.NODE_ENV !== \"production\") {\n      console.warn(\n        \"[app-sidebar] <AppSidebarTrigger> clicked with no <AppSidebar> in context and no `controls` prop — no-op.\",\n      );\n    }\n  };\n\n  const sidebarId = ctx?.sidebarId;\n\n  const triggerProps = {\n    type: \"button\" as const,\n    onClick: handleClick,\n    \"aria-controls\": sidebarId,\n    \"aria-haspopup\": \"dialog\" as const,\n    \"aria-label\": ariaLabel ?? \"Open navigation\",\n    className: cn(\n      !asChild &&\n        \"inline-flex h-9 w-9 items-center justify-center rounded-md text-foreground hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\",\n      className,\n    ),\n  };\n\n  if (asChild) {\n    return <SlotClone {...triggerProps}>{children}</SlotClone>;\n  }\n\n  return (\n    <button {...triggerProps}>\n      {children ?? <PanelLeft className=\"h-4 w-4\" aria-hidden=\"true\" />}\n    </button>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/app-sidebar/parts/sidebar-nav-trigger.tsx"
    },
    {
      "path": "src/registry/components/navigation/app-sidebar/parts/sidebar-skip-link.tsx",
      "content": "\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport type { AppSidebarEventArgs } from \"../types\";\n\ninterface SidebarSkipLinkProps {\n  target: string;\n  label: string;\n  onActivated?: (args: AppSidebarEventArgs[\"skipLinkActivated\"]) => void;\n}\n\n/**\n * Opt-in skip link (L31). Hidden until focused; appears at the top of the\n * sidebar's stacking context on keyboard focus. Activating jumps focus /\n * scroll to the consumer-supplied `skipLinkTarget` (e.g. `#main-content`).\n *\n * The component intentionally renders a plain `<a>` — using the consumer's\n * `linkComponent` for an in-page anchor would risk SPA frameworks treating\n * it as a route change. Native anchor behavior is the right primitive.\n */\nexport function SidebarSkipLink({\n  target,\n  label,\n  onActivated,\n}: SidebarSkipLinkProps) {\n  return (\n    <a\n      href={target}\n      data-sidebar-skip-link\n      onClick={(event) => onActivated?.({ event })}\n      className={cn(\n        // sr-only by default — appears on focus\n        \"sr-only focus:not-sr-only\",\n        \"focus:absolute focus:left-2 focus:top-2 focus:z-50\",\n        // RTL: skip link should sit on the same logical inline-start edge,\n        // which means physical-right when direction is RTL\n        \"rtl:focus:left-auto rtl:focus:right-2\",\n        \"focus:inline-flex focus:items-center focus:rounded-md\",\n        \"focus:bg-card focus:px-3 focus:py-2\",\n        \"focus:text-sm focus:font-medium focus:text-foreground\",\n        \"focus:shadow-md focus:ring-1 focus:ring-border\",\n        \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card\",\n      )}\n    >\n      {label}\n    </a>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/app-sidebar/parts/sidebar-skip-link.tsx"
    },
    {
      "path": "src/registry/components/navigation/app-sidebar/parts/tooltip-wrapper.tsx",
      "content": "\"use client\";\n\nimport {\n  cloneElement,\n  isValidElement,\n  useCallback,\n  useEffect,\n  useId,\n  useRef,\n  useState,\n  type ReactElement,\n  type ReactNode,\n} from \"react\";\nimport { cn } from \"@/lib/utils\";\n\ninterface TooltipWrapperProps {\n  content: ReactNode;\n  children: ReactNode;\n  side?: \"right\" | \"top\" | \"bottom\" | \"left\";\n  disabled?: boolean;\n  /** Hover show-delay in ms (keyboard focus shows immediately). Default 300. */\n  delay?: number;\n}\n\n/** Gap between the wrapped element and the bubble (old Radix sideOffset parity). */\nconst SIDE_OFFSET_PX = 8;\n\ntype TooltipSide = NonNullable<TooltipWrapperProps[\"side\"]>;\n\ninterface TooltipPosition {\n  top: number;\n  left: number;\n}\n\nfunction computePosition(rect: DOMRect, side: TooltipSide): TooltipPosition {\n  switch (side) {\n    case \"left\":\n      return { top: rect.top + rect.height / 2, left: rect.left - SIDE_OFFSET_PX };\n    case \"top\":\n      return { top: rect.top - SIDE_OFFSET_PX, left: rect.left + rect.width / 2 };\n    case \"bottom\":\n      return { top: rect.bottom + SIDE_OFFSET_PX, left: rect.left + rect.width / 2 };\n    case \"right\":\n      return { top: rect.top + rect.height / 2, left: rect.right + SIDE_OFFSET_PX };\n  }\n}\n\nconst SIDE_TRANSFORM: Record<TooltipSide, string> = {\n  right: \"translateY(-50%)\",\n  left: \"translate(-100%, -50%)\",\n  top: \"translate(-50%, -100%)\",\n  bottom: \"translateX(-50%)\",\n};\n\n/**\n * Tooltip wrapper for collapsed-sidebar row labels.\n *\n * v0.3.2 (F-cross-13 path-b): the shadcn Tooltip primitive is GONE from this\n * wrapper. `children` is an arbitrary interactive element (nav link,\n * primary-action button) and BOTH backends render `TooltipTrigger` as a\n * native `<button>` — composing required `asChild` (Base UI rejects it) and\n * wrapping without it nests interactive elements inside a button. So this is\n * now a tiny local tooltip: a `position: fixed` bubble (escapes the nav\n * list's overflow clipping the same way the old Radix portal did), shown\n * after `delay` ms on hover / immediately on keyboard (:focus-visible)\n * focus, hidden on leave / blur / press / Escape / any scroll.\n * `aria-describedby` is cloned onto the child element. `delay` is honored\n * cross-backend via setTimeout — no Radix-only `delayDuration` anywhere.\n * Trade-offs vs Radix: no arrow, no collision flipping, no cross-tooltip\n * skip-delay grouping.\n *\n * When `disabled` is true (e.g., expanded sidebar mode), children render\n * without any wrapper element at all.\n */\nexport function TooltipWrapper({\n  content,\n  children,\n  side = \"right\",\n  disabled,\n  delay = 300,\n}: TooltipWrapperProps) {\n  const tooltipId = useId();\n  const wrapperRef = useRef<HTMLSpanElement | null>(null);\n  const showTimerRef = useRef<number | null>(null);\n  const [position, setPosition] = useState<TooltipPosition | null>(null);\n\n  const clearShowTimer = useCallback(() => {\n    if (showTimerRef.current !== null) {\n      window.clearTimeout(showTimerRef.current);\n      showTimerRef.current = null;\n    }\n  }, []);\n\n  const show = useCallback(() => {\n    const el = wrapperRef.current;\n    if (!el) return;\n    // Parity with the old asChild path: a pointer-events-none child (disabled\n    // nav row) never received hover, so its tooltip never opened.\n    const child = el.firstElementChild;\n    if (\n      child instanceof HTMLElement &&\n      getComputedStyle(child).pointerEvents === \"none\"\n    ) {\n      return;\n    }\n    setPosition(computePosition(el.getBoundingClientRect(), side));\n  }, [side]);\n\n  const hide = useCallback(() => {\n    clearShowTimer();\n    setPosition(null);\n  }, [clearShowTimer]);\n\n  // Fixed-position coords go stale the moment anything scrolls (Radix\n  // repositioned via floating middleware; we simply dismiss). Capture phase\n  // so the nav list's own scroll container is caught too.\n  useEffect(() => {\n    if (position === null) return;\n    const onScroll = () => setPosition(null);\n    window.addEventListener(\"scroll\", onScroll, true);\n    window.addEventListener(\"resize\", onScroll);\n    return () => {\n      window.removeEventListener(\"scroll\", onScroll, true);\n      window.removeEventListener(\"resize\", onScroll);\n    };\n  }, [position]);\n\n  // Unmount — drop any pending show timer.\n  useEffect(() => clearShowTimer, [clearShowTimer]);\n\n  // `disabled` flipping true while shown (e.g. the rail expands under a\n  // resting pointer) skips render below but keeps state — the stale bubble\n  // would resurrect at old coords on the next collapse. Render-phase\n  // adjustment (react.dev \"adjusting state when a prop changes\"); a pending\n  // show-timer self-guards via the nulled wrapperRef, so only committed\n  // position needs the reset.\n  const [prevDisabled, setPrevDisabled] = useState(disabled);\n  if (disabled !== prevDisabled) {\n    setPrevDisabled(disabled);\n    if (disabled) setPosition(null);\n  }\n\n  if (disabled) return <>{children}</>;\n\n  // Wire aria-describedby onto the wrapped element itself (the focusable\n  // thing), composing with any describedby it already carries. Non-element\n  // children (arbitrary renderItem output) render unwired — best-effort.\n  let describedChildren = children;\n  if (isValidElement(children)) {\n    const element = children as ReactElement<{ \"aria-describedby\"?: string }>;\n    const existing = element.props[\"aria-describedby\"];\n    describedChildren = cloneElement(element, {\n      \"aria-describedby\": existing ? `${existing} ${tooltipId}` : tooltipId,\n    });\n  }\n\n  return (\n    <span\n      ref={wrapperRef}\n      data-slot=\"app-sidebar-tooltip-wrapper\"\n      className=\"relative block\"\n      onMouseEnter={() => {\n        clearShowTimer();\n        if (delay <= 0) {\n          show();\n          return;\n        }\n        showTimerRef.current = window.setTimeout(show, delay);\n      }}\n      onMouseLeave={hide}\n      onFocus={(event) => {\n        // Radix parity: keyboard focus opens instantly; mouse-press focus doesn't.\n        const target = event.target as HTMLElement;\n        if (typeof target.matches === \"function\" && target.matches(\":focus-visible\")) {\n          show();\n        }\n      }}\n      onBlur={hide}\n      onPointerDown={hide}\n      onKeyDown={(event) => {\n        if (event.key === \"Escape\") hide();\n      }}\n    >\n      {describedChildren}\n      <span\n        role=\"tooltip\"\n        id={tooltipId}\n        data-side={side}\n        style={\n          position\n            ? { top: position.top, left: position.left, transform: SIDE_TRANSFORM[side] }\n            : undefined\n        }\n        className={cn(\n          \"pointer-events-none fixed z-50 w-fit max-w-xs items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background\",\n          position ? \"inline-flex\" : \"hidden\",\n        )}\n      >\n        {content}\n      </span>\n    </span>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/app-sidebar/parts/tooltip-wrapper.tsx"
    }
  ],
  "categories": [
    "navigation",
    "app-shell"
  ],
  "type": "registry:block"
}