Skip to content

Admin Global Search (Prototype) Implementation Plan โ€‹

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Replace the admin's per-table filter box with a global command-palette search that finds any record across the whole dataset and jumps straight to it.

Architecture: A declarative SEARCH_SOURCES registry (same "edit one object" ethos as SCHEMA) lists every searchable collection and how to surface + open each hit. A pure ranking core (matchAndRank) does substring matching with title-weighted ranking. A runSearch() aggregator applies node-scope gating, ranks per source, and caps groups. An omnibox UI renders grouped results under the existing topbar box with full keyboard control.

Tech Stack: Vanilla ES (browser globals, inline onclick), DaisyUI/Tailwind classes for chrome, bespoke.css for the .adm-omni* layer. Node (no framework) for unit-testing the one pure module. Browser preview for DOM verification โ€” the repo has no test runner.

Global Constraints โ€‹

  • Work entirely inside design-system/Dispatch Design System - v2/ โ€” run all commands from design-system/. This plan does not touch apps/web (that is a later, separate plan).
  • No framework / no reactive state โ€” vanilla JS, inline onclick="", direct DOM manipulation, to match admin.js.
  • Match bespoke.css idiom โ€” px and rgba() are used throughout that file; use them as the surrounding .adm-* rules do. Use existing tokens where they exist: --color-base-100, --line, --r-pill, --r-md, --ink, --grey, --paper, --neon, --serif, --sans.
  • Bump cache-busters when behaviour changes: index.html pins ds-base.js?v=30 and admin.js?v=32; a new search-core.js is added. The ?v= propagates HTML โ†’ ds-base โ†’ app-shell โ†’ CSS. Track final values in STATUS.md.
  • Terminology: a task with an assignee is a dispatch, but the persisted entity/table stays tasks. Search group label for that source is "Tasks".
  • Node-scope: a node lead sees only their own node's people; a Principal sees all. Reuse the existing inScope(r) predicate (admin.js:475) โ€” do not reimplement it.

Task 1: Pure ranking core (matchAndRank) โ€‹

Files:

  • Create: design-system/Dispatch Design System - v2/templates/admin/search-core.js
  • Test: design-system/Dispatch Design System - v2/templates/admin/search.test.js

Interfaces:

  • Consumes: nothing.

  • Produces: matchAndRank(items, q, accessors) -> Array where accessors = { title:(r)=>string, fields:(r)=>string }. Returns the subset of items that match q (case-insensitive substring on fields), ordered best-first by a title-weighted score (exact title > title-prefix > title-substring > fields-only). Returns [] for an empty/whitespace query. Exposed as window.matchAndRank in the browser and module.exports.matchAndRank in Node.

  • [ ] Step 1: Write the failing test

Create search.test.js:

js
// Minimal zero-dependency test harness โ€” run with: node search.test.js
const { matchAndRank } = require('./search-core.js');

let pass = 0, fail = 0;
function eq(actual, expected, name){
  const a = JSON.stringify(actual), e = JSON.stringify(expected);
  if (a === e) { pass++; }
  else { fail++; console.error(`FAIL ${name}\n  expected ${e}\n  got      ${a}`); }
}

const people = [
  { id:1, name:'Sarah Laurent', email:'sarah@x.io' },
  { id:2, name:'Marc Tremblay', email:'marc@x.io' },
  { id:3, name:'Lรฉa Fontaine',  email:'lea@x.io'  },
  { id:4, name:'Sara Connor',   email:'sc@x.io'   },
];
const acc = { title:r=>r.name, fields:r=>`${r.name} ${r.email}` };

// empty query -> nothing
eq(matchAndRank(people, '',   acc).map(r=>r.id), [], 'empty query returns []');
eq(matchAndRank(people, '   ', acc).map(r=>r.id), [], 'whitespace query returns []');

// substring match, case-insensitive
eq(matchAndRank(people, 'sara', acc).map(r=>r.id), [4,1], 'prefix "Sara Connor" outranks substring "Sarah"');
eq(matchAndRank(people, 'LAURENT', acc).map(r=>r.id), [1], 'case-insensitive, matches non-title field span');

// fields-only match ranks below title matches
eq(matchAndRank(people, 'marc@x', acc).map(r=>r.id), [2], 'email-only match still returned');

