Appearance
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 fromdesign-system/. This plan does not touchapps/web(that is a later, separate plan). - No framework / no reactive state โ vanilla JS, inline
onclick="", direct DOM manipulation, to matchadmin.js. - Match
bespoke.cssidiom โ px andrgba()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.htmlpinsds-base.js?v=30andadmin.js?v=32; a newsearch-core.jsis added. The?v=propagates HTML โ ds-base โ app-shell โ CSS. Track final values inSTATUS.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) -> Arraywhereaccessors = { title:(r)=>string, fields:(r)=>string }. Returns the subset ofitemsthat matchq(case-insensitive substring onfields), ordered best-first by a title-weighted score (exact title > title-prefix > title-substring > fields-only). Returns[]for an empty/whitespace query. Exposed aswindow.matchAndRankin the browser andmodule.exports.matchAndRankin 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 theENTITIESdeclaration near line 276)
Interfaces:
Consumes:
matchAndRank(Task 1); existing globalsDB,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 withoverflow= 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-searchblock ~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-searchrules ~line 250)
Interfaces:
Consumes: nothing yet (controller wires in Task 4).
Produces: a
#omnipanel element inside the.adm-searchcontainer;.adm-omni*CSS classes;search-core.jsloaded beforeadmin.js.[ ] Step 1: Load
search-core.jsbeforeadmin.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-omnistyles
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. afterrunSearch)
Interfaces:
Consumes:
runSearch(Task 2); the#omni/#searchelements (Task 3); existinggoTo.Produces: globals
omniInput(),renderOmni(groups),omniOpenHit(gi, ri),omniOpenMore(key),closeOmni(), and module stateomniGroups,omniFlat,omniActive. (omniKeydownis 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(addomniKeydown+ globalโKlistener; editfiltered()~lines 472-478; editgoTo~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-levelkeydownlistener 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
goTofrom 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.
- Press
โK(mac) /Ctrl-K. Expected: the search box gains focus. - Type
cla, pressโtwice, thenEnter. Expected: the third-from-top result's destination opens (highlight moved with eachโ). - Press
Escwhile results are open. Expected: dropdown closes. - 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).
- Scope check: in the console run
viewScope = 2;then typesarah. Expected: Sarah Laurent (node 1) does not appear in Members (she is out of node 2's scope); reset withviewScope='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-NNin every commit message with the real Linear issue id once it exists. - The production phases (Angular
global-searchcomponent, Gopg_trgm/searchendpoint, the later semantic tier on the AI document component) are out of scope here and get their own plans โ see ยง4 of the design specdocs/superpowers/specs/2026-06-22-admin-global-search-design.md. - This plan deliberately does not touch
apps/web/src/styles/โ thebespoke.cssโdispatch-theme.csssync (CLAUDE.md) happens when the change is ported to the production app, not during prototype work.