{{define "layout"}}
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
  <title>{{.PageTitle}} — Breadbox</title>
  <!-- Safari: theme color for status bar / tab bar -->
  <meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)">
  <meta name="theme-color" content="#1a1a1a" media="(prefers-color-scheme: dark)">
  <!-- Safari: Add to Home Screen support. The non-prefixed mobile-web-app-capable
       is the modern equivalent; Safari still reads the apple-* variant, but
       browsers warn in console without the standard name. Ship both. -->
  <meta name="apple-mobile-web-app-capable" content="yes">
  <meta name="mobile-web-app-capable" content="yes">
  <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
  <meta name="apple-mobile-web-app-title" content="Breadbox">
  <link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
  <link rel="apple-touch-icon" href="/static/apple-touch-icon.png">
  <script>
    // Apply the persisted theme preference before paint so we don't flash
    // the wrong theme on reload. Three states (mirrors $store.theme below):
    //   'light' / 'dark' — force data-theme to that override
    //   'system' / absent / anything else — remove data-theme and let
    //     daisyUI's prefers-color-scheme default drive it ("light --default,
    //     dark --prefersdark" in input.css). Selecting "system" is the only
    //     way back to OS-following after a manual override.
    // Wrapped in try/catch because localStorage throws in private-mode
    // Safari and the toggle should degrade gracefully.
    (function () {
      try {
        var t = localStorage.getItem('bb-theme');
        if (t === 'light' || t === 'dark') {
          document.documentElement.dataset.theme = t;
        } else {
          delete document.documentElement.dataset.theme;
        }
      } catch (_) { /* best-effort */ }
    })();
  </script>
  <script>
    // Apply the persisted privacy mode before paint so sensitive values never
    // flash before privacy.js rewrites (obfuscate) or blurs (hide) them.
    //   'obfuscate'/'hide' — set data-privacy (CSS keys off it) AND add the
    //     'privacy-pending' class, which blurs every [data-private] until
    //     privacy.js's first pass runs and removes the class.
    //   'real' / absent    — no-op.
    // See static/js/admin/privacy.js + the privacy block in input.css.
    (function () {
      try {
        var p = localStorage.getItem('bb-privacy');
        if (p === 'obfuscate' || p === 'hide') {
          document.documentElement.setAttribute('data-privacy', p);
          document.documentElement.classList.add('privacy-pending');
        }
      } catch (_) { /* best-effort */ }
    })();
  </script>
  <!-- Privacy engine: defines window.bbPrivacy and rewrites/restores
       [data-private] nodes. Non-deferred so it exists before Alpine's
       `privacy` store inits; its own pass waits for DOMContentLoaded. -->
  <script src="/static/js/admin/privacy.js"></script>
  <!-- Copy-to-clipboard for server-rendered markdown code blocks (.bb-code).
       Delegated listener — covers every code block on the page incl. ones
       injected later by live polls. -->
  <script src="/static/js/admin/code-copy.js" defer></script>
  <!-- fetchpriority="high" hints that the app stylesheet is render-blocking and
       critical. Safari 17.2+ and Chrome 102+ honor it; older browsers ignore. -->
  <link rel="stylesheet" href="/static/css/styles.css" fetchpriority="high">
  <script>
    // Stash the browser's IANA timezone in a cookie so server-rendered date
    // logic ("Today" buckets, hero "Events today" counters, absolute-time
    // tooltips on /feed and the per-tx activity log) can render against the
    // viewer's wall clock instead of the server's. Re-set on every load so
    // DST shifts and travel propagate without manual intervention.
    (function () {
      try {
        var tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
        if (tz && /^[A-Za-z0-9_+\-/]+$/.test(tz)) {
          var oneYear = 60 * 60 * 24 * 365;
          var attrs = '; Path=/; Max-Age=' + oneYear + '; SameSite=Lax';
          if (location.protocol === 'https:') attrs += '; Secure';
          document.cookie = 'bb_tz=' + encodeURIComponent(tz) + attrs;
        }
      } catch (_) { /* best-effort */ }
    })();
  </script>
  <!-- Inter Variable, self-hosted to eliminate the cross-origin font swap on
       hard-reload. The @font-face declarations live in input.css so the
       wizard layout shares them; the roman file is preloaded here because
       it covers nearly all body copy and must paint with the page. -->
  <link rel="preload" href="/static/fonts/InterVariable.woff2" as="font" type="font/woff2" crossorigin>
  <style>
    /* Inter Variable + OpenType features mirroring shadcn's Nova polish.
       - cv11: more humanist single-storey g (matches Nova preview text)
       - ss01: stylistic alternates (the 'a' shape that gives Inter its "tightened" look)
       - cv02: rounded l
       Tabular nums everywhere — numbers in tables and tx lists stop jittering. */
    :root { font-family: 'Inter var', system-ui, -apple-system, sans-serif; }
    body {
      font-family: 'Inter var', system-ui, -apple-system, sans-serif;
      font-feature-settings: 'cv11', 'ss01', 'cv02';
      font-synthesis-weight: none;
      text-rendering: optimizeLegibility;
    }
  </style>
  <!--
    Speculation Rules — Chromium-only predictive prefetch of sidebar nav.
    Chrome 122+ fetches matching links when it predicts the user is about
    to navigate (intersection + hover heuristics). Safari ignores the
    script entirely, so this is purely additive — iOS gets the equivalent
    win from the hover/touchstart prefetch listener below in body scripts.
  -->
  <script type="speculationrules">
    {
      "prefetch": [{
        "source": "document",
        "where": { "selector_matches": "a.bb-sidebar-link" },
        "eagerness": "moderate"
      }]
    }
  </script>
  <script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/collapse@3.15.11/dist/cdn.min.js" integrity="sha384-NArNwzWsUSF+kY2lgW4YriEkjLqi+J+za6HrENUn/3nZqkBnWbxV22kCJEK5Uu6n" crossorigin="anonymous"></script>
  <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.15.11/dist/cdn.min.js" integrity="sha384-WPtu0YHhJ3arcykfnv1JgUffWDSKRnqnDeTpJUbOc2os2moEmLkIdaeR0trPN4be" crossorigin="anonymous"></script>
  <!-- Lucide: most icons are now server-rendered as inline SVG via the
       LucideIcon templ component (or the `lucide` html/template func) — see
       internal/templates/components/lucide.templ. The bundle remains loaded
       (deferred) for the small set of dynamically-bound `<i data-lucide>`
       placeholders that Alpine swaps at runtime (cmdk results, toast
       templates, the category and tag pickers, expand/collapse chevrons).
       Those callsites compute the icon name in JS, so createIcons() still
       needs to be available — but no longer drives the initial paint. -->
  <script defer src="/static/js/vendor/lucide.min.js"></script>
  {{if .CSRFToken}}<meta name="csrf-token" content="{{.CSRFToken}}">{{end}}
  <script>
    // Auto-inject CSRF token into all same-origin fetch requests.
    (function() {
      var origFetch = window.fetch;
      window.fetch = function(input, init) {
        init = init || {};
        var method = (init.method || 'GET').toUpperCase();
        if (method !== 'GET' && method !== 'HEAD') {
          var meta = document.querySelector('meta[name="csrf-token"]');
          if (meta) {
            init.headers = init.headers || {};
            // Support both Headers object and plain object
            if (init.headers instanceof Headers) {
              if (!init.headers.has('X-CSRF-Token')) {
                init.headers.set('X-CSRF-Token', meta.content);
              }
            } else {
              if (!init.headers['X-CSRF-Token']) {
                init.headers['X-CSRF-Token'] = meta.content;
              }
            }
          }
        }
        return origFetch.call(this, input, init);
      };
    })();
  </script>

  <script>
    // Alpine store: $store.device
    //
    // Exposes `isTouch` and `isMobile` for downstream UI code (keyboard-hint
    // components, shortcut dispatchers) to branch on input capability without
    // each call site re-running matchMedia. Evaluated once on Alpine init —
    // breakpoint changes on resize are not currently observed because nothing
    // in the UI depends on transitional values, and keeping the store static
    // avoids forcing every consumer to use reactive getters.
    //
    // Detection:
    //   isTouch — `(hover: none) and (pointer: coarse)` (true on phones, false
    //             on most laptops/desktops even with touchscreens).
    //   isMobile — viewport width < 1024px (matches the existing `lg:` breakpoint
    //              used by the drawer + mobile navbar).
    document.addEventListener('alpine:init', function() {
      var mqTouch = window.matchMedia('(hover: none) and (pointer: coarse)');
      var mqMobile = window.matchMedia('(max-width: 1023.98px)');
      Alpine.store('device', {
        isTouch: mqTouch.matches,
        isMobile: mqMobile.matches,
      });
    });
  </script>

  <script>
    // Alpine store: $store.drawers
    //
    // Drives the shared right-side slide-over (components.Drawer). One
    // drawer is open at a time, keyed by the id passed to the component.
    // Open from anywhere — a button, a row action, a page factory:
    //   <button @click="$store.drawers.open('edit-note')">Edit</button>
    //   Alpine.store('drawers').open('edit-note')   // from JS
    // close() is also bound to Escape + backdrop click by the component.
    // Body scroll is locked while a drawer is open so the page behind it
    // doesn't scroll under the panel.
    document.addEventListener('alpine:init', function() {
      Alpine.store('drawers', {
        active: null,
        is: function(id) { return this.active === id; },
        open: function(id) {
          this.active = id;
          document.documentElement.style.overflow = 'hidden';
        },
        close: function() {
          this.active = null;
          document.documentElement.style.overflow = '';
        },
      });
    });
  </script>

  <script>
    // Alpine store: $store.theme
    //
    // Single source of truth for the theme preference, shared by every
    // ThemeToggle / ThemeControl instance (sidebar, design navbar,
    // Settings → General). Three states:
    //   'system' — no data-theme; daisyUI's prefers-color-scheme drives it
    //   'light'  — forces data-theme="light"
    //   'dark'   — forces data-theme="dark"
    //
    // Persisted to localStorage('bb-theme'); the pre-paint <head> script
    // reapplies it on the next load so reloads never flash the other theme.
    // Theme is intentionally per-browser, not server-stored — a member's
    // dark-mode choice shouldn't follow them across devices or leak to
    // other household members. `systemDark` is tracked reactively so the
    // toggle glyph updates live when the OS flips while in 'system'.
    document.addEventListener('alpine:init', function() {
      var KEY = 'bb-theme';
      var mq = window.matchMedia('(prefers-color-scheme: dark)');
      function read() {
        try {
          var v = localStorage.getItem(KEY);
          if (v === 'light' || v === 'dark' || v === 'system') return v;
        } catch (_) { /* private-mode Safari */ }
        return 'system';
      }
      function apply(pref) {
        var root = document.documentElement;
        if (pref === 'light' || pref === 'dark') root.dataset.theme = pref;
        else delete root.dataset.theme;
      }
      Alpine.store('theme', {
        preference: read(),
        systemDark: mq.matches,
        get resolved() {
          return this.preference === 'system'
            ? (this.systemDark ? 'dark' : 'light')
            : this.preference;
        },
        get title() {
          return 'Theme: ' + this.preference + ' (click to change)';
        },
        set: function(pref) {
          if (pref !== 'light' && pref !== 'dark' && pref !== 'system') return;
          this.preference = pref;
          try { localStorage.setItem(KEY, pref); } catch (_) {}
          apply(pref);
        },
        cycle: function() {
          var next = { system: 'light', light: 'dark', dark: 'system' };
          this.set(next[this.preference] || 'system');
        },
        // toggle() is the command-palette / '*'-shortcut "Toggle theme"
        // action. Unlike cycle() — system → light → dark → system, which can
        // land on a visual no-op when the active theme already matches the OS
        // (e.g. dark → system while the OS is dark looks identical) — toggle()
        // flips between 'system' and the OPPOSITE of the current OS theme, so
        // a toggle is *always* a visible change. OS dark → bounce system(dark)
        // ↔ light; OS light → bounce system(light) ↔ dark. Keyed off the
        // resolved theme so an explicit light/dark preference still flips.
        toggle: function() {
          var opposite = this.systemDark ? 'light' : 'dark';
          this.set(this.resolved === opposite ? 'system' : opposite);
        },
        init: function() {
          var self = this;
          mq.addEventListener('change', function(e) { self.systemDark = e.matches; });
          // Cross-document sync: the `storage` event fires in every OTHER
          // same-origin document (other tabs, and — load-bearing here — the
          // embedded component iframe on /design/c/{slug}?embed=1) when one of
          // them writes localStorage. Without this, toggling the theme on the
          // standalone component page updates the parent but leaves the iframe
          // stuck on its load-time theme. e.key is null on clear(); re-read
          // through read() either way so we stay in sync with the source.
          window.addEventListener('storage', function(e) {
            if (e.key !== null && e.key !== KEY) return;
            var pref = read();
            self.preference = pref;
            apply(pref);
          });
        },
      });
    });
  </script>

  <script>
    // Alpine store: $store.privacy
    //
    // Reactive façade over window.bbPrivacy (static/js/admin/privacy.js), which
    // owns the actual mode state + DOM rewriting. This store exists so the
    // avatar-menu control can bind to `$store.privacy.mode` for active-segment
    // highlighting; every mutation delegates to window.bbPrivacy so the engine
    // stays the single source of truth. Three modes: 'real' / 'obfuscate' /
    // 'hide'. Persisted per-browser (never server-stored — a privacy preference
    // shouldn't leak across devices or members), mirroring $store.theme.
    document.addEventListener('alpine:init', function() {
      Alpine.store('privacy', {
        mode: (window.bbPrivacy && window.bbPrivacy.mode) || 'real',
        set: function(m) {
          if (window.bbPrivacy) window.bbPrivacy.set(m);
          this.mode = (window.bbPrivacy && window.bbPrivacy.mode) || m;
        },
        cycle: function() {
          if (window.bbPrivacy) window.bbPrivacy.cycle();
          this.mode = (window.bbPrivacy && window.bbPrivacy.mode) || this.mode;
        },
        get active() { return this.mode !== 'real'; },
      });
    });
  </script>

  <script>
    // bbKbdGlyph(key) → display string for a keyboard-shortcut token.
    //
    // Mirrors the Go-side `kbdGlyph` in internal/templates/components/kbd.templ.
    // Both must stay in lock-step so a help-modal entry, a cmdk hint, and a
    // server-rendered Kbd/KbdCombo component all render the exact same glyph
    // for a given registered key (e.g. 'cmd' → '⌘'). Shared by
    // `toTokens()` (help modal) and `shortcutKeyTokens()` (palette).
    window.bbKbdGlyph = function(key) {
      if (key == null) return '';
      var s = String(key);
      switch (s.toLowerCase()) {
        case 'cmd':
        case 'meta':    return '⌘';         // ⌘
        case 'shift':   return '⇧';         // ⇧
        case 'ctrl':    return '⌃';         // ⌃
        case 'alt':
        case 'option':
        case 'opt':     return '⌥';         // ⌥
        case 'enter':
        case 'return':  return '↵';         // ↵
        case 'esc':     return 'esc';
        case 'tab':     return '⇥';         // ⇥
        case 'space':   return '␣';         // ␣
        case 'up':      return '↑';         // ↑
        case 'down':    return '↓';         // ↓
        case 'left':    return '←';         // ←
        case 'right':   return '→';         // →
      }
      // Single-letter alphanumerics render uppercase so they read like a
      // key cap (`k` → `K`). Longer labels (`F2`, `?`) pass through.
      return s.length === 1 ? s.toUpperCase() : s;
    };
  </script>

  <script>
    // Alpine store: $store.shortcuts
    //
    // Central registry for all keyboard shortcuts. Pages declare their bindings
    // via $store.shortcuts.register() on init; the global dispatcher (see the
    // keydown listener near the bottom of this file) reads from this registry
    // to route events. Upcoming PRs will also feed the help modal (M0.3) and
    // command-palette kbd hints (M0.5) from the same source.
    //
    // Spec shape (see planned-features/keyboard-nav-sprint.md):
    //   {
    //     id:          string (unique),
    //     keys:        'c' | ['c'] | { chord: ['g','d'] } | 'cmd+k',
    //     description: short user-facing string,
    //     group:       'Navigation' | 'Go to' | 'New' | 'Actions' | 'Selection',
    //     scope:       'global' | route id (e.g. 'transactions'),
    //     when:        optional () => bool,
    //     action:      () => void | { cmdk: { label, icon, href } } palette-only,
    //     visible:     default true (hide from help modal; action still wired),
    //     allowInInput: optional bool (shortcut still fires while typing),
    //   }
    //
    // M0.2 scope: register + dispatch only. The help modal and command palette
    // remain hardcoded in this PR; they will be rewired to read from this
    // registry in M0.3 and M0.5.
    document.addEventListener('alpine:init', function() {
      Alpine.store('shortcuts', {
        items: [],
        currentScope: 'global',
        // Optional parent scope for the current scope. When set, the
        // dispatcher and help modal treat the parent's shortcuts as part
        // of the active set too — lets a page compose its bindings on top
        // of a related scope (e.g. the review queue inherits the
        // transactions list's j/k/Enter/c/t/e/x/Esc without re-registering).
        parentScope: null,

        register(spec) {
          if (!spec || !spec.id) return;
          // Idempotent by id — re-registering replaces the previous spec so
          // Alpine re-init (htmx boosts, drawer re-render) doesn't double-bind.
          var idx = this.items.findIndex(function(s) { return s.id === spec.id; });
          if (idx >= 0) this.items.splice(idx, 1, spec);
          else this.items.push(spec);
        },

        unregister(id) {
          var idx = this.items.findIndex(function(s) { return s.id === id; });
          if (idx >= 0) this.items.splice(idx, 1);
        },

        // Sets the active scope and, optionally, a parent scope that the
        // current scope inherits shortcuts from. Pass `{ parent: null }`
        // (or omit the second arg) to clear the parent.
        setScope(s, opts) {
          this.currentScope = s || 'global';
          this.parentScope = (opts && opts.parent) || null;
        },

        // True if `specScope` is considered active for the current scope —
        // i.e. it's 'global', the current scope itself, or the current
        // scope's declared parent. Exposed so the dispatcher and help
        // modal share one matching rule.
        scopeActive(specScope) {
          if (specScope === 'global') return true;
          if (specScope === this.currentScope) return true;
          if (this.parentScope && specScope === this.parentScope) return true;
          return false;
        },

        forScope(scope) {
          // Returns all shortcuts active for `scope` (or currentScope).
          // When called with an explicit scope arg, we still honor the
          // configured parentScope — callers are the help modal and
          // command palette, which always reason about the live scope.
          var target = scope || this.currentScope;
          var parent = this.parentScope;
          return this.items.filter(function(s) {
            if (s.scope === 'global') return true;
            if (s.scope === target) return true;
            if (parent && s.scope === parent) return true;
            return false;
          });
        },
      });
    });
  </script>
  <script>
    // Sidebar collapse — applied before paint (like the theme above) so a
    // reload never flashes the expanded rail then snaps to collapsed.
    // bbToggleSidebar flips <html data-sidebar="collapsed"> and persists the
    // choice; the desktop icon-rail CSS keys off that attribute (only active
    // at lg+ — the mobile drawer overlay has no collapsed state). localStorage
    // throws in private-mode Safari, so every access is best-effort.
    (function () {
      try {
        if (localStorage.getItem('bb-sidebar') === 'collapsed') {
          document.documentElement.dataset.sidebar = 'collapsed';
        }
      } catch (_) { /* best-effort */ }
    })();
    // Keep the toggle's tooltip honest: "Expand sidebar" while collapsed,
    // "Collapse sidebar" while expanded — so the icon-only rail (where the
    // visible "Collapse" label is hidden) never advertises the wrong action.
    function bbSyncSidebarToggleLabel() {
      var btn = document.querySelector('.bb-sidebar-collapse');
      if (!btn) return;
      btn.title = document.documentElement.dataset.sidebar === 'collapsed'
        ? 'Expand sidebar' : 'Collapse sidebar';
    }
    window.bbToggleSidebar = function () {
      var el = document.documentElement;
      var willCollapse = el.dataset.sidebar !== 'collapsed';
      if (willCollapse) { el.dataset.sidebar = 'collapsed'; }
      else { delete el.dataset.sidebar; }
      try { localStorage.setItem('bb-sidebar', willCollapse ? 'collapsed' : 'expanded'); } catch (_) {}
      bbSyncSidebarToggleLabel();
    };
    document.addEventListener('DOMContentLoaded', bbSyncSidebarToggleLabel);
  </script>