// exact title beats prefix
const dup = [{id:10,name:'Claims'},{id:11,name:'Claims Review'}];
eq(matchAndRank(dup, 'claims', {title:r=>r.name, fields:r=>r.name}).map(r=>r.id), [10,11], 'exact title first, then prefix');

console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);
  • [ ] Step 2: Run test to verify it fails

Run: node "design-system/Dispatch Design System - v2/templates/admin/search.test.js" Expected: FAIL โ€” Cannot find module './search-core.js'.

  • [ ] Step 3: Write minimal implementation

Create search-core.js:

js
/* search-core.js โ€” pure, framework-free search ranking.
   Loaded as a plain global before admin.js (browser); require-able in Node for tests.
   No DOM, no DB references โ€” keep it pure so it stays unit-testable. */
function matchAndRank(items, q, accessors){
  const query = String(q == null ? '' : q).toLowerCase().trim();
  if (!query) return [];
  const title  = accessors.title;
  const fields = accessors.fields;
  const scored = [];
  for (const r of items){
    const t = String(title(r)  || '').toLowerCase();
    const h = String(fields(r) || '').toLowerCase();
    let score = 0;
    if      (t === query)            score = 4;
    else if (t.startsWith(query))    score = 3;
    else if (t.includes(query))      score = 2;
    else if (h.includes(query))      score = 1;
    if (score) scored.push({ r, score });
  }
  // Array.sort is stable in modern engines: equal scores keep input order.
  scored.sort((a, b) => b.score - a.score);
  return scored.map(s => s.r);
}

if (typeof window !== 'undefined') window.matchAndRank = matchAndRank;
if (typeof module !== 'undefined' && module.exports) module.exports = { matchAndRank };
  • [ ] Step 4: Run test to verify it passes

Run: node "design-system/Dispatch Design System - v2/templates/admin/search.test.js" Expected: 6 passed, 0 failed (exit 0).

  • [ ] Step 5: Commit
bash
git add "design-system/Dispatch Design System - v2/templates/admin/search-core.js" "design-system/Dispatch Design System - v2/templates/admin/search.test.js"
git commit -m "feat(admin): pure search ranking core for global search (GAC-NN)"

Task 2: Search source registry + runSearch aggregator โ€‹

Files:

  • Modify: design-system/Dispatch Design System - v2/templates/admin/admin.js (add a new section after the ENTITIES declaration near line 276)

Interfaces:

  • Consumes: matchAndRank (Task 1); existing globals DB, inScope, goTo, openProfile, openMandatePage (admin.js).

  • Produces:

    • SEARCH_SOURCES โ€” array of { key, group, coll:()=>rows, fields:(r)=>string, title:(r)=>string, sub:(r)=>string, scope:(r)=>bool|undefined, open:(r)=>void }.
    • runSearch(query) -> Array<{ key, group, rows:Array<{src, r}>, overflow:number }>. Empty groups omitted; each group capped at 5 with overflow = count beyond the cap.
  • [ ] Step 1: Add the registry and aggregator

Insert into admin.js immediately after the const ENTITIES = [...] line (~line 276):

js
/* โ”€โ”€ GLOBAL SEARCH โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
   One declarative registry of everything searchable. Add a source =
   push one object (mirrors the SCHEMA-driven philosophy). Each source
   says how to match, how to label a hit, and where clicking it lands.
   Node-scope is honoured via `scope` (reusing inScope), so a node lead
   never surfaces out-of-node people. */
const SEARCH_CAP = 5;
function userSkillRows(){
  const out = [];
  (DB.users||[]).forEach(u => (u.skills||[]).forEach(s => out.push({ u, s })));
  return out;
}
const SEARCH_SOURCES = [
  { key:'orgs', group:'Organizations', coll:()=>DB.orgs,
    fields:r=>`${r.name} ${r.contract||''} ${r.type||''} ${r.notes||''}`, title:r=>r.name,
    sub:r=>`${r.type||'โ€”'} ยท ${r.status||''}`, open:r=>goTo('orgs') },
  { key:'mandates', group:'Mandates', coll:()=>DB.mandates,
    fields:r=>`${r.title} ${r.org||''} ${r.assignee||''} ${r.scope||''}`, title:r=>r.title,
    sub:r=>`${r.org||'โ€”'} ยท ${r.assignee||'Unassigned'}`, open:r=>openMandatePage(r.id) },
  { key:'tasks', group:'Tasks', coll:()=>DB.tasks,
    fields:r=>`${r.title} ${r.mandate||''} ${r.assignee||''} ${r.status||''}`, title:r=>r.title,
    sub:r=>`${r.mandate||'โ€”'} ยท ${r.status||''}`, open:r=>goTo('tasks') },
  { key:'votes', group:'Proposals', coll:()=>DB.votes,
    fields:r=>`${r.title} ${r.ref||''} ${r.detail||''}`, title:r=>r.title,
    sub:r=>`${r.ref||''} ยท ${r.status||''}`, open:r=>goTo('votes') },
  { key:'users', group:'Members', coll:()=>DB.users,
    fields:r=>`${r.name} ${r.email||''} ${r.function||''} ${r.role||''}`, title:r=>r.name,
    sub:r=>`${r.role||'โ€”'} ยท ${r.function||'โ€”'}`, scope:r=>inScope(r),
    open:r=>{ goTo('users'); openProfile(r.id); } },
  { key:'skills', group:'Skills', coll:()=>DB.skills,
    fields:r=>`${r.name} ${r.category||''}`, title:r=>r.name,
    sub:r=>r.category||'โ€”', open:r=>goTo('skills') },
  { key:'userskills', group:'Member skills', coll:userSkillRows,
    fields:o=>`${o.s.tag} ${o.u.name}`, title:o=>o.s.tag,
    sub:o=>o.u.name, scope:o=>inScope(o.u),
    open:o=>{ goTo('users'); openProfile(o.u.id); } },
  { key:'candidates', group:'Recruitment', coll:()=>DB.candidates,
    fields:r=>`${r.name} ${r.email||''} ${r.position||''} ${r.referredBy||''}`, title:r=>r.name,
    sub:r=>r.position||'โ€”', open:r=>goTo('dashboard') },
  { key:'referrals', group:'Referrals', coll:()=>DB.referrals,
    fields:r=>`${r.referrer||''} ${r.referred||''} ${r.sponsorMP||''}`, title:r=>r.referred,
    sub:r=>`referred by ${r.referrer||'โ€”'}`, open:r=>goTo('dashboard') },
  { key:'nodes', group:'Nodes', coll:()=>DB.nodes,
    fields:r=>`${r.name} ${r.region||''} ${r.lead||''}`, title:r=>r.name,
    sub:r=>`${r.region||'โ€”'} ยท lead ${r.lead||'โ€”'}`, open:r=>goTo('dashboard') },
  { key:'admins', group:'Admins', coll:()=>DB.admins||[],
    fields:r=>`${r.name||''} ${r.email||''}`, title:r=>r.name||r.email,
    sub:r=>r.email||'', open:r=>goTo('admins') },
];
function runSearch(query){
  const q = String(query||'').toLowerCase().trim();
  if (!q) return [];
  const out = [];
  for (const src of SEARCH_SOURCES){
    const pool = src.coll().filter(r => !src.scope || src.scope(r));
    const ranked = matchAndRank(pool, q, { title: src.title, fields: src.fields });
    if (!ranked.length) continue;
    out.push({
      key: src.key, group: src.group,
      rows: ranked.slice(0, SEARCH_CAP).map(r => ({ src, r })),
      overflow: Math.max(0, ranked.length - SEARCH_CAP),
    });
  }
  return out;
}
  • [ ] Step 2: Verify in the browser console

Open design-system/Dispatch Design System - v2/templates/admin/index.html, sign in through the SSO gate, open DevTools console, and run:

js
runSearch('sarah').map(g => [g.group, g.rows.length])

Expected: an array including ["Members", 1] (Sarah Laurent) and ["Tasks", N] (tasks assigned to / titled with Sarah). runSearch('claims').map(g=>g.group) includes "Mandates", "Tasks", and "Skills". runSearch('') returns [].

  • [ ] Step 3: Commit
bash
git add "design-system/Dispatch Design System - v2/templates/admin/admin.js"
git commit -m "feat(admin): SEARCH_SOURCES registry + runSearch aggregator (GAC-NN)"

Task 3: Omnibox markup, styles, and script load โ€‹