</head>
<body>
  <!-- Navigation progress bar -->
  <div class="bb-progress-bar" id="bb-nav-progress">
    <div class="bb-progress-bar__fill" id="bb-nav-progress-fill"></div>
  </div>

  {{if .Embed}}
  {{- /* Embed shell — bare HTML for an iframe target. No top bar, no
        sidebar, no flash, no footer. Only the global <head> scripts
        (Alpine, Lucide, styles.css) so the embedded demo's
        interactivity continues to work. Used by /design/c/{slug}?embed=1
        loaded inside the sandbox's viewport-toggle iframe. */ -}}
  <main class="p-6">
    {{if .TemplContent}}{{.TemplContent}}{{else}}{{block "content" .}}{{end}}{{end}}
  </main>
  {{else if .Standalone}}
  {{- /* Standalone shell — no sidebar, no mobile drawer. Used by the /design
        sandbox so reviewers can focus on the component under test. The page
        body still gets all the global head + footer scripts (Alpine, Lucide,
        cmdk, shortcuts) so x-data interactivity continues to work. */ -}}
  <div class="bg-base-200 min-h-screen">
    <!-- Top bar with brand + theme toggle + back-to-app link, built on
         the native daisy `navbar` component. Sticky so it stays in view
         while the gallery scrolls; the design gallery's anchor links
         and sticky outline both offset for this bar's height.
         The theme toggle is the shared ThemeToggle component, backed by
         $store.theme — selecting light/dark/system persists to
         localStorage just like the sidebar toggle. -->
    <div class="navbar bg-base-100 border-b border-base-300 sticky top-0 z-30 px-4 sm:px-6">
      <div class="navbar-start">
        <a
          href="/design"
          class="inline-flex items-center gap-2 text-sm font-semibold text-base-content no-underline hover:text-primary transition-colors"
        >
          {{ lucide "palette" "w-4 h-4" }}
          <span>Breadbox <span class="text-base-content/40">·</span> Design system</span>
        </a>
      </div>
      <div class="navbar-end gap-1">
        {{renderComponent "ThemeToggle" nil}}
        <a href="/" class="btn btn-ghost btn-sm gap-1.5 text-base-content/60">
          {{ lucide "arrow-left" "w-3.5 h-3.5" }}
          Back to app
        </a>
      </div>
    </div>
    <main class="p-4 sm:p-6 max-w-6xl mx-auto">
      {{template "flash" .}}
      {{if .TemplContent}}{{.TemplContent}}{{else}}{{block "content" .}}{{end}}{{end}}
    </main>
    <footer class="text-center text-xs text-base-content/35 py-6">
      Powered by
      <a href="https://daisyui.com/"
         target="_blank"
         rel="noopener"
         class="hover:text-base-content/60 transition-colors no-underline">daisyUI</a>
    </footer>
  </div>
  {{else}}
  <div class="drawer lg:drawer-open">
    <input id="bb-drawer" type="checkbox" class="drawer-toggle" />

    <!-- Main content -->
    <!-- Mintlify-style surface flip: the content column is the bright
         surface (base-100) and the sidebar is the recessed one (base-200).
         Cards sit on white and read by their border, not a fill contrast. -->
    <div class="drawer-content bg-base-100 flex flex-col min-h-dvh">
      <!-- Desktop topbar — static across every page (hidden < lg, where the
           mobile navbar below takes over). Left: breadcrumb of the current
           section/page. Right: ⌘K search, theme toggle, profile menu. -->
      <header class="bb-topbar">
        {{/* Single location trail — Section / parent / … / current. The
             layout owns this; pages no longer render their own breadcrumb. */}}
        {{renderComponent "TopbarBreadcrumb" .}}
        <div class="flex items-center gap-1.5 shrink-0">
          <button type="button"
                  class="bb-topbar-search"
                  x-data @click="$dispatch('open-cmdk')"
                  aria-label="Search"
                  title="Search">
            {{ lucide "search" "" }}
            <span>Search…</span>
            <span class="ml-auto">{{renderComponent "KbdCombo" (strs "cmd" "k")}}</span>
          </button>
          {{renderComponent "ThemeToggle" nil}}
          {{if .AdminUsername}}{{renderComponent "TopbarUserMenu" .}}{{end}}
        </div>
      </header>
      <!-- Mobile navbar — sticky so it stays at top while scrolling -->
      <div class="bb-mobile-navbar lg:hidden"
           x-data="{ open: false }"
           x-init="$watch('open', v => { var t = document.getElementById('bb-drawer-trigger'); if (t) t.setAttribute('aria-expanded', v ? 'true' : 'false'); });
                   document.getElementById('bb-drawer').addEventListener('change', e => { open = e.target.checked; });">
        <div class="flex-none">
          <label for="bb-drawer"
                 id="bb-drawer-trigger"
                 class="btn btn-square btn-ghost"
                 role="button"
                 tabindex="0"
                 aria-label="Open navigation menu"
                 aria-controls="bb-drawer-sidebar"
                 :aria-expanded="open.toString()">
            {{ lucide "menu" "w-5 h-5" }}
          </label>
        </div>
        <div class="flex-1 min-w-0 flex items-center gap-2">
          {{ brand "breadbox" "w-4 h-4 shrink-0 opacity-70" }}
          <a href="/" class="text-sm font-semibold truncate">Breadbox</a>
        </div>
        <div class="flex-none flex items-center gap-1">
          <button class="btn btn-square btn-ghost"
                  x-data @click="$dispatch('open-cmdk')"
                  aria-label="Search"
                  title="Search">
            {{ lucide "search" "w-5 h-5" }}
          </button>
          {{if .AdminUsername}}{{renderComponent "TopbarUserMenu" .}}{{end}}
        </div>
      </div>
      {{/* Mobile location trail — the desktop topbar is hidden < lg, so the
           breadcrumb rides below the mobile navbar (scrollable) on detail
           pages. Only rendered when the page supplies a trail. */}}
      {{if .Breadcrumbs}}
      <div class="lg:hidden px-4 pt-3">
        {{renderComponent "TopbarBreadcrumbMobile" .}}
      </div>
      {{end}}
      <!-- Page content — grows to fill the column instead of demanding its
           own full viewport height. The column (.drawer-content) is min-h-dvh,
           so the sticky topbar + navbar + this <main> together total exactly
           the viewport; using min-h-screen here instead would stack a full
           100vh BELOW the in-flow sticky topbar and overscroll the page by
           the topbar's height. -->
      <main class="p-4 sm:p-6 flex-1">
        {{template "flash" .}}
        {{- /* TemplContent slot — set by TemplateRenderer.RenderWithTempl to
              render a templ-generated body inside the base layout without
              migrating base.html wholesale. Pages migrated to templ drop
              their old {{define "content"}} block and pass a templ.Component
              instead. See issue #462. */ -}}
        {{if .TemplContent}}{{.TemplContent}}{{else}}{{block "content" .}}{{end}}{{end}}
      </main>
      {{- /* App-wide connect-bank slide-over. Rendered once here (not per
            page) so every "Connect a bank" entry point across the app opens
            the same drawer via $store.drawers.open('connect-bank'). Props
            (provider availability + household members) are resolved at render
            time by the connectDrawer resolver. */ -}}
      {{if .ConnectBankDrawer}}{{renderComponent "ConnectBankDrawer" .ConnectBankDrawer}}{{end}}
    </div>

    <!-- Sidebar -->
    <div class="drawer-side" id="bb-drawer-sidebar">
      <label for="bb-drawer" aria-label="close sidebar" class="drawer-overlay"></label>
      <aside class="bb-sidebar" id="bb-sidebar" aria-label="Main navigation">
        <!-- Brand row. The brand text + the theme/close cluster collapse on
             desktop: the icon-rail hides the text (.bb-sidebar-brand-text)
             and the theme toggle + close button are mobile-only (the desktop
             theme toggle lives in the topbar; close only matters for the
             mobile drawer overlay). -->
        <div class="flex items-center justify-between mb-5">
          <a href="/" class="bb-sidebar-brand !mb-0 !px-0 !py-0">
            {{ brand "breadbox" "w-5 h-5" }}
            <span class="bb-sidebar-brand-text">Breadbox</span>
          </a>
          <div class="flex items-center gap-1 shrink-0 lg:hidden">
            <!-- Theme toggle — shared ThemeToggle component. Cycles
                 system → light → dark via $store.theme, persisting the
                 choice to localStorage; the inline <head> script reapplies
                 it on next load so reloads don't flash the other theme. -->
            {{renderComponent "ThemeToggle" nil}}
            <!-- Close button — only visible on mobile/tablet (< lg) -->
            <label for="bb-drawer"
                   class="btn btn-sm btn-square btn-ghost"
                   aria-label="Close navigation menu">
              {{ lucide "x" "w-4 h-4" }}
            </label>
          </div>
        </div>
        {{template "nav" .}}
      </aside>
    </div>
  </div>
  {{end}}

  {{- /* Settings is a full page now (/settings/*), not a modal overlay.
        Any legacy `open-settings` dispatch — the sidebar user menu, the
        cmdk palette, keyboard shortcuts — navigates to the corresponding
        /settings/<tab> page instead of opening a dialog. One global
        listener keeps every existing dispatcher working untouched. */ -}}
  {{if .IsEditor}}
  <script>
    window.addEventListener('open-settings', function (e) {
      var tab = (e && e.detail && e.detail.tab) || 'account';
      window.location.href = '/settings/' + tab;
    });
  </script>
  {{end}}

  <!-- Global Confirmation Modal -->
  <div x-data="bbConfirmModal()" x-cloak
       @bb-confirm.window="showConfirm($event.detail)"
       @keydown.window.escape="if (visible) cancel()">
    <template x-if="visible">
      <div>
        <div class="bb-confirm-backdrop" @click="cancel()"></div>
        <div class="bb-confirm-dialog" @click.stop>
          <div class="bb-confirm-body">
            <div class="bb-confirm-icon" :class="variant === 'danger' ? 'bg-error/10' : 'bg-warning/10'">
              <i :data-lucide="variant === 'danger' ? 'trash-2' : 'alert-triangle'"
                 :class="variant === 'danger' ? 'w-5 h-5 text-error' : 'w-5 h-5 text-warning'"></i>
            </div>
            <div class="flex-1 min-w-0">
              <h3 class="text-sm font-semibold mb-1" x-text="title"></h3>
              <p class="text-sm text-base-content/50 leading-relaxed" x-text="message"></p>
            </div>
          </div>
          <div class="bb-confirm-actions">
            <button class="btn btn-sm btn-ghost" @click="cancel()" x-text="cancelLabel"></button>
            <button class="btn btn-sm"
                    :class="variant === 'danger' ? 'btn-error' : 'btn-warning'"
                    @click="confirm()"
                    x-text="confirmLabel"
                    x-ref="confirmBtn"></button>
          </div>
        </div>
      </div>
    </template>
  </div>

  {{- /* Global prompt modal — preview / edit Markdown, mounted once and
       opened from anywhere via a window event:
         $dispatch('bb-prompt-modal', { mode, edit, title, subtitle, value, target, onSaved })
       Logic lives in static/js/admin/components/prompt_modal.js. */ -}}
  <dialog x-data="bbPromptModal" id="bb-prompt-modal" x-cloak
          @bb-prompt-modal.window="open($event.detail)"
          @keydown.enter.window="if (($event.metaKey || $event.ctrlKey) && $root.open && mode === 'editable') { $event.preventDefault(); primary(); }"
          @keydown.c.window="if ($root.open && !$event.metaKey && !$event.ctrlKey && !$event.altKey && !['INPUT','TEXTAREA'].includes((document.activeElement || {}).tagName)) { $event.preventDefault(); copy(); }"
          @keydown.escape.window="if ($root.open) { $event.preventDefault(); $event.stopPropagation(); requestClose(); }"
          @cancel="$event.preventDefault()"
          class="modal modal-bottom sm:modal-middle">
    <div class="modal-box relative transition-[max-width] duration-200" :class="expanded ? 'max-w-5xl' : 'max-w-2xl'">
      {{- /* Discard-confirmation overlay (top-layer-safe; a z-index dialog
           would render behind this native modal). Shown when dismissing with
           unsaved edits. */ -}}
      <div x-show="confirmingDiscard" x-cloak
           class="absolute inset-0 z-20 flex items-center justify-center rounded-[inherit] bg-base-100/85 backdrop-blur-sm p-6">
        <div class="max-w-sm text-center">
          <div class="mx-auto mb-3 flex h-10 w-10 items-center justify-center rounded-full bg-error/10">
            {{ lucide "alert-triangle" "w-5 h-5 text-error" }}
          </div>
          <h3 class="text-base font-semibold mb-1">Discard changes?</h3>
          <p class="text-sm text-base-content/60 mb-4">You have unsaved edits to this prompt. Closing will discard them.</p>
          <div class="flex items-center justify-center gap-2">
            <button type="button" class="btn btn-ghost btn-sm" @click="confirmingDiscard = false">Keep editing</button>
            <button type="button" class="btn btn-error btn-sm" @click="close()">Discard</button>
          </div>
        </div>
      </div>
      <div class="flex items-start justify-between gap-3 mb-3">
        <div class="flex items-start gap-2 min-w-0">
          {{ lucide "file-text" "w-5 h-5 mt-0.5 text-base-content/60 shrink-0" }}
          <div class="min-w-0">
            <h3 class="text-base font-semibold leading-tight truncate" x-text="title || 'Prompt'"></h3>
            <p class="text-xs text-base-content/60 mt-0.5" x-show="subtitle" x-text="subtitle"></p>
          </div>
        </div>
        <div class="flex items-center gap-1 shrink-0">
          {{- /* Desktop-only expand toggle — grows the modal to a larger size. */ -}}
          <button type="button" class="btn btn-ghost btn-sm btn-square hidden sm:inline-flex"
                  @click="expanded = !expanded"
                  :aria-label="expanded ? 'Shrink' : 'Expand'"
                  :data-tip="expanded ? 'Shrink' : 'Expand'">
            <span x-show="!expanded">{{ lucide "maximize-2" "w-4 h-4" }}</span>
            <span x-show="expanded" x-cloak>{{ lucide "minimize-2" "w-4 h-4" }}</span>
          </button>
          <button type="button" class="btn btn-ghost btn-sm btn-square" aria-label="Close" @click="requestClose()">
            {{ lucide "x" "w-4 h-4" }}
          </button>
        </div>
      </div>

      {{- /* Editor */ -}}
      <div x-show="view === 'edit'">
        <textarea x-ref="editor" x-model="value"
                  class="textarea textarea-bordered w-full text-sm font-mono leading-relaxed resize-none"
                  :class="expanded ? 'h-[78vh]' : 'h-[55vh]'"
                  placeholder="Write the prompt in Markdown…"></textarea>
      </div>

      {{- /* Preview (server-rendered into .bb-prose). In editable mode the
           preview area is pinned to the editor's height so toggling
           Edit↔Preview — and the brief render spinner — never changes the
           modal height. Preview-only sizes to its content. */ -}}
      <div x-show="view === 'preview'" :class="mode === 'editable' ? (expanded ? 'h-[78vh]' : 'h-[55vh]') : ''">
        <div x-show="loading"
             class="flex items-center gap-2 text-sm text-base-content/60 py-6"
             :class="mode === 'editable' ? 'h-full justify-center' : ''">
          <span class="loading loading-spinner loading-sm"></span>
          Rendering…
        </div>
        <div x-show="!loading" x-ref="previewBody"
             :class="mode === 'editable' ? 'h-full' : (expanded ? 'max-h-[82vh]' : 'max-h-[60vh]')"
             class="bb-prose text-sm bg-base-200 border border-base-300 rounded-xl p-4 overflow-y-auto break-words"></div>
      </div>

      <div class="modal-action mt-4 justify-between">
        {{- /* Bottom-left: single Edit⇄Preview toggle (editable only). The
             label/icon reflects the target view. */ -}}
        <div>
          <template x-if="mode === 'editable'">
            <button type="button" class="btn btn-soft btn-sm gap-1.5" @click="setView(view === 'edit' ? 'preview' : 'edit')">
              <span x-show="view === 'edit'" class="inline-flex items-center gap-1.5">{{ lucide "eye" "w-4 h-4" }}Preview</span>
              <span x-show="view === 'preview'" class="inline-flex items-center gap-1.5">{{ lucide "pencil" "w-4 h-4" }}Edit</span>
            </button>
          </template>
        </div>
        {{- /* Right: [dismiss] · copy · [save] — the dismiss button (Cancel
             when editable, Close when preview-only) is always leftmost. */ -}}
        <div class="flex items-center gap-2">
          <template x-if="mode === 'editable'">
            <button type="button" class="btn btn-ghost btn-sm" @click="requestClose()">Cancel</button>
          </template>
          <button type="button" class="btn btn-ghost btn-sm" x-show="mode === 'preview'" @click="requestClose()">Close</button>
          <button type="button" class="btn btn-sm gap-2" :class="copied ? 'btn-success' : 'btn-soft'" @click="copy()">
            <span x-show="!copied" class="inline-flex items-center gap-2">
              {{ lucide "copy" "w-4 h-4" }}Copy
              {{renderComponent "Kbd" "c"}}
            </span>
            <span x-show="copied" x-cloak class="inline-flex items-center gap-2">
              {{ lucide "check" "w-4 h-4" }}Copied
            </span>
          </button>
          <template x-if="mode === 'editable'">
            <button type="button" class="btn btn-primary btn-sm gap-2" @click="primary()">
              <span x-text="dirty ? saveLabel : 'Done'"></span>
              {{renderComponent "KbdCombo" (strs "cmd" "enter")}}
            </button>
          </template>
        </div>
      </div>
    </div>
    <form method="dialog" class="modal-backdrop" @submit.prevent="requestClose()">
      <button>close</button>
    </form>
  </dialog>

  <!-- Global toast (centered floating pill). Daisy `toast toast-center
       toast-bottom` gives us the centered-bottom placement + flex-col
       stack out of the box; the bottom-8 spacing comes from daisy. Also
       flushes any session-persisted toast (e.g. queued before a full-page
       reload by the bulk action bar), which dispatching bb-toast from a
       DOMContentLoaded handler is too early to deliver reliably. -->
  <div class="toast toast-center toast-bottom z-50 pointer-events-none"
       role="region"
       aria-live="polite"
       aria-label="Notifications"
       x-data="{
         toasts: [],
         nextId: 0,
         add(detail) {
           const id = (typeof crypto !== 'undefined' && crypto.randomUUID)
             ? crypto.randomUUID()
             : ('t-' + (++this.nextId) + '-' + Math.random().toString(36).slice(2));
           // Optional action link (href + linkLabel) and a per-toast duration so
           // a toast carrying something to click stays long enough to act on.
           const duration = detail.duration || 3000;
           const toast = {
             message: detail.message, type: detail.type || 'success', id: id,
             href: detail.href || '', linkLabel: detail.linkLabel || 'View', duration: duration,
           };
           this.toasts.push(toast);
           setTimeout(() => { this.toasts = this.toasts.filter(t2 => t2.id !== id); }, duration + 500);
         },
         dismiss(id) { this.toasts = this.toasts.filter(t2 => t2.id !== id); },
       }"
       @bb-toast.window="add($event.detail)">
    <template x-for="t in toasts" :key="t.id">
      <div x-data="{ show: false }" x-init="$nextTick(() => { show = true; lucide.createIcons({nodes:[$el]}); setTimeout(() => show = false, t.duration) })"
           x-show="show"
           :role="t.type === 'error' ? 'alert' : 'status'"
           x-transition:enter="transition ease-out duration-200"
           x-transition:enter-start="opacity-0 translate-y-2 scale-95"
           x-transition:enter-end="opacity-100 translate-y-0 scale-100"
           x-transition:leave="transition ease-in duration-150"
           x-transition:leave-start="opacity-100 translate-y-0"
           x-transition:leave-end="opacity-0 translate-y-2"
           class="bg-base-100 border border-base-300 rounded-xl shadow-lg px-4 py-2.5 flex items-center gap-2 pointer-events-auto max-w-[calc(100vw-2rem)]">
        <template x-if="t.type === 'success'">{{ lucide "check" "w-4 h-4 text-success shrink-0" }}</template>
        <template x-if="t.type === 'error'">{{ lucide "x-circle" "w-4 h-4 text-error shrink-0" }}</template>
        <template x-if="t.type === 'warning'">{{ lucide "alert-triangle" "w-4 h-4 text-warning shrink-0" }}</template>
        <template x-if="t.type === 'info'">{{ lucide "info" "w-4 h-4 text-info shrink-0" }}</template>
        <span class="text-sm font-medium" x-text="t.message"></span>
        <template x-if="t.href">
          <a :href="t.href"
             x-text="t.linkLabel"
             class="text-sm font-semibold link link-primary no-underline hover:underline shrink-0"></a>
        </template>
        <button type="button"
                class="ml-1 -mr-1 p-1 rounded-lg text-base-content/40 hover:text-base-content hover:bg-base-200 transition-colors shrink-0"
                aria-label="Dismiss notification"
                @click="dismiss(t.id)">
          {{ lucide "x" "w-3.5 h-3.5" }}
        </button>
      </div>
    </template>
  </div>

  {{if .DevModeEnabled}}
  <!-- Developer Mode reporter — an always-on-top bug/task filer. Rendered
       only when an admin has enabled developer mode in Settings → Developer.
       Captures a screenshot + HTML snapshot of the current screen and opens
       a labelled GitHub issue. z-[9998/9999] keeps it above modals/drawers.
       Logic lives in static/js/admin/components/dev_reporter.js. -->
  <div id="bb-dev-reporter"
       x-data="devReporter"
       data-page="{{.CurrentPage}}"
       data-user="{{.AdminUsername}}"
       data-version="{{.AppVersion}}"
       x-cloak>
    <!-- Floating action button -->
    <button type="button"
            x-show="!panelOpen"
            x-transition.opacity
            @click="open()"
            class="fixed bottom-6 right-6 z-[9998] flex items-center justify-center w-12 h-12 rounded-full bg-primary text-primary-content shadow-lg hover:shadow-xl hover:scale-105 active:scale-95 transition-all"
            title="Report a bug or task"
            aria-label="Report a bug or task">
      {{ lucide "bug" "w-5 h-5" }}
    </button>

    <!-- Report panel -->
    <div x-show="panelOpen" x-cloak
         x-transition:enter="transition ease-out duration-200"
         x-transition:enter-start="opacity-0 translate-y-3 scale-95"
         x-transition:enter-end="opacity-100 translate-y-0 scale-100"
         x-transition:leave="transition ease-in duration-150"
         x-transition:leave-start="opacity-100 translate-y-0"
         x-transition:leave-end="opacity-0 translate-y-3"
         @keydown.escape.window="close()"
         class="fixed bottom-6 right-6 z-[9999] w-[min(92vw,24rem)] max-h-[85vh] flex flex-col bg-base-100 border border-base-300 rounded-2xl shadow-2xl overflow-hidden"
         role="dialog" aria-modal="false" aria-label="Report a bug or task">
      <!-- Header -->
      <div class="flex items-center justify-between gap-2 px-4 py-3 border-b border-base-200">
        <div class="flex items-center gap-2 min-w-0">
          {{ lucide "bug" "w-4 h-4 text-primary shrink-0" }}
          <span class="text-sm font-semibold">Report an issue</span>
        </div>
        <button type="button" @click="close()" aria-label="Close"
                class="p-1 rounded-lg text-base-content/40 hover:text-base-content hover:bg-base-200 transition-colors">
          {{ lucide "x" "w-4 h-4" }}
        </button>
      </div>

      <!-- Body -->
      <div class="px-4 py-3 space-y-3 overflow-y-auto">
        <!-- Type -->
        <div class="join w-full">
          <button type="button" @click="type='bug'"
                  class="join-item btn btn-sm flex-1"
                  :class="type==='bug' ? 'btn-primary' : 'btn-ghost border border-base-300'">
            {{ lucide "bug" "w-3.5 h-3.5" }} Bug
          </button>
          <button type="button" @click="type='task'"
                  class="join-item btn btn-sm flex-1"
                  :class="type==='task' ? 'btn-primary' : 'btn-ghost border border-base-300'">
            {{ lucide "check-square" "w-3.5 h-3.5" }} Task
          </button>
        </div>

        <!-- Title -->
        <input type="text" x-model="title" maxlength="160"
               @keydown.enter.prevent
               placeholder="Short summary"
               class="input input-sm w-full"
               x-ref="titleInput">

        <!-- Description -->
        <textarea x-model="description" rows="4"
                  placeholder="What happened, and what did you expect? (optional)"
                  class="textarea textarea-sm w-full leading-snug"></textarea>

        <!-- Privacy + screenshot -->
        <div class="rounded-xl border border-base-200 p-2.5 space-y-2">
          <label class="flex items-start gap-2 cursor-pointer">
            <input type="checkbox" x-model="redact" @change="onRedactChange()"
                   class="toggle toggle-sm mt-0.5">
            <span class="min-w-0">
              <span class="text-xs font-medium flex items-center gap-1">
                {{ lucide "shield-check" "w-3.5 h-3.5 text-success" }} Redact financial data
              </span>
              <span class="block text-xs text-base-content/50 leading-snug">Obfuscates amounts and balances, and masks names and other data, in the screenshot and HTML before anything leaves this instance.</span>
            </span>
          </label>
          <p x-show="!redact" x-cloak class="text-xs text-warning flex items-start gap-1">
            {{ lucide "triangle-alert" "w-3.5 h-3.5 shrink-0 mt-px" }}
            <span>Real financial data will be included in what's sent to GitHub.</span>
          </p>
          <div x-show="capturing"
               class="rounded-lg border border-base-300 bg-base-200/40 h-24 flex flex-col items-center justify-center gap-2 text-xs text-base-content/50">
            <span class="loading loading-spinner loading-sm"></span>
            <span>Preparing screenshot…</span>
          </div>
          <template x-if="screenshot && !capturing">
            <div>
              <div class="relative rounded-lg overflow-hidden border border-base-300">
                <img :src="screenshot" alt="Screenshot preview"
                     class="w-full max-h-32 object-cover object-top">
              </div>
              <label class="flex items-center gap-2 text-xs text-base-content/60 mt-2 cursor-pointer">
                <input type="checkbox" x-model="includeScreenshot" class="checkbox checkbox-xs">
                Attach this screenshot
              </label>
            </div>
          </template>
          <p x-show="captureNote && !capturing" x-text="captureNote" x-cloak
             class="text-xs text-warning"></p>
        </div>

        <!-- Context line -->
        <p class="text-xs text-base-content/40 truncate flex items-center gap-1">
          {{ lucide "map-pin" "w-3 h-3 shrink-0" }}
          <span x-text="pagePath"></span>
        </p>

        <!-- Error -->
        <p x-show="error" x-text="error" x-cloak
           class="text-xs text-error"></p>
      </div>

      <!-- Footer -->
      <div class="flex items-center justify-end gap-2 px-4 py-3 border-t border-base-200">
        <button type="button" @click="close()" class="btn btn-ghost btn-sm">Cancel</button>
        <button type="button" @click="submit()"
                :disabled="!title.trim() || submitting || capturing"
                class="btn btn-primary btn-sm gap-2">
          <span x-show="!submitting && !capturing" class="flex items-center gap-1.5">
            {{ brand "github" "brand-onbtn w-3.5 h-3.5" }} File issue
            {{ lucide "external-link" "w-3 h-3 opacity-70" }}
          </span>
          <span x-show="capturing && !submitting" x-cloak class="flex items-center gap-1.5">
            <span class="loading loading-spinner loading-xs"></span> Preparing…
          </span>
          <span x-show="submitting" x-cloak class="flex items-center gap-1.5">
            <span class="loading loading-spinner loading-xs"></span> Filing…
          </span>
        </button>
      </div>
    </div>
  </div>
  <script src="/static/js/admin/components/dev_reporter.js"></script>
  {{end}}

  <!-- Global prompt modal factory (preview / edit Markdown). -->
  <script src="/static/js/admin/components/prompt_modal.js"></script>

  <!-- Keyboard Shortcuts Help (?-key) — registry-driven, rendered as a
       right-side slide-over (the shared `drawers` store) rather than a centred
       modal so it matches every other sheet in the app and leaves the page
       visible behind it. Body is built from $store.shortcuts grouped by scope
       (this-page vs everywhere) and, within each scope, by the spec's `group`
       field. Sections are (re)built on open via the $watch in x-init below.
       See seedRegistry() for the current bindings. -->
  <div x-data="shortcutsHelp()" class="contents"
       x-init="$watch('$store.drawers.active', function (id) { if (id === 'shortcuts') rebuild(); })">
    <!-- scrim — z-[45] dims the sidebar too (matches components.Drawer) -->
    <div
      x-show="$store.drawers.is('shortcuts')"
      x-cloak
      x-transition:enter="transition-opacity ease-out duration-200"
      x-transition:enter-start="opacity-0"
      x-transition:enter-end="opacity-100"
      x-transition:leave="transition-opacity ease-in duration-150"
      x-transition:leave-start="opacity-100"
      x-transition:leave-end="opacity-0"
      class="fixed inset-0 bg-black/40 z-[45]"
      @click="$store.drawers.close()"
      aria-hidden="true"
    ></div>
    <!-- panel -->
    <div
      x-show="$store.drawers.is('shortcuts')"
      x-cloak
      x-transition:enter="transition ease-out duration-200"
      x-transition:enter-start="translate-x-full"
      x-transition:enter-end="translate-x-0"
      x-transition:leave="transition ease-in duration-150"
      x-transition:leave-start="translate-x-0"
      x-transition:leave-end="translate-x-full"
      @keydown.escape.window="if ($store.drawers.is('shortcuts') && !document.querySelector('dialog[open]')) $store.drawers.close()"
      role="dialog"
      aria-modal="true"
      aria-label="Keyboard shortcuts"
      class="fixed inset-y-0 right-0 z-50 w-full max-w-md bg-base-100 border-l border-base-300 shadow-xl flex flex-col"
    >
      <!-- header (mirrors components.DrawerHeader) -->
      <div class="flex items-center justify-between gap-3 p-5 border-b border-base-300 shrink-0">
        <div class="flex items-center gap-3 min-w-0">
          {{ lucide "keyboard" "w-5 h-5 text-base-content/60 shrink-0" }}
          <div class="font-semibold leading-tight">Keyboard shortcuts</div>
        </div>
        <button type="button" class="btn btn-ghost btn-sm btn-square" @click="$store.drawers.close()" aria-label="Close">
          {{ lucide "x" "w-4 h-4" }}
        </button>
      </div>

      <!-- body -->
      <div class="flex-1 overflow-y-auto px-5 py-4">
        <!-- On this page — shown whenever a page declares a scope, so the user
             always gets feedback (even an empty "nothing here yet"). Tinted
             with a subtle info wash + a small dot so scoped bindings read as
             distinct from the always-on set below. -->
        <template x-if="sections.pageScoped">
          <section>
            <div class="flex items-center justify-between gap-2 mb-2.5">
              <div class="flex items-center gap-2 min-w-0">
                <span class="inline-flex w-1.5 h-1.5 rounded-full bg-info shrink-0"></span>
                <span class="text-[0.65rem] font-semibold uppercase tracking-[0.07em] text-info shrink-0">On this page</span>
                <span class="text-sm font-semibold text-base-content truncate" x-text="sections.pageLabel"></span>
              </div>
              <span class="badge badge-soft badge-info badge-sm shrink-0" x-show="sections.pageCount > 0" x-text="sections.pageCount"></span>
            </div>
            <div class="rounded-xl border border-info/20 px-3.5 py-1" style="background: oklch(from var(--color-info) l c h / 0.05);">
              <template x-if="sections.page.length === 0">
                <div class="py-2.5 text-sm text-base-content/45">No shortcuts unique to this page.</div>
              </template>
              <template x-for="(group, gi) in sections.page" :key="'page-' + group.label">
                <div class="py-2.5" :class="gi > 0 ? 'border-t border-info/15' : ''">
                  <div class="text-[0.6rem] font-semibold uppercase tracking-[0.06em] text-base-content/40 mb-1.5" x-text="group.label"></div>
                  <template x-for="item in group.items" :key="'page-' + item.id">
                    <div class="flex items-center justify-between gap-3 py-1">
                      <span class="text-sm text-base-content/75" x-text="item.description"></span>
                      <span class="flex items-center gap-1.5 shrink-0" x-html="renderTokens(item.tokens)"></span>
                    </div>
                  </template>
                </div>
              </template>
            </div>
          </section>
        </template>

        <!-- Everywhere — the always-on global set. The eyebrow only appears when
             a page section sits above it (otherwise it's the only thing here and
             the label is redundant). -->
        <template x-if="sections.global.length > 0">
          <section :class="sections.pageScoped ? 'mt-6' : ''">
            <div class="flex items-center gap-2 mb-1" x-show="sections.pageScoped">
              <span class="text-[0.65rem] font-semibold uppercase tracking-[0.07em] text-base-content/40">Everywhere</span>
            </div>
            <template x-for="(group, gi) in sections.global" :key="'global-' + group.label">
              <div class="py-2.5" :class="gi > 0 ? 'border-t border-base-300/60' : ''">
                <div class="text-[0.6rem] font-semibold uppercase tracking-[0.06em] text-base-content/40 mb-1.5" x-text="group.label"></div>
                <template x-for="item in group.items" :key="item.id">
                  <div class="flex items-center justify-between gap-3 py-1">
                    <span class="text-sm text-base-content/75" x-text="item.description"></span>
                    <span class="flex items-center gap-1.5 shrink-0" x-html="renderTokens(item.tokens)"></span>
                  </div>
                </template>
              </div>
            </template>
          </section>
        </template>

        <template x-if="sections.global.length === 0 && !sections.pageScoped">
          <div class="py-2 text-sm text-base-content/45">No shortcuts registered.</div>
        </template>
      </div>

      <!-- footer hint (mirrors components.DrawerFooter chrome, info copy) -->
      <div class="px-5 py-3 border-t border-base-300 shrink-0 text-center text-[0.7rem] text-base-content/40">
        Press {{renderComponent "Kbd" "?"}} to toggle · {{renderComponent "Kbd" "esc"}} to close
      </div>
    </div>
  </div>

  <!-- Global Category Picker Overlay -->
  <div x-data="globalCategoryPicker()" x-show="open" x-cloak
       @open-category-picker.window="openPicker($event.detail)"
       @keydown.window.escape="if (open) { closePicker(); $event.stopPropagation(); }">
    <!-- Backdrop -->
    <div class="bb-catpicker-backdrop" @click="closePicker()"></div>
    <!-- Dialog -->
    <div class="bb-catpicker-dialog"
         @click.stop
         @keydown.escape.prevent="closePicker()"
         @keydown.arrow-down.prevent="moveDown()"
         @keydown.arrow-up.prevent="moveUp()"
         @keydown.enter.prevent="selectActive()">
      <!-- Search input -->
      <div class="bb-catpicker-input-wrap">
        {{ lucide "tag" "" }}
        <input type="search"
          enterkeyhint="search"
          class="bb-catpicker-input"
          placeholder="Search categories..."
          x-ref="catPickerInput"
          x-model="search"
          @input="activeIndex = 0"
          autocomplete="off"
          spellcheck="false">
        <div class="bb-kbd" style="font-size:0.6rem" @click="closePicker()">esc</div>
      </div>

      <!-- Results list -->
      <div class="bb-catpicker-list" x-ref="catPickerList"
           :data-searching="search ? 'true' : 'false'"
           x-effect="filteredList; $nextTick(() => { if (typeof lucide !== 'undefined' && $refs.catPickerList) lucide.createIcons({ nodes: [$refs.catPickerList] }); })">
        <div class="py-1">
          <template x-for="(item, idx) in filteredList" :key="item.slug + '-' + idx">
            <div class="bb-catpicker-item"
                 :class="{'bb-catpicker-item--indent': item.indent, 'bb-catpicker-item--parent': item.isParent}"
                 :data-active="activeIndex === idx"
                 :data-catpicker-idx="idx"
                 @click="selectItem(item)"
                 @mouseenter="activeIndex = idx">
              <template x-if="item.color">
                <span class="bb-catpicker-dot" :style="'background-color:' + item.color"></span>
              </template>
              <template x-if="item.icon">
                <i :data-lucide="item.icon" class="bb-catpicker-icon"></i>
              </template>
              <span x-text="item.label" class="truncate"></span>
              <template x-if="item.indent && item.parentLabel">
                <span class="bb-catpicker-item__parent-label text-xs text-base-content/30 ml-auto truncate" x-text="item.parentLabel"></span>
              </template>
            </div>
          </template>
        </div>
        <template x-if="search && filteredList.length === 0">
          <div class="bb-catpicker-empty">
            {{ lucide "search-x" "" }}
            <span class="text-sm">No categories found</span>
          </div>
        </template>
      </div>

      <!-- Footer -->
      <div class="bb-catpicker-footer">
        <div class="flex items-center gap-3">
          <span class="inline-flex items-center gap-1">{{renderComponent "Kbd" "up"}} {{renderComponent "Kbd" "down"}} navigate</span>
          <span class="inline-flex items-center gap-1">{{renderComponent "Kbd" "enter"}} select</span>
          <span class="inline-flex items-center gap-1">{{renderComponent "Kbd" "esc"}} close</span>
        </div>
      </div>
    </div>
  </div>

  <!-- Global Tag Picker Overlay
       Always multi + bulk: the picker edits tags for 1+ transactions at a time.
       Each chip carries its own state (absent / present / mixed) and cycles
       through a pending-add or pending-remove intent on click. Apply emits a
       diff; nothing fires until commit. -->
  <div x-data="globalTagPicker()" x-show="open" x-cloak
       @open-tag-picker.window="openPicker($event.detail)"
       @keydown.window.escape="if (open) { closePicker(); $event.stopPropagation(); }">
    <div class="bb-catpicker-backdrop" @click="closePicker()"></div>
    <div class="bb-tagpicker-dialog"
         @click.stop
         @keydown.escape.prevent="closePicker()"
         @keydown.enter.prevent="enterPressed()">

      <!-- Search / create input -->
      <div class="bb-catpicker-input-wrap gap-2">
        {{ lucide "tag" "" }}
        <input type="search"
          enterkeyhint="search"
          class="bb-catpicker-input"
          placeholder="Filter tags or type to create…"
          x-ref="tagPickerInput"
          x-model="search"
          autocomplete="off"
          spellcheck="false">
        <div class="bb-kbd" style="font-size:0.6rem" @click="closePicker()">esc</div>
      </div>

      <!-- Context strip: which transactions are being edited -->
      <div class="bb-tagpicker-context" x-show="txCount > 0">
        {{ lucide "receipt" "" }}
        <template x-if="txCount === 1">
          <span>Editing tags for <strong>1</strong> transaction</span>
        </template>
        <template x-if="txCount > 1">
          <span>Editing tags for <strong x-text="txCount"></strong> transactions</span>
        </template>
      </div>

      <!-- Body: chip grid + inline create -->
      <div class="bb-tagpicker-body" x-ref="tagPickerBody"
           x-effect="visibleChips; $nextTick(() => { if (typeof lucide !== 'undefined' && $refs.tagPickerBody) lucide.createIcons({ nodes: [$refs.tagPickerBody] }); })">

        <template x-if="visibleChips.length > 0">
          <div class="bb-tagpicker-grid">
            <template x-for="chip in visibleChips" :key="chip.slug">
              <button type="button"
                class="bb-tag bb-tag-interactive bb-tagpicker-chip"
                :class="{
                  'bb-tagpicker-chip--absent':         chip.state === 'absent',
                  'bb-tagpicker-chip--present':        chip.state === 'present',
                  'bb-tagpicker-chip--mixed':          chip.state === 'mixed',
                  'bb-tagpicker-chip--pending-add':    chip.state === 'pending-add',
                  'bb-tagpicker-chip--pending-remove': chip.state === 'pending-remove',
                }"
                :style="chip.color ? ('--tag-color:' + chip.color) : ''"
                @click="cycleChip(chip)"
                x-effect="syncChipGlyph($el, chip.state, chip.icon)"
                :title="chip.display_name && chip.display_name !== chip.slug ? (chip.display_name + ' — ' + chip.slug) : chip.slug">
                <!-- Glyph slot — rewritten imperatively on state change.
                     Can't use <template x-if> + lucide here: lucide replaces
                     <i data-lucide> with <svg>, which Alpine no longer tracks,
                     so the old svg lingers after the condition flips. -->
                <span class="bb-tagpicker-chip-glyph"></span>
                <span class="truncate" x-text="chip.display_name || chip.slug"></span>
              </button>
            </template>
          </div>
        </template>

        <!-- Inline "Create 'slug'" — creates + stages as pending-add, no auto-commit. -->
        <template x-if="canCreate">
          <button type="button" class="bb-tagpicker-create-inline"
            :class="visibleChips.length > 0 && 'mt-3'"
            :disabled="creating"
            @click="createAndStage()">
            {{ lucide "plus" "" }}
            <span>Create</span>
            <code class="font-mono bg-base-100/60 px-1.5 py-0.5 rounded text-[0.7rem]" x-text="normalizedQuery"></code>
            <span x-show="!creating" class="ml-auto text-[0.65rem] text-base-content/40">
              <kbd class="bb-kbd">&crarr;</kbd>
            </span>
            <span x-show="creating" class="ml-auto loading loading-spinner loading-xs"></span>
          </button>
        </template>

        <!-- Empty: no tags yet at all -->
        <template x-if="!search && visibleChips.length === 0 && chips.length === 0">
          <div class="bb-tagpicker-empty">
            {{ lucide "tags" "" }}
            <div class="text-sm">No tags yet</div>
            <div class="text-xs">Type a slug to create the first one.</div>
          </div>
        </template>

        <!-- No match + not a valid slug -->
        <template x-if="search && visibleChips.length === 0 && !canCreate">
          <div class="bb-tagpicker-empty">
            {{ lucide "search-x" "" }}
            <div class="text-sm">No matching tags</div>
            <div class="text-[0.65rem]">Slug must be lowercase letters, numbers, hyphens or colons.</div>
          </div>
        </template>
      </div>

      <!-- Diff strip — only when there's pending work -->
      <div class="bb-tagpicker-diff" x-show="pendingAddCount + pendingRemoveCount > 0">
        <span class="bb-tagpicker-diff-pill bb-tagpicker-diff-pill--add" x-show="pendingAddCount > 0">
          {{ lucide "plus" "" }}
          <span x-text="pendingAddCount"></span>
          <span>to add</span>
        </span>
        <span class="bb-tagpicker-diff-pill bb-tagpicker-diff-pill--remove" x-show="pendingRemoveCount > 0">
          {{ lucide "x" "" }}
          <span x-text="pendingRemoveCount"></span>
          <span>to remove</span>
        </span>
        <button type="button" class="bb-tagpicker-diff-revert ml-auto" @click="revertAll()">Revert</button>
      </div>

      <!-- Footer -->
      <div class="bb-catpicker-footer">
        <div class="flex items-center gap-3 flex-wrap">
          <template x-if="canCreate">
            <span class="inline-flex items-center gap-1">{{renderComponent "Kbd" "enter"}} create</span>
          </template>
          <template x-if="!canCreate">
            <span class="inline-flex items-center gap-1">{{renderComponent "Kbd" "enter"}} apply</span>
          </template>
          <span class="inline-flex items-center gap-1">{{renderComponent "Kbd" "esc"}} cancel</span>
        </div>
        <div class="flex items-center gap-2">
          <button type="button" class="btn btn-ghost btn-xs" @click="closePicker()">Cancel</button>
          <button type="button" class="btn btn-primary btn-xs"
            :disabled="pendingAddCount + pendingRemoveCount === 0"
            @click="apply()">Apply</button>
        </div>
      </div>
    </div>
  </div>

  <!-- Command Palette (Cmd+K) -->
  <div x-data="commandPalette()" x-show="open" x-cloak
       @open-cmdk.window="openPalette()"
       @keydown.window="handleGlobalKey($event)">
    <!-- Backdrop -->
    <div class="bb-cmdk-backdrop" @click="closePalette()"></div>
    <!-- Dialog -->
    <div class="bb-cmdk-dialog" @keydown.escape.prevent="closePalette()" @keydown.arrow-down.prevent="moveDown()" @keydown.arrow-up.prevent="moveUp()" @keydown.enter.prevent="selectActive()">
      <!-- Search input -->
      <div class="bb-cmdk-input-wrap">
        {{ lucide "search" "" }}
        <input type="search"
          enterkeyhint="search"
          class="bb-cmdk-input"
          placeholder="Type a command or search..."
          x-ref="cmdkInput"
          x-model="query"
          @input="onInput()"
          autocomplete="off"
          spellcheck="false">
        <div class="bb-kbd" style="font-size:0.6rem" @click="closePalette()">esc</div>
      </div>

      <!-- Results list -->
      <div class="bb-cmdk-list" x-ref="cmdkList">
        <template x-if="filteredItems.length === 0 && !_txLoading && query.length > 0">
          <div class="bb-cmdk-empty">
            {{ lucide "search-x" "" }}
            <span class="text-sm">No results found</span>
          </div>
        </template>
        <template x-for="(group, gi) in filteredGroups" :key="group.label">
          <div class="bb-cmdk-group">
            <div class="bb-cmdk-group-label" x-text="group.label"></div>
            <!-- Skeleton loaders for transaction search -->
            <template x-if="group._loading">
              <div>
                <div class="bb-cmdk-item" style="pointer-events:none">
                  <div class="bb-cmdk-generic">
                    <div class="bb-cmdk-item-icon"><div class="w-4 h-4 bg-base-300/40 rounded animate-pulse"></div></div>
                    <div class="bb-cmdk-item-label"><div class="h-3.5 bg-base-300/40 rounded w-36 animate-pulse"></div><div class="h-2.5 bg-base-300/30 rounded w-48 mt-1.5 animate-pulse"></div></div>
                  </div>
                </div>
                <div class="bb-cmdk-item" style="pointer-events:none">
                  <div class="bb-cmdk-generic">
                    <div class="bb-cmdk-item-icon"><div class="w-4 h-4 bg-base-300/40 rounded animate-pulse"></div></div>
                    <div class="bb-cmdk-item-label"><div class="h-3.5 bg-base-300/40 rounded w-44 animate-pulse"></div><div class="h-2.5 bg-base-300/30 rounded w-40 mt-1.5 animate-pulse"></div></div>
                  </div>
                </div>
                <div class="bb-cmdk-item" style="pointer-events:none">
                  <div class="bb-cmdk-generic">
                    <div class="bb-cmdk-item-icon"><div class="w-4 h-4 bg-base-300/40 rounded animate-pulse"></div></div>
                    <div class="bb-cmdk-item-label"><div class="h-3.5 bg-base-300/40 rounded w-32 animate-pulse"></div><div class="h-2.5 bg-base-300/30 rounded w-52 mt-1.5 animate-pulse"></div></div>
                  </div>
                </div>
              </div>
            </template>
            <!-- Real items -->
            <template x-for="(item, ii) in group.items" :key="item.id">
              <div class="bb-cmdk-item"
                   :class="item._kind === 'tx' ? 'bb-cmdk-item--tx' : ''"
                   :data-active="activeIndex === item._flatIndex"
                   :data-cmdk-idx="item._flatIndex"
                   @click.prevent="go(item)"
                   @mouseenter="activeIndex = item._flatIndex">
                <!-- Transaction variant: server-rendered TxRowCompact fragment
                     so cmdk shares one component with every other compact-tx
                     surface (dashboard recents, rule preview). Click is
                     prevented on the wrapper and routed through go() which
                     handles the admin progress bar + closePalette. -->
                <template x-if="item._kind === 'tx'">
                  <div x-html="item.html"></div>
                </template>

                <!-- Generic command / action variant -->
                <template x-if="item._kind !== 'tx'">
                  <div class="bb-cmdk-generic">
                    <div class="bb-cmdk-item-icon" :style="item.iconColor ? 'background-color: color-mix(in srgb, ' + item.iconColor + ' 15%, transparent); color: ' + item.iconColor : ''">
                      <i :data-lucide="item.icon"></i>
                    </div>
                    <div class="bb-cmdk-item-label">
                      <div class="bb-cmdk-item-title" x-text="item.title"></div>
                      <template x-if="item.desc">
                        <div class="bb-cmdk-item-desc" x-text="item.desc"></div>
                      </template>
                    </div>
                    <!-- Kbd hint: rendered from $store.shortcuts when the
                         item declares a shortcutId that matches a registered
                         spec. Hidden on touch ($store.device.isTouch) so
                         phones/tablets don't display a key hint that can
                         never fire. Chord tokens render as adjacent pills
                         ('g' 'd') with a small gap — no inline "then" label. -->
                    <template x-if="shortcutTokensFor(item).length > 0">
                      <div class="bb-cmdk-item-shortcut" x-show="!$store.device.isTouch">
                        <template x-for="(tok, ti) in shortcutTokensFor(item)" :key="ti">
                          <span>
                            <!-- Blended combo pill: renders ⌘│K as one tight
                                 badge so modifier combos don't read as two
                                 disconnected keycaps (see .bb-kbd-combo). -->
                            <template x-if="tok.combo">
                              <span class="bb-kbd-combo">
                                <template x-for="(k, ki) in tok.combo" :key="ki">
                                  <span class="bb-kbd-combo__key" x-text="k"></span>
                                </template>
                              </span>
                            </template>
                            <span x-show="!tok.then && !tok.combo" class="bb-kbd" x-text="tok.label"></span>
                          </span>
                        </template>
                      </div>
                    </template>
                  </div>
                </template>
              </div>
            </template>
          </div>
        </template>
      </div>

      <!-- Footer hints -->
      <div class="bb-cmdk-footer">
        <div class="flex items-center gap-3">
          <span class="inline-flex items-center gap-1">{{renderComponent "Kbd" "up"}} {{renderComponent "Kbd" "down"}} navigate</span>
          <span class="inline-flex items-center gap-1">{{renderComponent "Kbd" "enter"}} open</span>
          <span class="inline-flex items-center gap-1">{{renderComponent "Kbd" "esc"}} close</span>
          <span class="inline-flex items-center gap-1">{{renderComponent "Kbd" "?"}} shortcuts</span>
        </div>
      </div>
    </div>
  </div>

  <script>
    // Lucide is loaded via `<script defer>` from `<head>`, so by the time this
    // body script runs the global `lucide` may or may not exist yet — defer
    // scripts execute after HTML parsing finishes. Run createIcons as soon as
    // both the DOM is ready AND lucide is defined.
    (function() {
      function init() { if (typeof lucide !== 'undefined') lucide.createIcons(); }
      if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init);
      } else {
        init();
      }
    })();

    // Auto-render Lucide icons when new elements are added to the DOM.
    // Prevents icons from missing when Alpine.js x-if templates or
    // fetch-based content adds data-lucide elements after initial render.
    //
    // PERF: Lucide's createIcons() replaces `<i data-lucide="x">` with a
    // `<svg data-lucide="x">`, preserving the attribute on the generated node.
    // So a naive `querySelector('[data-lucide]')` guard ALWAYS matches after
    // the first render, creating an infinite replace → mutation → replace loop
    // that ticks every ~50ms forever. It surfaces as window-resize lag,
    // sluggish interactions, and idle CPU churn.
    //
    // Fix: only match unprocessed nodes (`<i>` or `<span>`, never `<svg>`).
    // Also filter mutations: only schedule a sweep if the mutation added a
    // non-SVG node somewhere, so Lucide's own SVG output never re-triggers us.
    (function() {
      var iconTimer = null;
      var UNPROCESSED_SELECTOR = '[data-lucide]:not(svg)';
      new MutationObserver(function(mutations) {
        // Bail if every mutation is Lucide replacing icons with SVGs.
        var sawPotentialIcon = false;
        for (var i = 0; i < mutations.length && !sawPotentialIcon; i++) {
          var added = mutations[i].addedNodes;
          for (var j = 0; j < added.length; j++) {
            var n = added[j];
            if (n.nodeType !== 1) continue;
            if (n.tagName === 'SVG' || n.tagName === 'svg') continue;
            sawPotentialIcon = true;
            break;
          }
        }
        if (!sawPotentialIcon) return;
        if (iconTimer) return;
        iconTimer = setTimeout(function() {
          iconTimer = null;
          if (document.querySelector(UNPROCESSED_SELECTOR)) {
            lucide.createIcons();
          }
        }, 50);
      }).observe(document.body, { childList: true, subtree: true });
    })();

    // Sidebar nav prefetch — instant.page-style hover/touch warmup.
    //
    // When the user lingers on a sidebar nav link for ≥65ms (or starts a
    // touch / focuses it via keyboard), inject <link rel="prefetch"> so
    // the browser preloads the next page's HTML into the HTTP cache. On
    // click, the navigation resolves from cache, eliminating the network
    // round-trip without giving up bfcache, native scroll restoration, or
    // the iOS swipe-back gesture.
    //
    // Chromium also gets the Speculation Rules block in <head>, which is
    // strictly more capable. This listener is the Safari/Firefox path.
    //
    // Scoped to a.bb-sidebar-link only — a bounded set of canonical
    // destinations — so we never speculatively fetch unbounded lists
    // (transaction rows, search results, etc.). Skips cross-origin,
    // current URL, [data-no-prefetch], and save-data connections.
    (function() {
      var conn = navigator.connection || {};
      if (conn.saveData) return;
      var prefetched = new Set();
      function shouldSkip(a) {
        if (!a.href || a.target === '_blank' || a.hasAttribute('download')) return true;
        if (a.dataset.noPrefetch !== undefined) return true;
        try {
          var u = new URL(a.href, location.href);
          if (u.origin !== location.origin) return true;
          if (u.pathname + u.search === location.pathname + location.search) return true;
        } catch (_) { return true; }
        return prefetched.has(a.href);
      }
      function prefetch(url) {
        if (prefetched.has(url)) return;
        prefetched.add(url);
        var link = document.createElement('link');
        link.rel = 'prefetch';
        link.as = 'document';
        link.href = url;
        document.head.appendChild(link);
      }
      var HOVER_DELAY = 65;
      document.addEventListener('mouseover', function(e) {
        var a = e.target.closest('a.bb-sidebar-link');
        if (!a || shouldSkip(a)) return;
        var timer = setTimeout(function() { prefetch(a.href); }, HOVER_DELAY);
        a.addEventListener('mouseleave', function() { clearTimeout(timer); }, { once: true });
      });
      document.addEventListener('touchstart', function(e) {
        var a = e.target.closest('a.bb-sidebar-link');
        if (!a || shouldSkip(a)) return;
        prefetch(a.href);
      }, { passive: true, capture: true });
      document.addEventListener('focusin', function(e) {
        var a = e.target.closest('a.bb-sidebar-link');
        if (!a || shouldSkip(a)) return;
        prefetch(a.href);
      });
    })();

    // settingsTabFromHref returns the modal tab id when `href` points at
    // a Settings page (e.g. "/settings/sync" → "sync", "/settings" →
    // "account"). Returns null for non-settings hrefs. Sub-routes like
    // /settings/api-keys/new aren't a tab — they're a sub-page only
    // reachable from inside the modal — so we return null and let the
    // normal full-page nav handle them.
    var BB_SETTINGS_TABS = ['account','sync','security','system','providers','agents','mcp','api-keys','backups','help'];
    function settingsTabFromHref(href) {
      if (!href) return null;
      // Strip query + hash so /settings?foo=1 still matches.
      var path = String(href).split('?')[0].split('#')[0];
      if (path === '/settings' || path === '/settings/') return 'account';
      if (path.indexOf('/settings/') !== 0) return null;
      var rest = path.slice('/settings/'.length).replace(/\/$/, '');
      // Only the bare tab slug is a modal target — sub-routes like
      // /settings/api-keys/new keep their full-page nav behavior.
      if (rest.indexOf('/') !== -1) return null;
      return BB_SETTINGS_TABS.indexOf(rest) !== -1 ? rest : null;
    }
    window.bbSettingsTabFromHref = settingsTabFromHref;

    // Button loading state helpers for async actions
    window.bbButtonLoading = function(btn) {
      btn._origHTML = btn.innerHTML;
      btn.disabled = true;
      btn.classList.add('pointer-events-none');
      btn.innerHTML = '<span class="loading loading-spinner loading-xs"></span>';
    };
    window.bbButtonDone = function(btn) {
      btn.disabled = false;
      btn.classList.remove('pointer-events-none');
      btn.innerHTML = btn._origHTML;
      lucide.createIcons({ nodes: [btn] });
    };

    // Mobile drawer: auto-close on nav tap + focus trap + aria management (#640)
    //
    // DaisyUI's checkbox-based drawer is visual-only — when opened on mobile it
    // doesn't manage focus, so Tab escapes out to the topbar search button
    // behind the overlay and screen readers can still navigate the page
    // content. This IIFE scopes modal behavior to the drawer while it's open
    // at <lg (<1024px), and leaves the persistent desktop sidebar alone.
    (function() {
      var toggle = document.getElementById('bb-drawer');
      if (!toggle) return;
      var drawerSide = document.getElementById('bb-drawer-sidebar');
      var sidebar = document.getElementById('bb-sidebar');
      var trigger = document.getElementById('bb-drawer-trigger');
      var content = document.querySelector('.drawer-content');
      if (!drawerSide || !sidebar) return;

      var TABBABLE = [
        'a[href]',
        'button:not([disabled])',
        'input:not([disabled]):not([type="hidden"])',
        'select:not([disabled])',
        'textarea:not([disabled])',
        '[tabindex]:not([tabindex="-1"])',
      ].join(',');

      function isMobile() { return window.innerWidth < 1024; }

      function tabbableItems() {
        return Array.prototype.filter.call(
          sidebar.querySelectorAll(TABBABLE),
          function(el) {
            // Skip elements hidden via display:none/visibility
            return el.offsetParent !== null || el === document.activeElement;
          }
        );
      }

      function setInertOutside(on) {
        // Apply inert + aria-hidden to everything outside the drawer-side so
        // the mobile navbar (search button) and main content don't swallow
        // focus or get announced by screen readers.
        [content, document.querySelector('.bb-mobile-navbar')].forEach(function(el) {
          if (!el) return;
          if (on) {
            el.setAttribute('inert', '');
            el.setAttribute('aria-hidden', 'true');
          } else {
            el.removeAttribute('inert');
            el.removeAttribute('aria-hidden');
          }
        });
      }

      function onOpen() {
        if (!isMobile()) return;
        sidebar.setAttribute('role', 'dialog');
        sidebar.setAttribute('aria-modal', 'true');
        if (trigger) trigger.setAttribute('aria-expanded', 'true');
        setInertOutside(true);
        // Move focus into the drawer on next tick so the checkbox-driven
        // CSS transition has started.
        requestAnimationFrame(function() {
          var items = tabbableItems();
          if (items.length) items[0].focus();
        });
      }

      function onClose() {
        var wasModal = sidebar.hasAttribute('aria-modal');
        sidebar.removeAttribute('role');
        sidebar.removeAttribute('aria-modal');
        if (trigger) trigger.setAttribute('aria-expanded', 'false');
        setInertOutside(false);
        // If focus was inside the drawer (e.g. a nav link was clicked on
        // mobile), return it to the trigger so keyboard users don't lose
        // their place. Skip if focus already moved elsewhere intentionally.
        if (wasModal && trigger && (document.activeElement === document.body ||
            sidebar.contains(document.activeElement))) {
          trigger.focus();
        }
      }

      toggle.addEventListener('change', function() {
        if (toggle.checked) onOpen(); else onClose();
      });

      // Tab cycling — trap focus among drawer tabbables while open on mobile.
      document.addEventListener('keydown', function(e) {
        if (!toggle.checked || !isMobile()) return;
        if (e.key === 'Tab') {
          var items = tabbableItems();
          if (!items.length) { e.preventDefault(); return; }
          var first = items[0];
          var last = items[items.length - 1];
          var active = document.activeElement;
          // Focus not inside drawer (e.g. body after click) → pull it in.
          if (!sidebar.contains(active)) {
            e.preventDefault();
            (e.shiftKey ? last : first).focus();
            return;
          }
          if (e.shiftKey && active === first) {
            e.preventDefault();
            last.focus();
          } else if (!e.shiftKey && active === last) {
            e.preventDefault();
            first.focus();
          }
        } else if (e.key === 'Escape') {
          e.preventDefault();
          toggle.checked = false;
          // Manually fire 'change' so our onClose + screen readers update;
          // setting .checked programmatically doesn't dispatch change.
          toggle.dispatchEvent(new Event('change'));
          if (trigger) trigger.focus();
        }
      });

      // On resize across the lg breakpoint, clean up modal state so the
      // persistent desktop sidebar isn't stuck in dialog/inert limbo.
      window.addEventListener('resize', function() {
        if (!isMobile()) {
          sidebar.removeAttribute('role');
          sidebar.removeAttribute('aria-modal');
          if (trigger) trigger.setAttribute('aria-expanded', 'false');
          setInertOutside(false);
        } else if (toggle.checked) {
          onOpen();
        }
      });

      // INTENTIONALLY no auto-close handler on sidebar nav taps. We let the
      // drawer stay open right through the navigation — the new document
      // renders with the drawer closed by default (checkbox state is per-
      // document, doesn't carry across nav), so visually the drawer covers
      // the source page until the new page paints over it. No flash of the
      // source page underneath, no slide-out animation racing the nav.
      //
      // On browsers with cross-document View Transitions (Safari 18.2+ /
      // Chrome 126+), the .bb-sidebar named group (see input.css) makes
      // this even nicer: the browser interpolates the sidebar's bbox from
      // "visible on-screen" (old page) to "translated off-screen" (new
      // page), so the drawer slides out as the main content cross-fades
      // in — synchronized with the actual nav rather than racing it.
      //
      // History: tried defer-until-transitionend (felt slow on prefetched
      // pages) and snap-close (jarring flash of source page underneath).
      // Doing nothing turned out to be the cleanest fix.
      //
      // Non-nav close paths (Esc, tap outside backdrop, close button) are
      // handled by the keydown listener above and DaisyUI's native checkbox
      // toggle — those still animate normally.
    })();

    // iOS 26-style scroll fade for mobile sidebar nav
    (function() {
      var nav = document.querySelector('.bb-sidebar-nav');
      if (!nav) return;
      function update() {
        nav.style.setProperty('--fade-top', nav.scrollTop > 2 ? '1' : '0');
        nav.style.setProperty('--fade-bottom',
          nav.scrollHeight - nav.scrollTop - nav.clientHeight > 2 ? '1' : '0');
      }
      nav.addEventListener('scroll', update, {passive: true});
      // Initial check after icons render
      requestAnimationFrame(function() { requestAnimationFrame(update); });
    })();

    // Global Confirmation Modal
    function bbConfirmModal() {
      return {
        visible: false,
        title: '',
        message: '',
        confirmLabel: 'Confirm',
        cancelLabel: 'Cancel',
        variant: 'danger',
        _resolve: null,
        _onConfirm: null,
        _onCancel: null,

        showConfirm(detail) {
          this.title = detail.title || 'Are you sure?';
          this.message = detail.message || '';
          this.confirmLabel = detail.confirmLabel || 'Confirm';
          this.cancelLabel = detail.cancelLabel || 'Cancel';
          this.variant = detail.variant || 'danger';
          this._resolve = detail.resolve || null;
          this._onConfirm = typeof detail.onConfirm === 'function' ? detail.onConfirm : null;
          this._onCancel = typeof detail.onCancel === 'function' ? detail.onCancel : null;
          this.visible = true;
          var self = this;
          setTimeout(function() {
            if (self.$refs.confirmBtn) self.$refs.confirmBtn.focus();
            var dialog = document.querySelector('.bb-confirm-dialog');
            if (dialog) lucide.createIcons({ nodes: [dialog] });
          }, 50);
        },

        confirm() {
          this.visible = false;
          var resolve = this._resolve, onConfirm = this._onConfirm;
          this._resolve = this._onConfirm = this._onCancel = null;
          if (resolve) resolve(true);
          if (onConfirm) onConfirm();
        },

        cancel() {
          this.visible = false;
          var resolve = this._resolve, onCancel = this._onCancel;
          this._resolve = this._onConfirm = this._onCancel = null;
          if (resolve) resolve(false);
          if (onCancel) onCancel();
        }
      };
    }

    // Helper: returns a Promise that resolves to true/false
    window.bbConfirm = function(opts) {
      return new Promise(function(resolve) {
        window.dispatchEvent(new CustomEvent('bb-confirm', {
          detail: Object.assign({}, opts, { resolve: resolve })
        }));
      });
    };

    // Scroll lock: freeze body in place so modals don't cause scroll-through
    // or viewport jumps when the iOS keyboard opens.
    (function() {
      var savedY = 0;
      var lockCount = 0;
      window.bbLockScroll = function() {
        if (lockCount++ > 0) return; // nested lock
        savedY = window.scrollY;
        document.body.style.position = 'fixed';
        document.body.style.top = '-' + savedY + 'px';
        document.body.style.left = '0';
        document.body.style.right = '0';
        document.body.style.overflow = 'hidden';
      };
      window.bbUnlockScroll = function() {
        if (--lockCount > 0) return; // still locked by another modal
        lockCount = 0;
        document.body.style.position = '';
        document.body.style.top = '';
        document.body.style.left = '';
        document.body.style.right = '';
        document.body.style.overflow = '';
        window.scrollTo({ top: savedY, behavior: 'instant' });
      };
    })();

    // Global Category Picker component
    function globalCategoryPicker() {
      return {
        open: false,
        search: '',
        activeIndex: 0,
        mode: 'assign',
        allowEmpty: true,
        emptyLabel: 'Uncategorized',
        sourceId: '',
        categories: [],
        _resolve: null,

        get flatList() {
          var items = [];
          if (this.allowEmpty) {
            items.push({ id: '', slug: '', label: this.emptyLabel, isParent: false, indent: false, icon: null, color: null, hidden: false });
          }
          for (var i = 0; i < this.categories.length; i++) {
            var cat = this.categories[i];
            // Skip the DB "uncategorized" category when allowEmpty provides its own empty option
            if (this.allowEmpty && cat.slug === 'uncategorized') continue;
            items.push({ id: cat.id, slug: cat.slug, label: cat.display_name, isParent: true, indent: false, icon: cat.icon, color: cat.color, hidden: cat.hidden || false });
            if (cat.children) {
              for (var j = 0; j < cat.children.length; j++) {
                var child = cat.children[j];
                items.push({ id: child.id, slug: child.slug, label: child.display_name, isParent: false, indent: true, icon: child.icon, color: child.color, hidden: child.hidden || false, parentLabel: cat.display_name });
              }
            }
          }
          return items;
        },

        get filteredList() {
          var list = this.flatList;
          if (!this.search) return list.filter(function(i) { return !i.hidden || i.id === ''; });
          var q = this.search.toLowerCase();
          return list.filter(function(i) {
            if (i.hidden && i.id !== '') return false;
            return i.label.toLowerCase().indexOf(q) !== -1 ||
              (i.parentLabel && i.parentLabel.toLowerCase().indexOf(q) !== -1) ||
              (i.slug && i.slug.toLowerCase().indexOf(q) !== -1);
          });
        },

        openPicker(detail) {
          this.mode = detail.mode || 'assign';
          this.allowEmpty = detail.allowEmpty !== false;
          this.emptyLabel = detail.emptyLabel || 'Uncategorized';
          this.sourceId = detail.sourceId || '';
          this.categories = detail.categories || window.__bbCategories || [];
          this.search = '';
          this.activeIndex = 0;
          this.open = true;
          window.bbLockScroll();

          var self = this;
          setTimeout(function() {
            if (self.$refs.catPickerInput) self.$refs.catPickerInput.focus();
            var dialog = document.querySelector('.bb-catpicker-dialog');
            if (dialog) lucide.createIcons({ nodes: [dialog] });
          }, 50);
        },

        closePicker() {
          this.open = false;
          this.search = '';
          window.bbUnlockScroll();
        },

        moveDown() {
          var max = this.filteredList.length - 1;
          if (this.activeIndex < max) this.activeIndex++;
          else this.activeIndex = 0;
          this.scrollToActive();
        },

        moveUp() {
          if (this.activeIndex > 0) this.activeIndex--;
          else this.activeIndex = this.filteredList.length - 1;
          this.scrollToActive();
        },

        scrollToActive() {
          var self = this;
          setTimeout(function() {
            var el = document.querySelector('[data-catpicker-idx="' + self.activeIndex + '"]');
            if (el) el.scrollIntoView({ block: 'nearest' });
          }, 10);
        },

        selectActive() {
          var items = this.filteredList;
          if (items.length > 0 && this.activeIndex < items.length) {
            this.selectItem(items[this.activeIndex]);
          }
        },

        selectItem(item) {
          var value = this.mode === 'filter' ? item.slug : item.id;
          window.dispatchEvent(new CustomEvent('category-picked', {
            detail: { id: item.id, slug: item.slug, label: item.label, value: value, sourceId: this.sourceId }
          }));
          this.closePicker();
        }
      };
    }

    // ── Global Tag Picker ──
    //
    // Always multi + bulk. Edits tags for 1+ transactions through a single shared UX.
    //
    // Opens via `window.dispatchEvent(new CustomEvent('open-tag-picker', { detail: {...} }))`.
    //
    //   sourceId:         string         — echoed in outbound events so listeners can route.
    //   transactionIds:   string[]       — optional; echoed back on commit for caller convenience.
    //   txCount:          number         — count of transactions being edited (drives context strip).
    //                                       Derived from transactionIds.length when not provided.
    //   appliedCounts:    { slug: n }    — how many of the selected txs already have each tag.
    //                                       Drives chip state: n=0 absent, n=txCount present, else mixed.
    //   availableTags:    TagLite[]      — override the global `window.__bbAllTags` snapshot.
    //
    // Outbound events:
    //   tag-selection-commit { sourceId, transactionIds, adds, removes } — Apply pressed.
    //   tag-created          { tag }                                     — quick-created tag.
    //
    // Chip states: absent | present | mixed | pending-add | pending-remove.
    // Click cycle: absent → pending-add → absent; present → pending-remove → present;
    //              mixed → pending-add → pending-remove → mixed.
    //
    // Keyboard: Enter creates (when search is a valid new slug) else applies. Esc cancels.
    function globalTagPicker() {
      var SLUG_RE = /^[a-z0-9]([a-z0-9\-:]*[a-z0-9])?$/;
      // Deterministic color palette for quick-create so the same slug always gets the same color.
      var DEFAULT_COLORS = ['#6366f1','#8b5cf6','#ec4899','#ef4444','#f97316','#eab308','#22c55e','#14b8a6','#06b6d4','#3b82f6','#64748b','#78716c'];

      // Sort priority for stable chip ordering (pre-click original state).
      // Lower = higher in the list.
      var ORDER = {present: 0, mixed: 1, absent: 2};

      return {
        open: false,
        search: '',
        sourceId: '',
        transactionIds: [],
        txCount: 0,
        // Per-chip: {slug, display_name, color, icon, original, state}
        // `original` is one of absent|present|mixed, set on openPicker and never mutated.
        // `state` is one of absent|present|mixed|pending-add|pending-remove, toggled by the user.
        chips: [],
        creating: false,

        get normalizedQuery() {
          return (this.search || '').trim().toLowerCase();
        },

        get visibleChips() {
          var q = this.normalizedQuery;
          if (!q) return this.chips;
          return this.chips.filter(function(c) {
            return (c.slug && c.slug.toLowerCase().indexOf(q) !== -1) ||
                   (c.display_name && c.display_name.toLowerCase().indexOf(q) !== -1) ||
                   (c.description && c.description.toLowerCase().indexOf(q) !== -1);
          });
        },

        get canCreate() {
          var q = this.normalizedQuery;
          if (!q || !SLUG_RE.test(q)) return false;
          return !this.chips.some(function(c) { return c.slug === q; });
        },

        get pendingAddCount() {
          return this.chips.filter(function(c) { return c.state === 'pending-add'; }).length;
        },

        get pendingRemoveCount() {
          return this.chips.filter(function(c) { return c.state === 'pending-remove'; }).length;
        },

        openPicker(detail) {
          detail = detail || {};
          this.sourceId = detail.sourceId || '';
          this.transactionIds = (detail.transactionIds || []).slice();
          this.txCount = typeof detail.txCount === 'number'
            ? detail.txCount
            : this.transactionIds.length;
          var applied = detail.appliedCounts || {};
          var tags = (detail.availableTags && detail.availableTags.slice()) ||
                     (window.__bbAllTags ? window.__bbAllTags.slice() : []);
          var n = this.txCount;
          this.chips = tags.map(function(t) {
            var count = applied[t.slug] || 0;
            var original;
            if (count === 0 || n === 0) original = 'absent';
            else if (count >= n) original = 'present';
            else original = 'mixed';
            return {
              slug: t.slug,
              display_name: t.display_name,
              color: t.color || null,
              icon: t.icon || null,
              description: t.description || '',
              original: original,
              state: original,
            };
          });
          // Sort once on open by original state, then alpha. Chips never move on click.
          this.chips.sort(function(a, b) {
            if (ORDER[a.original] !== ORDER[b.original]) return ORDER[a.original] - ORDER[b.original];
            var an = (a.display_name || a.slug).toLowerCase();
            var bn = (b.display_name || b.slug).toLowerCase();
            return an < bn ? -1 : an > bn ? 1 : 0;
          });
          this.search = '';
          this.open = true;
          window.bbLockScroll && window.bbLockScroll();
          var self = this;
          setTimeout(function() {
            if (self.$refs.tagPickerInput) self.$refs.tagPickerInput.focus();
            var dialog = document.querySelector('.bb-tagpicker-dialog');
            if (dialog && typeof lucide !== 'undefined') lucide.createIcons({ nodes: [dialog] });
          }, 50);
        },

        closePicker() {
          this.open = false;
          this.search = '';
          this.creating = false;
          window.bbUnlockScroll && window.bbUnlockScroll();
        },

        // State cycle. A chip never transitions into a permanent state — the commit
        // happens on Apply, which reads `chips` and derives adds/removes.
        cycleChip(chip) {
          var s = chip.state;
          var o = chip.original;
          if (o === 'absent') {
            chip.state = (s === 'absent') ? 'pending-add' : 'absent';
          } else if (o === 'present') {
            chip.state = (s === 'present') ? 'pending-remove' : 'present';
          } else /* mixed */ {
            // mixed → pending-add → pending-remove → mixed
            if (s === 'mixed') chip.state = 'pending-add';
            else if (s === 'pending-add') chip.state = 'pending-remove';
            else chip.state = 'mixed';
          }
        },

        revertAll() {
          this.chips.forEach(function(c) { c.state = c.original; });
        },

        // Rewrites the chip's glyph slot when state (or its own icon) changes.
        // We can't use <template x-if> with lucide icons here: lucide mutates the
        // <i data-lucide> into <svg>, Alpine loses its node reference, and on
        // condition-flip the stale <svg> is left behind — stacking glyphs.
        // Instead: one stable .bb-tagpicker-chip-glyph span per chip, replaced
        // imperatively so nothing leaks. Build the <i> via createElement so the
        // tag icon (admin-controlled) can't escape the data-lucide attribute.
        syncChipGlyph(el, state, tagIcon) {
          var slot = el.querySelector('.bb-tagpicker-chip-glyph');
          if (!slot) return;
          var icon = null;
          if (state === 'present') icon = 'check';
          else if (state === 'mixed') icon = 'minus';
          else if (state === 'pending-add') icon = 'plus';
          else if (state === 'pending-remove') icon = 'x';
          else if (state === 'absent' && tagIcon && /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(tagIcon)) icon = tagIcon;
          slot.replaceChildren();
          if (!icon) return;
          var i = document.createElement('i');
          i.setAttribute('data-lucide', icon);
          slot.appendChild(i);
          if (typeof lucide !== 'undefined') {
            lucide.createIcons({ nodes: [slot] });
          }
        },

        apply() {
          var adds = [], removes = [];
          this.chips.forEach(function(c) {
            if (c.state === 'pending-add') adds.push(c.slug);
            else if (c.state === 'pending-remove') removes.push(c.slug);
          });
          if (adds.length + removes.length === 0) { this.closePicker(); return; }
          window.dispatchEvent(new CustomEvent('tag-selection-commit', {
            detail: {
              sourceId: this.sourceId,
              transactionIds: this.transactionIds.slice(),
              adds: adds,
              removes: removes,
            }
          }));
          this.closePicker();
        },

        enterPressed() {
          // Priority 1: staging a newly typed slug.
          if (this.canCreate) { this.createAndStage(); return; }
          // Priority 2: single filtered chip — treat Enter as a one-click toggle.
          if (this.search && this.visibleChips.length === 1) {
            this.cycleChip(this.visibleChips[0]);
            return;
          }
          // Priority 3: commit whatever is pending.
          if (this.pendingAddCount + this.pendingRemoveCount > 0) {
            this.apply();
          }
        },

        createAndStage() {
          if (!this.canCreate || this.creating) return;
          var slug = this.normalizedQuery;
          var displayName = slug.split(/[-:]/).map(function(w) {
            return w.charAt(0).toUpperCase() + w.slice(1);
          }).join(' ');
          var hash = 0;
          for (var i = 0; i < slug.length; i++) { hash = (hash * 31 + slug.charCodeAt(i)) | 0; }
          var color = DEFAULT_COLORS[Math.abs(hash) % DEFAULT_COLORS.length];
          var self = this;
          this.creating = true;
          fetch('/-/tags', {
            method: 'POST',
            headers: {'Content-Type': 'application/json'},
            body: JSON.stringify({
              slug: slug, display_name: displayName, description: '',
              color: color, icon: null
            })
          })
          .then(function(r) { return r.json().then(function(d) { return {ok: r.ok, body: d}; }); })
          .then(function(res) {
            self.creating = false;
            if (!res.ok) {
              window.dispatchEvent(new CustomEvent('bb-toast', {detail: {message: (res.body && res.body.error && res.body.error.message) || 'Could not create tag', type: 'error'}}));
              return;
            }
            var t = res.body;
            var tag = {
              id: t.id, slug: t.slug, display_name: t.display_name,
              description: t.description || '', color: t.color || color,
              icon: t.icon || null
            };
            if (window.__bbAllTags && !window.__bbAllTags.some(function(x) { return x.slug === tag.slug; })) {
              window.__bbAllTags.push(tag);
            }
            // Inject the new chip in pending-add state. Original is 'absent' (not applied yet).
            self.chips.push({
              slug: tag.slug,
              display_name: tag.display_name,
              color: tag.color,
              icon: tag.icon,
              description: tag.description,
              original: 'absent',
              state: 'pending-add',
            });
            window.dispatchEvent(new CustomEvent('bb-toast', {detail: {message: 'Created ' + tag.slug, type: 'success'}}));
            window.dispatchEvent(new CustomEvent('tag-created', {detail: {tag: tag}}));
            self.search = '';
          })
          .catch(function() {
            self.creating = false;
            window.dispatchEvent(new CustomEvent('bb-toast', {detail: {message: 'Network error creating tag', type: 'error'}}));
          });
        }
      };
    }

    // Command Palette Alpine component
    function commandPalette() {
      // NB: `shortcutId` points into $store.shortcuts. When an entry matches,
      // the palette row renders a trailing kbd hint (see shortcutTokensFor()
      // below). Matching by id (not inventing new chords) keeps the registry
      // the single source of truth — change a chord in seedRegistry and every
      // palette hint updates automatically.
      var allItems = [
        // Overview
        { id: 'nav-feed', shortcutId: 'global.go.o', group: 'Overview', title: 'Feed', desc: 'Activity feed across the household', icon: 'rss', href: '/', keywords: 'home feed activity dashboard overview' },
        { id: 'nav-transactions', shortcutId: 'global.go.t', group: 'Overview', title: 'Transactions', desc: 'View all transactions', icon: 'receipt', href: '/transactions', keywords: 'payments expenses spending' },
        { id: 'nav-recurring', group: 'Overview', title: 'Recurring', desc: 'Recurring charges and subscriptions', icon: 'repeat', href: '/recurring', keywords: 'subscriptions series recurring charges bills memberships renewals monthly' },
        { id: 'nav-reports', group: 'Overview', title: 'Reports', desc: 'Agent reports and findings', icon: 'file-text', href: '/reports', keywords: 'agent summaries notifications' },
        // Manage
        { id: 'nav-workflows', shortcutId: 'global.go.h', group: 'Manage', title: 'Workflows', desc: 'Agent automations and runs', icon: 'workflow', href: '/workflows', keywords: 'workflows automations agents runs schedule cron presets gallery sdk prompts custom' },
        { id: 'nav-rules', shortcutId: 'global.go.u', group: 'Manage', title: 'Rules', desc: 'Auto-categorization rules', icon: 'list-filter', href: '/rules', keywords: 'automation patterns categorize labels organize' },
        { id: 'nav-categories', shortcutId: 'global.go.a', group: 'Manage', title: 'Categories', desc: 'Category taxonomy', icon: 'folder', href: '/categories', keywords: 'taxonomy labels organize spending' },
        { id: 'nav-tags', group: 'Manage', title: 'Tags', desc: 'Tag transactions and triage review queue', icon: 'tags', href: '/tags', keywords: 'labels needs-review triage annotations' },
        // System (matches the sidebar's System section)
        { id: 'sys-connections', shortcutId: 'global.go.c', group: 'System', title: 'Connections', desc: 'Bank connections and account links', icon: 'link', href: '/connections', keywords: 'banks accounts linked dedup matching' },
        { id: 'sys-users', shortcutId: 'global.go.f', group: 'System', title: 'Household', desc: 'Manage household members', icon: 'users', href: '/household', keywords: 'people family members' },
        { id: 'sys-access', shortcutId: 'global.go.k', group: 'System', title: 'API Keys', desc: 'API keys and OAuth clients', icon: 'shield-check', href: '/settings/api-keys', keywords: 'tokens authentication api keys oauth connector claude access' },
        { id: 'sys-providers', shortcutId: 'global.go.p', group: 'System', title: 'Providers', desc: 'Configure Plaid, Teller, CSV', icon: 'plug', href: '/settings/providers', keywords: 'plaid teller csv bank setup' },
        // Logs is hidden from quick-nav for now (route still live, reachable via feed deep links);
        // restore this entry + the g-chord below to re-list it as a top-level page.
        { id: 'sys-backups', shortcutId: 'global.go.b', group: 'System', title: 'Backups', desc: 'Database backup and restore', icon: 'hard-drive', href: '/settings/backups', keywords: 'backup restore database dump export' },
        { id: 'sys-settings', shortcutId: 'global.go.s', group: 'System', title: 'Settings', desc: 'Sync schedule, security, system', icon: 'settings', href: '/settings', keywords: 'schedule password encryption' },
        { id: 'sys-design', shortcutId: 'global.go.d', group: 'System', title: 'Design system', desc: 'DaisyUI + bb-* component sandbox', icon: 'palette', href: '/design', keywords: 'sandbox gallery components ui daisyui tailwind preview' },
        // Quick Actions
        { id: 'act-connect', shortcutId: 'global.new.c', group: 'Quick Actions', title: 'Connect a Bank', desc: 'Add a new bank connection', icon: 'plus-circle', href: '/connections/new', keywords: 'add new link' },
        { id: 'act-import', shortcutId: 'global.new.i', group: 'Quick Actions', title: 'Import CSV', desc: 'Import transactions from CSV', icon: 'upload', href: '/connections/import-csv', keywords: 'upload file' },
        { id: 'act-api-key', shortcutId: 'global.new.k', group: 'Quick Actions', title: 'Create API Key', desc: 'Generate a new API key', icon: 'key-round', href: '/settings/api-keys', keywords: 'new token' },
        { id: 'act-user', shortcutId: 'global.new.u', group: 'Quick Actions', title: 'Add Member', desc: 'Add a new household member', icon: 'user-plus', href: '/household/new', keywords: 'new person family' },
        // Theme actions (no href) — drive $store.theme directly. `act-theme`
        // is the smart toggle: it always flips to a visibly different theme
        // (system ↔ opposite-of-OS) so a toggle is never a no-op. The three
        // explicit actions set a specific preference. shortcutId on the toggle
        // links to the '*' binding seeded in seedRegistry() so the palette row
        // shows the same keycap hint as the help modal (and the same behavior).
        { id: 'act-theme', shortcutId: 'global.theme.toggle', group: 'Quick Actions', title: 'Toggle theme', desc: 'Flip between system and its opposite', icon: 'sun-moon', action: function() { if (window.Alpine && Alpine.store('theme')) Alpine.store('theme').toggle(); }, keywords: 'theme dark light mode appearance toggle system color scheme night day switch' },
        { id: 'act-theme-light', group: 'Quick Actions', title: 'Light theme', desc: 'Always use the light theme', icon: 'sun', action: function() { if (window.Alpine && Alpine.store('theme')) Alpine.store('theme').set('light'); }, keywords: 'theme light mode appearance bright day color scheme set' },
        { id: 'act-theme-dark', group: 'Quick Actions', title: 'Dark theme', desc: 'Always use the dark theme', icon: 'moon', action: function() { if (window.Alpine && Alpine.store('theme')) Alpine.store('theme').set('dark'); }, keywords: 'theme dark mode appearance night color scheme set' },
        { id: 'act-theme-system', group: 'Quick Actions', title: 'System theme', desc: 'Match your OS appearance', icon: 'monitor', action: function() { if (window.Alpine && Alpine.store('theme')) Alpine.store('theme').set('system'); }, keywords: 'theme system auto os appearance default color scheme automatic set' },
        // Privacy actions (no href) — drive $store.privacy directly, mirroring
        // the theme block above. `act-privacy` is the smart cycle
        // (real → obfuscate → hide); its shortcutId links to the 'shift+p'
        // binding in seedRegistry() so the palette row shows the same keycap
        // hint as the help modal. The three explicit actions jump straight to a
        // specific mode (the palette is the only place you can land directly on
        // Hide without cycling).
        { id: 'act-privacy', shortcutId: 'global.privacy.cycle', group: 'Quick Actions', title: 'Toggle privacy mode', desc: 'Cycle real → obfuscate → hide', icon: 'shield', action: function() { if (window.Alpine && Alpine.store('privacy')) Alpine.store('privacy').cycle(); }, keywords: 'privacy obfuscate hide blur censor mask redact conceal sensitive screenshot demo screen share shoulder surf fake matrix hex' },
        { id: 'act-privacy-real', group: 'Quick Actions', title: 'Show real data', desc: 'Turn privacy mode off', icon: 'eye', action: function() { if (window.Alpine && Alpine.store('privacy')) Alpine.store('privacy').set('real'); }, keywords: 'privacy real show reveal unmask actual disable off normal clear' },
        { id: 'act-privacy-obfuscate', group: 'Quick Actions', title: 'Obfuscate data', desc: 'Replace values with obvious fakes', icon: 'venetian-mask', action: function() { if (window.Alpine && Alpine.store('privacy')) Alpine.store('privacy').set('obfuscate'); }, keywords: 'privacy obfuscate fake matrix hex scramble mask demo screenshot sensitive glitch' },
        { id: 'act-privacy-hide', group: 'Quick Actions', title: 'Hide data', desc: 'Blur out sensitive values', icon: 'eye-off', action: function() { if (window.Alpine && Alpine.store('privacy')) Alpine.store('privacy').set('hide'); }, keywords: 'privacy hide blur censor redact conceal hidden sensitive screenshot' },
      ];

      // Turn a spec's `keys` into flat tokens for rendering:
      //   'cmd+k'               → [{combo:['⌘','K']}]
      //   '?'                   → [{label:'?'}]
      //   { chord: ['g','d'] }  → [{label:'g'}, {then:true}, {label:'d'}]
      // Symbol mapping goes through the shared `bbKbdGlyph` helper defined
      // at the top of this file so the palette, help modal, and server-
      // rendered Kbd/KbdCombo components all speak the same glyph vocabulary.
      function shortcutKeyTokens(keys) {
        if (!keys) return [];
        if (keys.chord && Array.isArray(keys.chord)) {
          var out = [];
          keys.chord.forEach(function(k, i) {
            if (i > 0) out.push({ then: true });
            out.push({ label: bbKbdGlyph(k) });
          });
          return out;
        }
        // Array form collapses into a blended pill — the keys are meant to
        // be pressed simultaneously so visually they belong in one badge.
        if (Array.isArray(keys)) {
          return [{ combo: keys.map(function(k) { return bbKbdGlyph(k); }) }];
        }
        if (typeof keys === 'string') {
          if (keys.indexOf('+') !== -1 && keys.length > 1) {
            return [{
              combo: keys.split('+').map(function(part) {
                return bbKbdGlyph(part);
              }),
            }];
          }
          return [{ label: bbKbdGlyph(keys) }];
        }
        return [];
      }

      return {
        open: false,
        query: '',
        activeIndex: 0,
        _allItems: allItems,
        _txResults: [],
        _txLoading: false,
        _txTimer: null,
        _txAbort: null,

        // Lookup the key tokens for a palette item's registered shortcut.
        // Returns [] when the item has no `shortcutId`, the shortcuts store
        // hasn't loaded yet, or the id doesn't match. The template renders
        // nothing when the array is empty, so missing bindings silently fall
        // through.
        shortcutTokensFor: function(item) {
          if (!item || !item.shortcutId) return [];
          var store = (window.Alpine && Alpine.store('shortcuts')) || null;
          if (!store || !store.items) return [];
          var spec = store.items.find(function(s) { return s.id === item.shortcutId; });
          if (!spec) return [];
          return shortcutKeyTokens(spec.keys);
        },

        get filteredItems() {
          var commandItems;
          if (!this.query.trim()) {
            commandItems = this._allItems;
          } else {
            var q = this.query.toLowerCase().trim();
            var terms = q.split(/\s+/);
            commandItems = this._allItems.filter(function(item) {
              var haystack = (item.title + ' ' + (item.desc || '') + ' ' + (item.keywords || '')).toLowerCase();
              return terms.every(function(term) { return haystack.indexOf(term) !== -1; });
            });
          }
          return commandItems.concat(this._txResults);
        },

        get filteredGroups() {
          var items = this.filteredItems;
          var groups = [];
          var seen = {};
          var flatIndex = 0;
          items.forEach(function(item) {
            if (!seen[item.group]) {
              seen[item.group] = { label: item.group, items: [] };
              groups.push(seen[item.group]);
            }
            var clone = Object.assign({}, item, { _flatIndex: flatIndex });
            seen[item.group].items.push(clone);
            flatIndex++;
          });
          // Add skeleton group while loading
          if (this._txLoading && !seen['Transactions']) {
            groups.push({ label: 'Transactions', items: [], _loading: true });
          }
          return groups;
        },

        openPalette() {
          this.open = true;
          this.query = '';
          this.activeIndex = 0;
          this._txResults = [];
          this._txLoading = false;
          window.bbLockScroll();
          var self = this;
          setTimeout(function() {
            if (self.$refs.cmdkInput) {
              self.$refs.cmdkInput.focus();
            }
            lucide.createIcons({ nodes: [document.querySelector('.bb-cmdk-dialog')] });
          }, 50);
        },

        closePalette() {
          this.open = false;
          this.query = '';
          this._txResults = [];
          this._txLoading = false;
          window.bbUnlockScroll();
          if (this._txTimer) clearTimeout(this._txTimer);
          if (this._txAbort) this._txAbort.abort();
        },

        handleGlobalKey(e) {
          if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
            e.preventDefault();
            if (this.open) {
              this.closePalette();
            } else {
              this.openPalette();
            }
          }
        },

        onInput() {
          this.activeIndex = 0;
          var self = this;
          setTimeout(function() {
            var dialog = document.querySelector('.bb-cmdk-dialog');
            if (dialog) lucide.createIcons({ nodes: [dialog] });
          }, 10);

          // Debounced transaction search
          if (this._txTimer) clearTimeout(this._txTimer);
          if (this._txAbort) this._txAbort.abort();

          var q = this.query.trim();
          if (q.length < 2) {
            this._txResults = [];
            this._txLoading = false;
            return;
          }

          this._txLoading = true;
          this._txTimer = setTimeout(function() {
            self._fetchTransactions(q);
          }, 300);
        },

        _fetchTransactions(q) {
          var self = this;
          var controller = new AbortController();
          this._txAbort = controller;

          fetch('/-/search/transactions?q=' + encodeURIComponent(q), { signal: controller.signal })
            .then(function(res) { return res.json(); })
            .then(function(items) {
              self._txLoading = false;
              self._txResults = items.map(function(tx) {
                return {
                  id: 'tx-' + tx.id,
                  group: 'Transactions',
                  _kind: 'tx',
                  title: tx.name,
                  href: tx.href,
                  keywords: '',
                  // Pre-rendered TxRowCompact HTML; injected via x-html.
                  html: tx.html
                };
              });
              setTimeout(function() {
                var dialog = document.querySelector('.bb-cmdk-dialog');
                if (dialog) lucide.createIcons({ nodes: [dialog] });
              }, 10);
            })
            .catch(function(err) {
              if (err.name !== 'AbortError') {
                self._txLoading = false;
                self._txResults = [];
              }
            });
        },

        moveDown() {
          var max = this.filteredItems.length - 1;
          if (this.activeIndex < max) this.activeIndex++;
          else this.activeIndex = 0;
          this.scrollToActive();
        },

        moveUp() {
          if (this.activeIndex > 0) this.activeIndex--;
          else this.activeIndex = this.filteredItems.length - 1;
          this.scrollToActive();
        },

        scrollToActive() {
          var self = this;
          setTimeout(function() {
            var el = document.querySelector('[data-cmdk-idx="' + self.activeIndex + '"]');
            if (el) el.scrollIntoView({ block: 'nearest' });
          }, 10);
        },

        selectActive() {
          var items = this.filteredItems;
          if (items.length > 0 && this.activeIndex < items.length) {
            this.go(items[this.activeIndex]);
          }
        },

        go(item) {
          // Action items run a callback instead of navigating (e.g. the
          // theme toggle). Run it, then close — no href, no page transition.
          if (typeof item.action === 'function') {
            item.action();
            this.closePalette();
            return;
          }
          if (item.href) {
            // Settings URLs open the modal in-place instead of full-page
            // navigating to the empty host shell — keeps the user on
            // their current page and feels like a true modal.
            var settingsTab = settingsTabFromHref(item.href);
            if (settingsTab) {
              window.dispatchEvent(new CustomEvent('open-settings', { detail: { tab: settingsTab } }));
              this.closePalette();
              return;
            }
            if (window.bbProgress) {
              window.bbProgress.start();
              window.bbProgress.fadeMainOut();
            }
            window.location.href = item.href;
          }
          this.closePalette();
        }
      };
    }

    // Shortcuts Help Panel — registry-driven.
    //
    // Reads from $store.shortcuts every time the panel opens, groups entries
    // by scope ('global' vs current page scope) and then by the spec's
    // `group` field. Entries with visible === false are skipped. Keys are
    // formatted into lightweight tokens ({ label } for a keycap, { then: true }
    // for the "then" separator) so the template can render them with a single
    // x-html pass.
    function shortcutsHelp() {
      // Preferred ordering for group labels within each section. Unknown
      // groups fall to the end alphabetically.
      var GROUP_ORDER = ['Actions', 'Go to', 'New', 'Navigation', 'Selection'];

      function orderGroups(a, b) {
        var ia = GROUP_ORDER.indexOf(a);
        var ib = GROUP_ORDER.indexOf(b);
        if (ia === -1 && ib === -1) return a.localeCompare(b);
        if (ia === -1) return 1;
        if (ib === -1) return -1;
        return ia - ib;
      }

      // Escape text before it goes through x-html. Keep it minimal — we only
      // emit user-authored descriptions/labels from the registry.
      function esc(s) {
        return String(s == null ? '' : s)
          .replace(/&/g, '&amp;').replace(/</g, '&lt;')
          .replace(/>/g, '&gt;').replace(/"/g, '&quot;');
      }

      // Turn a spec's `keys` into a flat list of render tokens:
      //   [{ label: 'g' }, { then: true }, { label: 'd' }]
      //   [{ combo: ['⌘', 'K'] }]                         ← modifier combo
      // Supports: single string ('?', '/', 'c'), modifier combo ('cmd+k',
      // 'ctrl+shift+p'), array-of-one (['c']), and chord ({ chord: ['g','d'] }).
      // Combos become a single `combo` token so the renderer can emit one
      // blended pill (.bb-kbd-combo) instead of adjacent standalone kbds.
      function toTokens(keys) {
        if (!keys) return [];
        // Chord: sequential keys rendered as adjacent pills (gap-1 by CSS).
        // No separator — the gap reads as "press in sequence".
        if (keys.chord && Array.isArray(keys.chord)) {
          return keys.chord.map(function(k) {
            return { label: esc(bbKbdGlyph(k)) };
          });
        }
        // Array form — treat as simultaneous chord rendered as a single
        // blended pill so the keys read as a unit rather than two
        // standalone keycaps side-by-side.
        if (Array.isArray(keys)) {
          return [{ combo: keys.map(function(k) { return esc(bbKbdGlyph(k)); }) }];
        }
        if (typeof keys === 'string') {
          // Modifier combo like 'cmd+k' or 'ctrl+shift+p'. Plus as a shortcut
          // for itself (a lone '+') is not something we register today.
          if (keys.indexOf('+') !== -1 && keys.length > 1) {
            return [{
              combo: keys.split('+').map(function(part) {
                return esc(bbKbdGlyph(part));
              }),
            }];
          }
          return [{ label: esc(bbKbdGlyph(keys)) }];
        }
        return [];
      }

      function renderTokens(tokens) {
        if (!tokens || !tokens.length) return '';
        return tokens.map(function(t) {
          if (t.combo) {
            return '<span class="bb-kbd-combo">' +
              t.combo.map(function(k) {
                return '<span class="bb-kbd-combo__key">' + k + '</span>';
              }).join('') +
              '</span>';
          }
          return '<kbd class="bb-kbd">' + t.label + '</kbd>';
        }).join('');
      }

      // Derive a display label for a scope id. We don't maintain an explicit
      // scope→name map — treat the id as kebab-case and titlecase the first
      // word so 'transactions' → 'Transactions', 'transaction-detail' →
      // 'Transaction detail'. Falls back to 'This page' if the scope is empty
      // or equals 'global'.
      function scopeLabel(scope) {
        if (!scope || scope === 'global') return 'This page';
        var parts = String(scope).split('-');
        if (!parts.length) return 'This page';
        parts[0] = parts[0].charAt(0).toUpperCase() + parts[0].slice(1);
        return parts.join(' ');
      }

      // Build the section data structure consumed by the template.
      // Returns:
      //   { global:    [{label, items: [{id, description, tokens}]}, ...],
      //     page:      [...],            // empty when no page-scoped shortcuts
      //     pageLabel: 'Transactions',   // display name for the current scope
      //     pageScoped: true,            // current scope is a page (not global)
      //     pageCount:  3 }              // total visible page-scoped shortcuts
      // `page` excludes entries whose scope is 'global'. `pageScoped` drives
      // whether the "On this page" section renders at all — when true but
      // `page` is empty, the section still shows with an empty-state line so
      // the user gets feedback rather than a silently-missing section.
      function buildSections() {
        var store = (window.Alpine && Alpine.store('shortcuts')) || null;
        var empty = { global: [], page: [], pageLabel: 'This page', pageScoped: false, pageCount: 0 };
        if (!store) return empty;
        var scope = store.currentScope || 'global';
        var all = store.forScope(scope) || [];
        var byScope = { global: {}, page: {} };
        var pageCount = 0;

        all.forEach(function(spec) {
          if (!spec || spec.visible === false) return;
          var bucket = spec.scope === 'global' ? 'global' : 'page';
          var group = spec.group || 'Other';
          if (!byScope[bucket][group]) byScope[bucket][group] = [];
          byScope[bucket][group].push({
            id: spec.id,
            description: spec.description || spec.id,
            tokens: toTokens(spec.keys)
          });
          if (bucket === 'page') pageCount++;
        });

        function flatten(buckets) {
          return Object.keys(buckets)
            .sort(orderGroups)
            .map(function(label) { return { label: label, items: buckets[label] }; });
        }

        return {
          global: flatten(byScope.global),
          page: flatten(byScope.page),
          pageLabel: scopeLabel(scope),
          pageScoped: scope !== 'global',
          pageCount: pageCount
        };
      }

      return {
        sections: { global: [], page: [], pageLabel: 'This page', pageScoped: false, pageCount: 0 },
        renderTokens: renderTokens,
        // Rebuild the section data — called by the x-init $watch whenever the
        // shortcuts drawer is opened, so newly registered shortcuts (late-init
        // pages, dynamic scope changes) surface immediately. Visibility +
        // scroll-lock + Escape are owned by the shared `drawers` store and the
        // drawer markup; this factory only supplies the rendered content.
        rebuild: function() {
          this.sections = buildSections();
        }
      };
    }

    // Navigation Progress Bar
    (function() {
      var bar = document.getElementById('bb-nav-progress');
      var fill = document.getElementById('bb-nav-progress-fill');
      if (!bar || !fill) return;

      var progress = 0;
      var tickTimer = null;
      var isActive = false;

      function start() {
        if (isActive) return;
        isActive = true;
        progress = 0;
        fill.style.width = '0%';
        fill.classList.remove('bb-progress-bar__fill--done');
        bar.classList.remove('bb-progress-bar--fade');
        bar.classList.add('bb-progress-bar--active');

        // Quick initial jump
        setTimeout(function() { setProgress(15); }, 50);
        setTimeout(function() { setProgress(30); }, 200);

        // Slow trickle
        tickTimer = setInterval(function() {
          if (progress < 85) {
            progress += (85 - progress) * 0.03;
            fill.style.width = progress + '%';
          }
        }, 200);
      }

      function setProgress(val) {
        progress = val;
        fill.style.width = val + '%';
      }

      function finish() {
        if (tickTimer) { clearInterval(tickTimer); tickTimer = null; }
        fill.classList.add('bb-progress-bar__fill--done');
        isActive = false;

        // Always restore main content interactivity.
        var main = document.querySelector('main');
        if (main) {
          main.style.opacity = '';
          main.style.filter = '';
          main.style.pointerEvents = '';
          main.style.transition = '';
        }

        setTimeout(function() {
          bar.classList.add('bb-progress-bar--fade');
          setTimeout(function() {
            bar.classList.remove('bb-progress-bar--active', 'bb-progress-bar--fade');
            fill.classList.remove('bb-progress-bar__fill--done');
            fill.style.width = '0%';
          }, 500);
        }, 150);
      }

      // Soft page-out fade applied to <main> before a full-page navigation.
      //
      // Centralized so the click handler, the command palette, and the
      // chord dispatcher all fade identically. The blur is deliberately
      // skipped whenever a fixed Settings rail is on the page: a CSS
      // `filter` on an ancestor turns that ancestor into the containing
      // block for its position:fixed descendants, which would rip the
      // viewport-anchored rail out of place and park it mid-screen for the
      // duration of the fade (regression once Settings grew a full-page
      // fixed rail). `opacity` creates no containing block, so opacity-only
      // leaves the rail pinned.
      function fadeMainOut() {
        var main = document.querySelector('main');
        if (!main) return;
        main.style.transition = 'opacity 0.15s ease, filter 0.15s ease';
        main.style.opacity = '0.6';
        main.style.pointerEvents = 'none';
        if (!main.querySelector('.bb-settings-rail')) {
          main.style.filter = 'blur(1px)';
        }
      }

      // Expose globally
      window.bbProgress = { start: start, finish: finish, setProgress: setProgress, fadeMainOut: fadeMainOut };

      // Intercept navigation clicks (sidebar links, breadcrumbs, any internal link)
      document.addEventListener('click', function(e) {
        // Don't intercept if the event was already handled (e.g. Alpine @click.prevent)
        if (e.defaultPrevented) return;

        var link = e.target.closest('a[href]');
        if (!link) return;

        var href = link.getAttribute('href');
        // Only intercept internal same-origin links
        if (!href || href.startsWith('#') || href.startsWith('javascript:') ||
            href.startsWith('http://') && !href.startsWith(window.location.origin) ||
            href.startsWith('https://') && !href.startsWith(window.location.origin)) return;

        // Don't intercept if modifier keys are held (new tab, etc.)
        if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;

        // Don't intercept if target is _blank
        if (link.target === '_blank') return;

        // Skip same-page links
        if (href === window.location.pathname + window.location.search) return;

        // Start the progress bar for this navigation
        start();

        // Subtle content fade during navigation
        fadeMainOut();

        // Mobile sidebar: optimistically move the active marker to the
        // clicked tab and show a trailing spinner. The drawer intentionally
        // stays open through the navigation (see the mobile-drawer IIFE
        // above), so without this the previously-active tab keeps its bold
        // highlight until the new page paints — feels unresponsive.
        //
        // The spinner is an inline SVG rotated by a CSS @keyframes
        // animation, *not* daisy's `loading loading-spinner`. Daisy 5's
        // spinner relies on SMIL `<animateTransform>` inside a
        // `mask-image: url("data:…")` data URI, and Chromium / WebKit
        // rasterize mask SVGs once — the SMIL animation never ticks, and
        // at frame 0 the stroke-dasharray is `0,150` (zero visible
        // length), so the spinner appears as an empty box that "freezes"
        // the instant it's added. A CSS transform animation on an inline
        // SVG runs on the compositor and stays alive through the
        // navigation window until paint-holding replaces the document.
        if (window.innerWidth < 1024 && link.classList.contains('bb-sidebar-link')) {
          var sbNav = link.closest('.bb-sidebar-nav');
          if (sbNav) {
            // Remember the server-rendered active link the first time we
            // mutate, so pagehide can restore it before bfcache snapshots.
            var origActive = sbNav.querySelector('.bb-sidebar-link--active');
            if (origActive && !sbNav.querySelector('[data-bb-was-active]')) {
              origActive.setAttribute('data-bb-was-active', '');
            }
            sbNav.querySelectorAll('.bb-sidebar-link--active').forEach(function(el) {
              el.classList.remove('bb-sidebar-link--active');
            });
            sbNav.querySelectorAll('.bb-sidebar-loading').forEach(function(el) {
              el.remove();
            });
            link.classList.add('bb-sidebar-link--active');
            var spinner = document.createElement('span');
            spinner.className = 'bb-sidebar-loading ml-auto';
            spinner.setAttribute('aria-hidden', 'true');
            spinner.innerHTML = '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-dasharray="42 150"/></svg>';
            link.appendChild(spinner);
          }
        }
      });

      // Handle form submissions
      document.addEventListener('submit', function(e) {
        // A handler that called preventDefault() is taking over client-side —
        // no navigation is coming, so starting the nav bar would leave it
        // trickling forever (e.g. the connect wizard's step transition).
        if (e.defaultPrevented) return;
        var form = e.target;
        if (form && form.method && form.method.toLowerCase() === 'get') {
          start();
        }
      });

      // Finish on page load (covers back/forward navigation via bfcache)
      window.addEventListener('pageshow', function(e) {
        if (isActive) finish();
        // Restore main content if page was served from bfcache
        if (e.persisted) {
          var main = document.querySelector('main');
          if (main) {
            main.style.opacity = '';
            main.style.filter = '';
            main.style.pointerEvents = '';
            main.style.transition = '';
          }
        }
      });

      // Before unload — ensure bar stays visible
      window.addEventListener('beforeunload', function() {
        if (isActive) setProgress(90);
      });

      // pagehide fires before the document enters bfcache. Roll back the
      // mobile-sidebar optimistic state so a back-navigation restoring this
      // page from cache shows the correct (server-rendered) active tab and
      // no leftover spinner.
      window.addEventListener('pagehide', function() {
        document.querySelectorAll('.bb-sidebar-nav .bb-sidebar-loading').forEach(function(el) {
          el.remove();
        });
        document.querySelectorAll('.bb-sidebar-nav [data-bb-was-active]').forEach(function(el) {
          var nav = el.closest('.bb-sidebar-nav');
          if (nav) {
            nav.querySelectorAll('.bb-sidebar-link--active').forEach(function(other) {
              other.classList.remove('bb-sidebar-link--active');
            });
          }
          el.classList.add('bb-sidebar-link--active');
          el.removeAttribute('data-bb-was-active');
        });
      });
    })();

    // Global Keyboard Shortcuts — registry-driven dispatcher.
    //
    // Reads `$store.shortcuts` (see head) and routes keydown events against
    // registered specs. This PR (M0.2) keeps the observable behavior identical
    // to the previous hardcoded switch: `?` opens help, `/` opens cmdk, `Cmd+K`
    // toggles cmdk, `g+<x>` navigates, `n+<x>` opens new-entity pages. The
    // help modal and command palette continue to render from their hardcoded
    // lists; they will be swapped to read from the registry in M0.3 and M0.5.
    (function() {
      var COMBO_WINDOW = 800; // ms to type second key in a chord
      var pendingPrefix = null;
      var prefixTimeout = null;

      // "Go to" navigation — source of truth for `g+<x>` chords. Still exported
      // as plain maps so M0.5 can reuse them to seed palette entries.
      var goRoutes = {
        o: { path: '/',                   label: 'Go to Feed' },
        t: { path: '/transactions',       label: 'Go to Transactions' },
        c: { path: '/connections',        label: 'Go to Connections' },
        r: { path: '/reviews',            label: 'Go to Reviews' },
        u: { path: '/rules',              label: 'Go to Rules' },
        a: { path: '/categories',         label: 'Go to Categories' },
        // g+v / g+l → /logs disabled while Logs is hidden from top-level nav; restore with the sidebar link.
        b: { path: '/settings/backups',   label: 'Go to Backups' },
        s: { path: '/settings',           label: 'Go to Settings' },
        p: { path: '/settings/providers', label: 'Go to Providers' },
        m: { path: '/settings/mcp',       label: 'Go to MCP Settings' },
        k: { path: '/settings/api-keys',  label: 'Go to API Keys' },
        f: { path: '/household', label: 'Go to Household' },
        h: { path: '/agents',             label: 'Go to Agents' },
        d: { path: '/design',             label: 'Go to Design system' }
      };

      var newRoutes = {
        c: { path: '/connections/new',         label: 'New Connection' },
        i: { path: '/connections/import-csv',  label: 'Import CSV' },
        k: { path: '/settings/api-keys',       label: 'API Keys' },
        u: { path: '/household/new',  label: 'New Household Member' }
      };

      // Soft page-out before a chord-triggered full-page nav. Matches the
      // previous behavior in the hardcoded dispatcher byte-for-byte.
      //
      // Settings URLs are special-cased: the chord opens the settings
      // modal in-place instead of navigating to the empty host page,
      // which would feel jarring after a single keystroke.
      function navigateTo(path) {
        var settingsTab = settingsTabFromHref(path);
        if (settingsTab) {
          window.dispatchEvent(new CustomEvent('open-settings', { detail: { tab: settingsTab } }));
          return;
        }
        if (window.bbProgress) {
          window.bbProgress.start();
          window.bbProgress.fadeMainOut();
        }
        window.location.href = path;
      }

      function isInputFocused() {
        var el = document.activeElement;
        if (!el) return false;
        var tag = el.tagName.toLowerCase();
        if (tag === 'input' || tag === 'textarea' || tag === 'select') return true;
        if (el.contentEditable === 'true') return true;
        return false;
      }

      function isOverlayOpen() {
        // Command palette open?
        var cmdkEl = document.querySelector('[x-data="commandPalette()"]');
        if (cmdkEl && cmdkEl.style.display !== 'none' && cmdkEl.__x && cmdkEl.__x.$data && cmdkEl.__x.$data.open) return true;
        // Any slide-over drawer (incl. the keyboard-shortcuts help, now a
        // drawer) is modal — suppress page/global single-key shortcuts behind
        // it. The drawer owns its own Escape-to-close, so this just stops a
        // stray `j`/`g`/etc. from acting on the page underneath.
        var drawers = window.Alpine && Alpine.store('drawers');
        if (drawers && drawers.active) return true;
        // Also check by visibility of the cmdk backdrop (covers the brief
        // window before Alpine state settles).
        var backdrops = document.querySelectorAll('.bb-cmdk-backdrop');
        for (var i = 0; i < backdrops.length; i++) {
          if (backdrops[i].offsetParent !== null) return true;
        }
        return false;
      }

      function clearPrefix() {
        pendingPrefix = null;
        if (prefixTimeout) {
          clearTimeout(prefixTimeout);
          prefixTimeout = null;
        }
      }

      // ----- Registry seed -----
      //
      // Populated once on alpine:init (deferred via setTimeout so the keydown
      // listener below doesn't depend on Alpine being ready — any unregistered
      // keys simply fall through to the legacy-less no-op path).
      function seedRegistry() {
        if (!window.Alpine || !Alpine.store('shortcuts')) return;
        var reg = Alpine.store('shortcuts');

        reg.register({
          id: 'global.help',
          keys: '?',
          description: 'Toggle keyboard shortcuts',
          group: 'Actions',
          scope: 'global',
          action: function() {
            if (window.Alpine && Alpine.store('drawers')) Alpine.store('drawers').open('shortcuts');
          }
        });

        reg.register({
          id: 'global.cmdk.slash',
          keys: '/',
          description: 'Open command palette',
          group: 'Actions',
          scope: 'global',
          visible: false, // `cmd+k` is the canonical binding shown in help
          action: function() { window.dispatchEvent(new CustomEvent('open-cmdk')); }
        });

        reg.register({
          id: 'global.cmdk',
          keys: 'cmd+k',
          description: 'Open command palette',
          group: 'Actions',
          scope: 'global',
          // Actual toggle is wired in commandPalette()'s @keydown.window handler
          // so it can close the palette when already open. Listed here only
          // so the help modal / palette can show the binding.
          visible: true
        });

        // Esc is handled by per-overlay Alpine `@keydown.window.escape` rather
        // than this dispatcher, but the help modal should still advertise it.
        // visible:true + no action keeps it display-only.
        reg.register({
          id: 'global.esc',
          keys: 'Esc',
          description: 'Close dialog / cancel',
          group: 'Actions',
          scope: 'global',
          visible: true
        });

        // Toggle theme: flip between system and the opposite of the OS theme
        // (always a visible change — never the cycle()'s system→light→dark
        // no-op). Bound to 'shift+*' — on a US layout '*' is itself Shift+8,
        // so the keycap reads "⇧ *" which is clearer than a bare "*". The
        // dispatcher rejects Cmd/Ctrl/Alt but allows Shift and tries a
        // 'shift+<key>' spec before plain single-key matching, so Shift+8
        // (which the browser folds into e.key='*', shiftKey=true) matches
        // here. Mirrors the command palette's "Toggle theme" action — keep
        // them on the same store method.
        reg.register({
          id: 'global.theme.toggle',
          keys: 'shift+*',
          description: 'Toggle theme (system ↔ opposite)',
          group: 'Actions',
          scope: 'global',
          action: function() { if (Alpine.store('theme')) Alpine.store('theme').toggle(); }
        });

        // Cycle privacy mode: real → obfuscate → hide → real. Bound to
        // 'shift+p' (a bare 'p' would collide with page-level single-key
        // bindings); the dispatcher folds Shift into e.key and tries the
        // 'shift+<key>' spec first, same path as the theme toggle above.
        reg.register({
          id: 'global.privacy.cycle',
          keys: 'shift+p',
          description: 'Cycle privacy (real → obfuscate → hide)',
          group: 'Actions',
          scope: 'global',
          action: function() { if (Alpine.store('privacy')) Alpine.store('privacy').cycle(); }
        });

        Object.keys(goRoutes).forEach(function(k) {
          var r = goRoutes[k];
          reg.register({
            id: 'global.go.' + k,
            keys: { chord: ['g', k] },
            description: r.label,
            group: 'Go to',
            scope: 'global',
            action: (function(path) { return function() { navigateTo(path); }; })(r.path)
          });
        });

        Object.keys(newRoutes).forEach(function(k) {
          var r = newRoutes[k];
          reg.register({
            id: 'global.new.' + k,
            keys: { chord: ['n', k] },
            description: r.label,
            group: 'New',
            scope: 'global',
            action: (function(path) { return function() { navigateTo(path); }; })(r.path)
          });
        });
      }

      document.addEventListener('alpine:init', function() {
        // Wait for the shortcuts store from head to register before seeding.
        setTimeout(seedRegistry, 0);
      });

      // ----- Match helpers -----
      //
      // Returns the first spec whose `keys` matches the given single key within
      // the active scope set (current scope + optional parent scope + global).
      // Chord specs are skipped here — chord resolution happens once we have a
      // pending prefix.
      //
      // Precedence when the same key is bound at multiple scopes:
      //   currentScope > parentScope > global
      // This lets a page shadow a global binding (transactions rebinding `/`
      // to focus the in-page search input) and lets an inheriting scope
      // shadow one of its parent's bindings (reviews layering `a` approve
      // on top of the transactions list's j/k/Enter/c).
      function matchSingle(reg, key) {
        var scope = reg.currentScope;
        var parent = reg.parentScope;
        var parentMatch = null;
        var globalMatch = null;
        for (var i = 0; i < reg.items.length; i++) {
          var s = reg.items[i];
          if (!reg.scopeActive(s.scope)) continue;
          var k = s.keys;
          var matches = (typeof k === 'string' && k === key) ||
            (Array.isArray(k) && k.length === 1 && k[0] === key);
          if (!matches) continue;
          if (s.scope === scope && scope !== 'global') return s;
          if (parent && s.scope === parent && !parentMatch) parentMatch = s;
          if (s.scope === 'global' && !globalMatch) globalMatch = s;
        }
        return parentMatch || globalMatch;
      }

      function matchChord(reg, prefix, key) {
        for (var i = 0; i < reg.items.length; i++) {
          var s = reg.items[i];
          if (!reg.scopeActive(s.scope)) continue;
          var k = s.keys;
          if (k && k.chord && k.chord.length === 2 && k.chord[0] === prefix && k.chord[1] === key) return s;
        }
        return null;
      }

      function hasChordStartingWith(reg, prefix) {
        var scope = reg.currentScope;
        var parent = reg.parentScope;
        // If the active page scope (or its parent) has a single-key binding
        // for this exact key, the page wins — don't start a chord sequence.
        // This lets a page rebind a key that's otherwise a global chord
        // prefix (e.g. the rules list using `n` for "New rule" instead of
        // `n+_`), and lets an inherited binding do the same.
        for (var j = 0; j < reg.items.length; j++) {
          var ps = reg.items[j];
          if (ps.scope === 'global') continue;
          if (ps.scope !== scope && (!parent || ps.scope !== parent)) continue;
          var pk = ps.keys;
          var pageMatch = (typeof pk === 'string' && pk === prefix) ||
            (Array.isArray(pk) && pk.length === 1 && pk[0] === prefix);
          if (pageMatch) return false;
        }
        for (var i = 0; i < reg.items.length; i++) {
          var s = reg.items[i];
          if (!reg.scopeActive(s.scope)) continue;
          var k = s.keys;
          if (k && k.chord && k.chord.length >= 1 && k.chord[0] === prefix) return true;
        }
        return false;
      }

      document.addEventListener('keydown', function(e) {
        // Cmd+K / Ctrl+K is handled by the command-palette Alpine component so
        // it can toggle the palette when already open. Everything else in this
        // dispatcher is modifier-free.
        if (e.ctrlKey || e.metaKey || e.altKey) return;
        if (isInputFocused()) return;

        // `?` toggles the shortcuts drawer: when it's already the active
        // overlay, a second `?` closes it (the open path is the normal
        // dispatch below). Handled before the isOverlayOpen() gate, which
        // would otherwise swallow the keystroke while the drawer is open.
        var drawersStore = window.Alpine && Alpine.store('drawers');
        if (e.key === '?' && drawersStore && drawersStore.active === 'shortcuts') {
          e.preventDefault();
          drawersStore.close();
          return;
        }

        if (isOverlayOpen()) return;

        var reg = (window.Alpine && Alpine.store('shortcuts')) || null;
        if (!reg) return;

        var key = e.key;

        // Touch device guard — page-level specs don't fire on touch, and
        // two-key chord prefixes (`g+_`, `n+_`) are suppressed entirely since
        // users can't reasonably type chords on a soft keyboard. Single-key
        // globals (`?`, `/`, Cmd+K, Esc) still run — they're non-destructive
        // and occasionally reachable from mobile hardware keyboards.
        var isTouch = (Alpine.store('device') || {}).isTouch === true;

        function shouldFire(spec) {
          if (!spec) return false;
          if (spec.when && !spec.when()) return false;
          if (isTouch && spec.scope !== 'global') return false;
          return true;
        }

        // Chord resolution: if we have a pending prefix, match second key now.
        // On touch the prefix shouldn't have been acquired (see below), but
        // bail defensively if it somehow was.
        if (pendingPrefix) {
          if (isTouch) {
            clearPrefix();
            return;
          }
          var prefix = pendingPrefix;
          clearPrefix();
          var chordSpec = matchChord(reg, prefix, key);
          if (shouldFire(chordSpec) && typeof chordSpec.action === 'function') {
            e.preventDefault();
            chordSpec.action();
          }
          return;
        }

        // Start a chord sequence if any registered spec begins with this key.
        // Skipped on touch so `g`/`n` don't swallow a keystroke waiting for a
        // second key that will never come — and the chord itself is unusable
        // on mobile anyway.
        if (!isTouch && hasChordStartingWith(reg, key)) {
          pendingPrefix = key;
          prefixTimeout = setTimeout(function() {
            pendingPrefix = null;
            prefixTimeout = null;
          }, COMBO_WINDOW);
          return;
        }

        // Shift-modified single-key match (e.g. 'shift+r', 'shift+*').
        // Cmd/Ctrl/Alt are still rejected up top; Shift is folded into e.key
        // by the browser — letters arrive uppercased (shift+r → 'R', so we
        // lowercase), symbols arrive as the shifted glyph (shift+8 → '*').
        // Specs that opt in via 'shift+<key>' are tried first, then fall
        // through to plain single-key matching so existing '/' and '?'
        // bindings (which carry no 'shift+' spec) still work as before.
        if (e.shiftKey && key.length === 1) {
          var modSpec = matchSingle(reg, 'shift+' + key.toLowerCase());
          if (shouldFire(modSpec) && typeof modSpec.action === 'function') {
            e.preventDefault();
            modSpec.action();
            return;
          }
        }

        // Single-key match.
        var spec = matchSingle(reg, key);
        if (shouldFire(spec) && typeof spec.action === 'function') {
          e.preventDefault();
          spec.action();
        }
      });
    })();
  </script>
</body>
</html>
{{end}}