Files:

  • Modify: design-system/Dispatch Design System - v2/templates/admin/index.html (the .adm-search block ~lines 70-73; the <script> tags ~lines 8 and 193)
  • Modify: design-system/Dispatch Design System - v2/bespoke.css (add .adm-omni* rules after the .adm-search rules ~line 250)

Interfaces:

  • Consumes: nothing yet (controller wires in Task 4).

  • Produces: a #omni panel element inside the .adm-search container; .adm-omni* CSS classes; search-core.js loaded before admin.js.

  • [ ] Step 1: Load search-core.js before admin.js

In index.html, change the final script include (~line 193) from:

html
<script src="./admin.js?v=32"></script>

to:

html
<script src="./search-core.js?v=1"></script>
<script src="./admin.js?v=33"></script>
  • [ ] Step 2: Add the omnibox container and repoint the input

In index.html, replace the .adm-search block (~lines 70-73):

html
        <div class="adm-search">
          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
          <input type="text" id="search" placeholder="Searchโ€ฆ" oninput="tablePage[current]=1; tablePage['_members']=1; render()">
        </div>

with:

html
        <div class="adm-search" id="search-wrap">
          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
          <input type="text" id="search" placeholder="Search everythingโ€ฆ  โŒ˜K" autocomplete="off"
                 oninput="omniInput()" onkeydown="omniKeydown(event)">
          <div class="adm-omni" id="omni" role="listbox" aria-label="Search results"></div>
        </div>
  • [ ] Step 3: Add the .adm-omni styles

In bespoke.css, immediately after the .adm-search svg{โ€ฆ} rule (~line 250), add:

css
.adm-search{position:relative;}
.adm-omni{position:absolute;top:calc(100% + 8px);right:0;width:380px;max-height:60vh;overflow-y:auto;
  background:var(--color-base-100);border:1px solid var(--line);border-radius:var(--r-md);
  box-shadow:0 18px 50px rgba(0,0,0,.16);z-index:120;padding:6px;display:none;}
.adm-omni.open{display:block;}
.adm-omni-group{padding:8px 10px 4px;font-size:11px;font-weight:700;letter-spacing:.04em;
  text-transform:uppercase;color:var(--grey);font-family:var(--sans);}
.adm-omni-item{display:flex;flex-direction:column;gap:2px;padding:9px 11px;border-radius:8px;cursor:pointer;}
.adm-omni-item:hover,.adm-omni-item.active{background:var(--paper);}
.adm-omni-title{font-size:13px;font-weight:600;color:var(--ink);font-family:var(--sans);}
.adm-omni-sub{font-size:11px;color:var(--grey);font-family:var(--sans);}
.adm-omni-more{padding:7px 11px;font-size:12px;color:var(--neon);cursor:pointer;font-family:var(--sans);}
.adm-omni-more:hover{text-decoration:underline;}
.adm-omni-empty{padding:18px 12px;text-align:center;font-size:13px;color:var(--grey);font-family:var(--sans);}
  • [ ] Step 4: Verify the panel renders (temporary smoke check)

Open index.html, sign in, open the console, and run:

js
document.getElementById('omni').classList.add('open');
document.getElementById('omni').innerHTML = '<div class="adm-omni-group">Members</div><div class="adm-omni-item active"><span class="adm-omni-title">Sarah Laurent</span><span class="adm-omni-sub">Member Partner</span></div>';

Expected: a styled dropdown appears under the search box, anchored to its right edge, with the group header and one highlighted row. Then run document.getElementById('omni').classList.remove('open') to hide it.

  • [ ] Step 5: Commit
bash
git add "design-system/Dispatch Design System - v2/templates/admin/index.html" "design-system/Dispatch Design System - v2/bespoke.css"
git commit -m "feat(admin): omnibox markup + styles, load search-core (GAC-NN)"

Task 4: Omnibox controller (render, select, close) โ€‹

Files:

  • Modify: design-system/Dispatch Design System - v2/templates/admin/admin.js (add an omnibox controller section near the other UI controllers, e.g. after runSearch)

Interfaces:

  • Consumes: runSearch (Task 2); the #omni / #search elements (Task 3); existing goTo.

  • Produces: globals omniInput(), renderOmni(groups), omniOpenHit(gi, ri), omniOpenMore(key), closeOmni(), and module state omniGroups, omniFlat, omniActive. (omniKeydown is added in Task 5.)

  • [ ] Step 1: Add the controller

Insert into admin.js after the runSearch function:

js
/* โ”€โ”€ OMNIBOX CONTROLLER โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
   Renders runSearch() output into #omni. omniFlat is the keyboard-
   navigable list of selectable rows in display order (hits only;
   overflow rows are mouse-only). */
let omniGroups = [], omniFlat = [], omniActive = -1;
function omniEl(){ return document.getElementById('omni'); }
function omniInput(){
  const q = document.getElementById('search').value;
  omniGroups = runSearch(q);
  omniActive = -1;
  renderOmni(q);
}
function renderOmni(q){
  const el = omniEl(); if (!el) return;
  if (!String(q||'').trim()){ closeOmni(); return; }
  omniFlat = [];
  let html = '';
  if (!omniGroups.length){
    html = `<div class="adm-omni-empty">No matches for โ€œ${q}โ€.</div>`;
  } else {
    omniGroups.forEach((g, gi) => {
      html += `<div class="adm-omni-group">${g.group}</div>`;
      g.rows.forEach((hit, ri) => {
        const idx = omniFlat.length;
        omniFlat.push({ gi, ri });
        html += `<div class="adm-omni-item${idx===omniActive?' active':''}" role="option"
          data-idx="${idx}" onmousedown="event.preventDefault()" onclick="omniOpenHit(${gi},${ri})">
          <span class="adm-omni-title">${hit.src.title(hit.r)}</span>
          <span class="adm-omni-sub">${hit.src.sub(hit.r)}</span></div>`;
      });
      if (g.overflow > 0){
        html += `<div class="adm-omni-more" onmousedown="event.preventDefault()" onclick="omniOpenMore('${g.key}')">+${g.overflow} more in ${g.group} โ†’</div>`;
      }
    });
  }
  el.innerHTML = html;
  el.classList.add('open');
}
function omniOpenHit(gi, ri){
  const hit = omniGroups[gi] && omniGroups[gi].rows[ri];
  closeOmni();
  if (hit) hit.src.open(hit.r);
}
function omniOpenMore(key){
  closeOmni();
  // Jump to the entity's own view; entity tables are the "see all" surface.
  const NAV = { orgs:'orgs', mandates:'mandates', tasks:'tasks', votes:'votes',
                users:'users', skills:'skills', userskills:'users',
                candidates:'dashboard', referrals:'dashboard', nodes:'dashboard', admins:'admins' };
  goTo(NAV[key] || 'dashboard');
}
function closeOmni(){
  const el = omniEl(); if (el){ el.classList.remove('open'); el.innerHTML = ''; }
  document.getElementById('search').value = '';
  omniGroups = []; omniFlat = []; omniActive = -1;
}
  • [ ] Step 2: Verify search-and-jump in the browser

Open index.html, sign in. Type sarah in the search box. Expected: the dropdown opens with a "Members" group containing "Sarah Laurent", plus "Tasks"/"Mandates" groups. Click "Sarah Laurent". Expected: navigates to the Users view and opens Sarah's profile modal; the search box clears and the dropdown closes.

Then type a nonsense string like zzzqqq. Expected: the dropdown shows "No matches for "zzzqqq".".

  • [ ] Step 3: Commit
bash
git add "design-system/Dispatch Design System - v2/templates/admin/admin.js"
git commit -m "feat(admin): omnibox render + select controller (GAC-NN)"

Task 5: Keyboard control, scope-aware open, and removal of the old filter โ€‹

Files:

  • Modify: design-system/Dispatch Design System - v2/templates/admin/admin.js (add omniKeydown + global โŒ˜K listener; edit filtered() ~lines 472-478; edit goTo ~line 437)
  • Modify: design-system/Dispatch Design System - v2/STATUS.md (record cache-buster versions + the feature)

Interfaces:

  • Consumes: omniFlat, omniGroups, omniActive, renderOmni, omniOpenHit, closeOmni (Task 4).

  • Produces: global omniKeydown(event); a document-level keydown listener for โŒ˜K/Ctrl-K.

  • [ ] Step 1: Add keyboard navigation

Insert into admin.js after closeOmni:

js
/* Keyboard: โ†‘/โ†“ move the active row, Enter opens it, Esc closes. */
function omniSetActive(i){
  const max = omniFlat.length;
  if (!max) return;
  omniActive = (i + max) % max;
  const el = omniEl(); if (!el) return;
  el.querySelectorAll('.adm-omni-item').forEach(node => {
    const on = Number(node.dataset.idx) === omniActive;
    node.classList.toggle('active', on);
    if (on) node.scrollIntoView({ block:'nearest' });
  });
}
function omniKeydown(e){
  const open = omniEl() && omniEl().classList.contains('open');
  if (e.key === 'Escape'){ closeOmni(); return; }
  if (!open) return;
  if (e.key === 'ArrowDown'){ e.preventDefault(); omniSetActive(omniActive + 1); }
  else if (e.key === 'ArrowUp'){ e.preventDefault(); omniSetActive(omniActive - 1); }
  else if (e.key === 'Enter'){
    e.preventDefault();
    const sel = omniFlat[omniActive >= 0 ? omniActive : 0];
    if (sel) omniOpenHit(sel.gi, sel.ri);
  }
}
/* โŒ˜K / Ctrl-K focuses the search box from anywhere; click-outside closes. */
document.addEventListener('keydown', e => {
  if ((e.metaKey || e.ctrlKey) && (e.key === 'k' || e.key === 'K')){
    e.preventDefault();
    const box = document.getElementById('search');
    if (box){ box.focus(); box.select(); }
  }
});
document.addEventListener('click', e => {
  const wrap = document.getElementById('search-wrap');
  if (wrap && !wrap.contains(e.target)) closeOmni();
});
  • [ ] Step 2: Remove the old per-table search filter

In admin.js, the filtered() function (~lines 472-478) currently reads #search to filter the current table. Replace it with:

js
function filtered(){
  let rows = DB[current];
  if(current==='users') rows = rows.filter(inScope);
  return rows;
}

(The #search box is now global-only; per-table narrowing remains available through the existing column-filter rows.)

  • [ ] Step 3: Stop goTo from clobbering the search box, and close the omnibox on nav

In admin.js, goTo (~line 437) ends with document.getElementById('search').value='';. Replace that trailing statement so navigation closes the omnibox cleanly. Change the line:

js
  current=e; buildNav(); render(); document.getElementById('search').value='';

to:

js
  current=e; buildNav(); render(); closeOmni();
  • [ ] Step 4: Verify keyboard + scope + that the old filter is gone

Open index.html, sign in.

  1. Press โŒ˜K (mac) / Ctrl-K. Expected: the search box gains focus.
  2. Type cla, press โ†“ twice, then Enter. Expected: the third-from-top result's destination opens (highlight moved with each โ†“).
  3. Press Esc while results are open. Expected: dropdown closes.
  4. Navigate to the Tasks view. Confirm the table shows all tasks and there is no longer a search-driven row filter on it (column filter rows still work).
  5. Scope check: in the console run viewScope = 2; then type sarah. Expected: Sarah Laurent (node 1) does not appear in Members (she is out of node 2's scope); reset with viewScope='all'.
  • [ ] Step 5: Update STATUS.md

In STATUS.md, record under the cache-buster / current-state notes: admin.js?v=33, new search-core.js?v=1, and a one-line entry: "Global search (omnibox) replaces the per-table search filter; SEARCH_SOURCES registry in admin.js, pure ranker in search-core.js."

  • [ ] Step 6: Commit
bash
git add "design-system/Dispatch Design System - v2/templates/admin/admin.js" "design-system/Dispatch Design System - v2/STATUS.md"
git commit -m "feat(admin): omnibox keyboard control + retire per-table filter (GAC-NN)"

Notes for the implementer โ€‹

  • Replace GAC-NN in every commit message with the real Linear issue id once it exists.
  • The production phases (Angular global-search component, Go pg_trgm /search endpoint, the later semantic tier on the AI document component) are out of scope here and get their own plans โ€” see ยง4 of the design spec docs/superpowers/specs/2026-06-22-admin-global-search-design.md.
  • This plan deliberately does not touch apps/web/src/styles/ โ€” the bespoke.css โ†’ dispatch-theme.css sync (CLAUDE.md) happens when the change is ported to the production app, not during prototype work.