thevoidforge 21.0.10 → 21.0.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/dist/.claude/commands/ai.md +69 -0
  2. package/dist/.claude/commands/architect.md +121 -0
  3. package/dist/.claude/commands/assemble.md +201 -0
  4. package/dist/.claude/commands/assess.md +75 -0
  5. package/dist/.claude/commands/blueprint.md +135 -0
  6. package/dist/.claude/commands/build.md +116 -0
  7. package/dist/.claude/commands/campaign.md +201 -0
  8. package/dist/.claude/commands/cultivation.md +166 -0
  9. package/dist/.claude/commands/current.md +128 -0
  10. package/dist/.claude/commands/dangerroom.md +74 -0
  11. package/dist/.claude/commands/debrief.md +178 -0
  12. package/dist/.claude/commands/deploy.md +99 -0
  13. package/dist/.claude/commands/devops.md +143 -0
  14. package/dist/.claude/commands/gauntlet.md +140 -0
  15. package/dist/.claude/commands/git.md +104 -0
  16. package/dist/.claude/commands/grow.md +146 -0
  17. package/dist/.claude/commands/imagine.md +126 -0
  18. package/dist/.claude/commands/portfolio.md +50 -0
  19. package/dist/.claude/commands/prd.md +113 -0
  20. package/dist/.claude/commands/qa.md +107 -0
  21. package/dist/.claude/commands/review.md +151 -0
  22. package/dist/.claude/commands/security.md +100 -0
  23. package/dist/.claude/commands/test.md +96 -0
  24. package/dist/.claude/commands/thumper.md +116 -0
  25. package/dist/.claude/commands/treasury.md +100 -0
  26. package/dist/.claude/commands/ux.md +118 -0
  27. package/dist/.claude/commands/vault.md +189 -0
  28. package/dist/.claude/commands/void.md +108 -0
  29. package/dist/CHANGELOG.md +1918 -0
  30. package/dist/CLAUDE.md +250 -0
  31. package/dist/HOLOCRON.md +856 -0
  32. package/dist/VERSION.md +123 -0
  33. package/dist/docs/NAMING_REGISTRY.md +478 -0
  34. package/dist/docs/methods/AI_INTELLIGENCE.md +276 -0
  35. package/dist/docs/methods/ASSEMBLER.md +142 -0
  36. package/dist/docs/methods/BACKEND_ENGINEER.md +165 -0
  37. package/dist/docs/methods/BUILD_JOURNAL.md +185 -0
  38. package/dist/docs/methods/BUILD_PROTOCOL.md +426 -0
  39. package/dist/docs/methods/CAMPAIGN.md +568 -0
  40. package/dist/docs/methods/CONTEXT_MANAGEMENT.md +189 -0
  41. package/dist/docs/methods/DEEP_CURRENT.md +184 -0
  42. package/dist/docs/methods/DEVOPS_ENGINEER.md +295 -0
  43. package/dist/docs/methods/FIELD_MEDIC.md +261 -0
  44. package/dist/docs/methods/FORGE_ARTIST.md +108 -0
  45. package/dist/docs/methods/FORGE_KEEPER.md +268 -0
  46. package/dist/docs/methods/GAUNTLET.md +344 -0
  47. package/dist/docs/methods/GROWTH_STRATEGIST.md +466 -0
  48. package/dist/docs/methods/HEARTBEAT.md +168 -0
  49. package/dist/docs/methods/MCP_INTEGRATION.md +139 -0
  50. package/dist/docs/methods/MUSTER.md +148 -0
  51. package/dist/docs/methods/PRD_GENERATOR.md +186 -0
  52. package/dist/docs/methods/PRODUCT_DESIGN_FRONTEND.md +250 -0
  53. package/dist/docs/methods/QA_ENGINEER.md +337 -0
  54. package/dist/docs/methods/RELEASE_MANAGER.md +145 -0
  55. package/dist/docs/methods/SECURITY_AUDITOR.md +320 -0
  56. package/dist/docs/methods/SUB_AGENTS.md +335 -0
  57. package/dist/docs/methods/SYSTEMS_ARCHITECT.md +171 -0
  58. package/dist/docs/methods/TESTING.md +359 -0
  59. package/dist/docs/methods/THUMPER.md +175 -0
  60. package/dist/docs/methods/TIME_VAULT.md +120 -0
  61. package/dist/docs/methods/TREASURY.md +184 -0
  62. package/dist/docs/methods/TROUBLESHOOTING.md +265 -0
  63. package/dist/docs/patterns/README.md +52 -0
  64. package/dist/docs/patterns/ad-billing-adapter.ts +537 -0
  65. package/dist/docs/patterns/ad-platform-adapter.ts +421 -0
  66. package/dist/docs/patterns/ai-classifier.ts +195 -0
  67. package/dist/docs/patterns/ai-eval.ts +272 -0
  68. package/dist/docs/patterns/ai-orchestrator.ts +341 -0
  69. package/dist/docs/patterns/ai-router.ts +194 -0
  70. package/dist/docs/patterns/ai-tool-schema.ts +237 -0
  71. package/dist/docs/patterns/api-route.ts +241 -0
  72. package/dist/docs/patterns/backtest-engine.ts +499 -0
  73. package/dist/docs/patterns/browser-review.ts +292 -0
  74. package/dist/docs/patterns/combobox.tsx +300 -0
  75. package/dist/docs/patterns/component.tsx +262 -0
  76. package/dist/docs/patterns/daemon-process.ts +338 -0
  77. package/dist/docs/patterns/data-pipeline.ts +297 -0
  78. package/dist/docs/patterns/database-migration.ts +466 -0
  79. package/dist/docs/patterns/e2e-test.ts +629 -0
  80. package/dist/docs/patterns/error-handling.ts +312 -0
  81. package/dist/docs/patterns/execution-safety.ts +601 -0
  82. package/dist/docs/patterns/financial-transaction.ts +342 -0
  83. package/dist/docs/patterns/funding-plan.ts +462 -0
  84. package/dist/docs/patterns/game-entity.ts +137 -0
  85. package/dist/docs/patterns/game-loop.ts +113 -0
  86. package/dist/docs/patterns/game-state.ts +143 -0
  87. package/dist/docs/patterns/job-queue.ts +225 -0
  88. package/dist/docs/patterns/kongo-integration.ts +164 -0
  89. package/dist/docs/patterns/middleware.ts +363 -0
  90. package/dist/docs/patterns/mobile-screen.tsx +139 -0
  91. package/dist/docs/patterns/mobile-service.ts +167 -0
  92. package/dist/docs/patterns/multi-tenant.ts +382 -0
  93. package/dist/docs/patterns/oauth-token-lifecycle.ts +223 -0
  94. package/dist/docs/patterns/outbound-rate-limiter.ts +260 -0
  95. package/dist/docs/patterns/prompt-template.ts +195 -0
  96. package/dist/docs/patterns/revenue-source-adapter.ts +311 -0
  97. package/dist/docs/patterns/service.ts +224 -0
  98. package/dist/docs/patterns/sse-endpoint.ts +118 -0
  99. package/dist/docs/patterns/stablecoin-adapter.ts +511 -0
  100. package/dist/docs/patterns/third-party-script.ts +68 -0
  101. package/dist/scripts/thumper/gom-jabbar.sh +241 -0
  102. package/dist/scripts/thumper/relay.sh +610 -0
  103. package/dist/scripts/thumper/scan.sh +359 -0
  104. package/dist/scripts/thumper/thumper.sh +190 -0
  105. package/dist/scripts/thumper/water-rings.sh +76 -0
  106. package/dist/scripts/voidforge.js +1 -1
  107. package/package.json +1 -1
  108. package/dist/tsconfig.tsbuildinfo +0 -1
@@ -0,0 +1,292 @@
1
+ /**
2
+ * Pattern: Browser-Based Agent Review
3
+ *
4
+ * Purpose: Give review agents (Batman, Galadriel, Kenobi, Thanos) browser eyes
5
+ * during ad-hoc review passes. NOT E2E testing (see e2e-test.ts for that).
6
+ *
7
+ * E2E tests vs browser review — separate concerns:
8
+ * - E2E tests: deterministic, CI, assert pass/fail. Owned by test suites.
9
+ * - Browser review: exploratory, during /qa /ux /security /gauntlet. Evidence for triage.
10
+ *
11
+ * Three capabilities:
12
+ * 1. Console capture — catch uncaught exceptions and JS errors passively.
13
+ * 2. Behavioral walkthrough — click buttons, fill forms, verify responses.
14
+ * 3. Screenshot evidence — capture viewport state for triage reports.
15
+ *
16
+ * Screenshots: good for triage (blank page? modal open?) and evidence (attach to findings).
17
+ * NOT good for design review. Riker's dissent: "A screenshot tells you what rendered, not
18
+ * whether it looks right. Design review requires a designer's eye, not a pixel grid."
19
+ *
20
+ * Agents: Batman (QA walkthrough), Galadriel (responsive), Kenobi (security),
21
+ * Hawkeye (Gauntlet smoke), Riker (dissent on screenshot-as-design-review)
22
+ *
23
+ * === Framework Adaptations (baseURL + startup) ===
24
+ * Next.js: `next dev -p 3199`, baseURL = 'http://127.0.0.1:3199'
25
+ * Express: `PORT=3199 npx tsx src/server.ts`, same baseURL
26
+ * Django: `python manage.py runserver 3199 --settings=project.settings.test`
27
+ * Rails: `RAILS_ENV=test bin/rails server -p 3199`
28
+ * All: start the dev server BEFORE calling launchReviewBrowser(). The review
29
+ * browser connects to a running server — it does NOT manage the lifecycle.
30
+ *
31
+ * Dependencies: playwright (already available if using e2e-test.ts)
32
+ */
33
+
34
+ import { chromium, type Browser, type BrowserContext, type Page } from 'playwright';
35
+ // Type-only import for axe — actual usage is dynamic import (not all projects have it)
36
+ type AxeResults = { violations: Array<{ id: string; impact?: string; description: string; nodes: unknown[] }> };
37
+
38
+ // ── Types ───────────────────────────────────────────
39
+
40
+ export interface ConsoleError {
41
+ url: string;
42
+ type: 'uncaught-exception' | 'console-error';
43
+ message: string;
44
+ stack: string | null;
45
+ }
46
+
47
+ export interface PageStateReport {
48
+ route: string;
49
+ title: string;
50
+ headings: string[];
51
+ screenshotPath: string;
52
+ consoleErrors: ConsoleError[];
53
+ a11yViolations: Array<{ id: string; impact: string | undefined; description: string; nodeCount: number }>;
54
+ }
55
+
56
+ export interface ViewportCapture {
57
+ viewport: { width: number; height: number; label: string };
58
+ screenshotPath: string;
59
+ consoleErrors: ConsoleError[];
60
+ }
61
+
62
+ export interface ResponsiveReport { route: string; captures: ViewportCapture[] }
63
+
64
+ export interface InteractionReport {
65
+ route: string;
66
+ buttons: Array<{ text: string; responded: boolean; error: string | null }>;
67
+ links: Array<{ text: string; href: string | null }>;
68
+ formFields: Array<{ label: string; type: string; acceptedInput: boolean; validationMessage: string | null }>;
69
+ }
70
+
71
+ export interface CookieReport {
72
+ cookies: Array<{ name: string; domain: string; secure: boolean; httpOnly: boolean; sameSite: string; expires: number }>;
73
+ findings: string[];
74
+ }
75
+
76
+ export interface CORSReport {
77
+ url: string;
78
+ allowOrigin: string | null;
79
+ allowCredentials: string | null;
80
+ allowMethods: string | null;
81
+ allowHeaders: string | null;
82
+ findings: string[];
83
+ }
84
+
85
+ export interface CSPViolation { blockedURI: string; violatedDirective: string; originalPolicy: string }
86
+
87
+ // ── Review Browser Launcher ─────────────────────────
88
+ // Network-isolated Chromium. Fixed viewport (1440x900, 1x DPI, light mode).
89
+
90
+ export async function launchReviewBrowser(baseURL: string): Promise<{
91
+ browser: Browser; context: BrowserContext; page: Page;
92
+ }> {
93
+ const browser = await chromium.launch({
94
+ args: [
95
+ '--host-resolver-rules=MAP * ~NOTFOUND, EXCLUDE 127.0.0.1', // Block external DNS
96
+ '--disable-extensions',
97
+ ],
98
+ });
99
+ const context = await browser.newContext({
100
+ viewport: { width: 1440, height: 900 },
101
+ deviceScaleFactor: 1,
102
+ colorScheme: 'light',
103
+ baseURL,
104
+ });
105
+ const page = await context.newPage();
106
+ return { browser, context, page };
107
+ }
108
+
109
+ // ── Console Error Capture ───────────────────────────
110
+ // pageerror = uncaught exception (always a finding).
111
+ // console.error = filtered for noise (React dev, HMR, extensions).
112
+
113
+ const CONSOLE_NOISE: RegExp[] = [
114
+ /Download the React DevTools/i, /Warning: ReactDOM/i,
115
+ /Warning: Each child in a list/i, /\[HMR\]/i, /\[vite\]/i,
116
+ /webpack.*hot/i, /chrome-extension:\/\//i, /moz-extension:\/\//i,
117
+ /Failed to load resource.*favicon/i,
118
+ ];
119
+
120
+ export function attachConsoleCapture(page: Page): {
121
+ errors: ConsoleError[]; getErrors: () => ConsoleError[];
122
+ } {
123
+ const errors: ConsoleError[] = [];
124
+ page.on('pageerror', (error) => {
125
+ errors.push({ url: page.url(), type: 'uncaught-exception', message: error.message, stack: error.stack ?? null });
126
+ });
127
+ page.on('console', (msg) => {
128
+ if (msg.type() !== 'error') return;
129
+ const text = msg.text();
130
+ if (CONSOLE_NOISE.some((p) => p.test(text))) return;
131
+ errors.push({ url: page.url(), type: 'console-error', message: text, stack: null });
132
+ });
133
+ return { errors, getErrors: () => [...errors] };
134
+ }
135
+
136
+ // ── Page State Capture ──────────────────────────────
137
+ // Navigate, wait for ready, screenshot, headings, a11y. The workhorse.
138
+
139
+ export async function capturePageState(
140
+ page: Page, route: string,
141
+ options?: { readySelector?: string; screenshotDir?: string; runA11y?: boolean }
142
+ ): Promise<PageStateReport> {
143
+ const readySelector = options?.readySelector ?? '[data-ready]';
144
+ const screenshotDir = options?.screenshotDir ?? 'review-captures';
145
+ const runA11y = options?.runA11y ?? true;
146
+
147
+ await page.goto(route);
148
+ // Wait for ready selector; fall back to networkidle (lenient for review, not E2E)
149
+ try { await page.waitForSelector(readySelector, { timeout: 3000 }); }
150
+ catch { await page.waitForLoadState('networkidle'); }
151
+
152
+ const slug = route.replace(/\//g, '-').replace(/^-/, '') || 'index';
153
+ const screenshotPath = `${screenshotDir}/${slug}.jpg`;
154
+ await page.screenshot({ path: screenshotPath, type: 'jpeg', quality: 80 });
155
+
156
+ const title = await page.title();
157
+ const headings = await page.locator('h1, h2, h3').allTextContents();
158
+
159
+ // a11y scan — dynamic import so pattern doesn't hard-depend on axe
160
+ let a11yViolations: PageStateReport['a11yViolations'] = [];
161
+ if (runA11y) {
162
+ try {
163
+ const { default: Builder } = await import('@axe-core/playwright');
164
+ const results: AxeResults = await new (Builder as unknown as new (opts: { page: Page }) => { analyze: () => Promise<AxeResults> })({ page }).analyze();
165
+ a11yViolations = results.violations.map((v) => ({
166
+ id: v.id, impact: v.impact, description: v.description, nodeCount: v.nodes.length,
167
+ }));
168
+ } catch { /* axe not installed — skip */ }
169
+ }
170
+
171
+ return { route, title, headings, screenshotPath, consoleErrors: [], a11yViolations };
172
+ }
173
+
174
+ // ── Responsive Capture ──────────────────────────────
175
+ // Three viewports: mobile (375x812), tablet (768x1024), desktop (1440x900).
176
+
177
+ const VIEWPORTS = [
178
+ { width: 375, height: 812, label: 'mobile' },
179
+ { width: 768, height: 1024, label: 'tablet' },
180
+ { width: 1440, height: 900, label: 'desktop' },
181
+ ] as const;
182
+
183
+ export async function captureResponsiveSet(
184
+ page: Page, route: string, options?: { screenshotDir?: string }
185
+ ): Promise<ResponsiveReport> {
186
+ const screenshotDir = options?.screenshotDir ?? 'review-captures';
187
+ const captures: ViewportCapture[] = [];
188
+ for (const vp of VIEWPORTS) {
189
+ await page.setViewportSize({ width: vp.width, height: vp.height });
190
+ const capture = attachConsoleCapture(page);
191
+ await page.goto(route);
192
+ await page.waitForLoadState('networkidle');
193
+ const slug = route.replace(/\//g, '-').replace(/^-/, '') || 'index';
194
+ const path = `${screenshotDir}/${slug}-${vp.label}.jpg`;
195
+ await page.screenshot({ path, type: 'jpeg', quality: 80 });
196
+ captures.push({ viewport: { width: vp.width, height: vp.height, label: vp.label }, screenshotPath: path, consoleErrors: capture.getErrors() });
197
+ }
198
+ await page.setViewportSize({ width: 1440, height: 900 }); // Restore default
199
+ return { route, captures };
200
+ }
201
+
202
+ // ── Behavioral Walkthrough ──────────────────────────
203
+ // Click everything, fill every form, see what breaks. Exploratory, not deterministic.
204
+
205
+ export async function walkInteractiveElements(page: Page): Promise<InteractionReport> {
206
+ const route = page.url();
207
+ const buttons: InteractionReport['buttons'] = [];
208
+ const btnLocators = page.locator('button:visible, [role="button"]:visible, [role="tab"]:visible');
209
+ for (let i = 0; i < await btnLocators.count(); i++) {
210
+ const btn = btnLocators.nth(i);
211
+ const text = ((await btn.textContent()) ?? '[no text]').trim();
212
+ const before = await page.content();
213
+ try {
214
+ await btn.click({ timeout: 2000 });
215
+ await page.waitForTimeout(500); // Brief wait for async response
216
+ buttons.push({ text, responded: (await page.content()) !== before, error: null });
217
+ } catch (err) { buttons.push({ text, responded: false, error: String(err) }); }
218
+ }
219
+
220
+ const links: InteractionReport['links'] = [];
221
+ const linkLocators = page.locator('a:visible');
222
+ for (let i = 0; i < await linkLocators.count(); i++) {
223
+ const link = linkLocators.nth(i);
224
+ links.push({ text: ((await link.textContent()) ?? '').trim(), href: await link.getAttribute('href') });
225
+ }
226
+
227
+ const formFields: InteractionReport['formFields'] = [];
228
+ const inputs = page.locator('input:visible, textarea:visible, select:visible');
229
+ for (let i = 0; i < await inputs.count(); i++) {
230
+ const input = inputs.nth(i);
231
+ const label = (await input.getAttribute('aria-label')) ?? (await input.getAttribute('placeholder'))
232
+ ?? (await input.getAttribute('name')) ?? '[unlabeled]';
233
+ const type = (await input.getAttribute('type')) ?? 'text';
234
+ try {
235
+ await input.fill('test-review-input');
236
+ await input.blur();
237
+ const msg = await input.evaluate((el) => (el as HTMLInputElement).validationMessage || null);
238
+ formFields.push({ label, type, acceptedInput: true, validationMessage: msg });
239
+ } catch (err) { formFields.push({ label, type, acceptedInput: false, validationMessage: String(err) }); }
240
+ }
241
+ return { route, buttons, links, formFields };
242
+ }
243
+
244
+ // ── Security Inspection ─────────────────────────────
245
+ // Kenobi's toolkit: cookies, CORS, CSP. Runtime security inspection.
246
+
247
+ export async function inspectCookies(context: BrowserContext): Promise<CookieReport> {
248
+ const raw = await context.cookies();
249
+ const findings: string[] = [];
250
+ const cookies = raw.map((c) => {
251
+ if (!c.secure) findings.push(`Cookie "${c.name}" missing Secure flag`);
252
+ if (!c.httpOnly && /sess|token|auth/i.test(c.name)) findings.push(`Session cookie "${c.name}" missing HttpOnly`);
253
+ if (c.sameSite === 'None' && !c.secure) findings.push(`Cookie "${c.name}" SameSite=None without Secure`);
254
+ if (c.expires === -1 && /sess|token|auth/i.test(c.name)) findings.push(`Session cookie "${c.name}" no expiry`);
255
+ return { name: c.name, domain: c.domain, secure: c.secure, httpOnly: c.httpOnly, sameSite: c.sameSite, expires: c.expires };
256
+ });
257
+ return { cookies, findings };
258
+ }
259
+
260
+ export async function captureCORSHeaders(page: Page, apiPattern: string): Promise<CORSReport> {
261
+ const report: CORSReport = { url: apiPattern, allowOrigin: null, allowCredentials: null, allowMethods: null, allowHeaders: null, findings: [] };
262
+ const respPromise = page.waitForResponse((r) => r.url().includes(apiPattern), { timeout: 5000 }).catch(() => null);
263
+ await page.evaluate(async (p) => { try { await fetch(p, { method: 'OPTIONS', mode: 'cors' }); } catch { /* expected */ } }, apiPattern);
264
+ const resp = await respPromise;
265
+ if (resp) {
266
+ const h = resp.headers();
267
+ report.allowOrigin = h['access-control-allow-origin'] ?? null;
268
+ report.allowCredentials = h['access-control-allow-credentials'] ?? null;
269
+ report.allowMethods = h['access-control-allow-methods'] ?? null;
270
+ report.allowHeaders = h['access-control-allow-headers'] ?? null;
271
+ }
272
+ if (report.allowOrigin === '*') report.findings.push('CORS allows all origins (*)');
273
+ if (report.allowOrigin === '*' && report.allowCredentials === 'true') report.findings.push('CRITICAL: Wildcard origin + credentials');
274
+ if (report.allowMethods?.includes('*')) report.findings.push('CORS allows all methods');
275
+ return report;
276
+ }
277
+
278
+ export async function captureCSPViolations(page: Page): Promise<CSPViolation[]> {
279
+ // Inject listener BEFORE navigating — call this, then goto(), then read violations
280
+ await page.addInitScript(() => {
281
+ const v: Array<{ blockedURI: string; violatedDirective: string; originalPolicy: string }> = [];
282
+ document.addEventListener('securitypolicyviolation', (e) => {
283
+ v.push({ blockedURI: e.blockedURI, violatedDirective: e.violatedDirective, originalPolicy: e.originalPolicy });
284
+ });
285
+ Object.defineProperty(window, '__cspViolations', { get: () => v, configurable: true });
286
+ });
287
+ // Read accumulated violations (call after navigation)
288
+ return page.evaluate(() => {
289
+ const w = window as unknown as Record<string, unknown>;
290
+ return (w.__cspViolations as CSPViolation[] | undefined) ?? [];
291
+ });
292
+ }
@@ -0,0 +1,300 @@
1
+ /**
2
+ * Pattern: Accessible Combobox with Value Source Management
3
+ *
4
+ * Key principles:
5
+ * - Two distinct value sources: display value (closed) and search query (open)
6
+ * - Never let dropdown close events switch the value source mid-keystroke
7
+ * - ARIA combobox role with listbox, proper focus management
8
+ * - Keyboard nav: arrow keys, Enter to select, Escape to close
9
+ *
10
+ * The trap: A controlled combobox shows `displayValue` when closed and
11
+ * `searchQuery` when open. If the search function closes the dropdown
12
+ * (e.g., on short queries or no results), the input switches from
13
+ * `searchQuery` back to `displayValue` — wiping the user's keystrokes.
14
+ *
15
+ * Agents: Legolas (code), Samwise (a11y), Bilbo (copy)
16
+ *
17
+ * Framework adaptations:
18
+ * React: This file (hooks, controlled input)
19
+ * Vue: v-model with computed get/set for value source switching
20
+ * Svelte: Reactive declarations with $: for value derivation
21
+ * Django + HTMX: Server-filtered results via hx-get, debounced hx-trigger
22
+ *
23
+ * Field report #259: Combobox value source switching caused keystroke loss
24
+ * when dropdown closed during typing.
25
+ */
26
+
27
+ import { useState, useRef, useCallback, useEffect } from 'react';
28
+
29
+ // ── Types ────────────────────────────────────────────────
30
+
31
+ interface ComboboxOption {
32
+ id: string;
33
+ label: string; // Display text
34
+ searchText?: string; // Optional: separate text for search matching
35
+ }
36
+
37
+ interface ComboboxProps {
38
+ options: ComboboxOption[];
39
+ value: string | null; // Selected option ID
40
+ onChange: (id: string | null) => void;
41
+ onSearch?: (query: string) => void; // For async/server-side search
42
+ placeholder?: string;
43
+ label: string; // Required — a11y
44
+ disabled?: boolean;
45
+ minSearchLength?: number; // Min chars before showing results (default: 1)
46
+ }
47
+
48
+ // ── The Value Source Rule ────────────────────────────────
49
+ //
50
+ // WRONG: Derive input value from isOpen
51
+ // value={isOpen ? searchQuery : displayValue}
52
+ // // Closing the dropdown wipes searchQuery with displayValue
53
+ //
54
+ // RIGHT: Track value source explicitly, only switch on user actions
55
+ // - User types → source = 'search'
56
+ // - User selects option → source = 'display', close dropdown
57
+ // - User clicks away (blur) → source = 'display', close dropdown
58
+ // - User presses Escape → source = 'display', close dropdown
59
+ // - Dropdown closes from search logic → DO NOT switch source
60
+ //
61
+ // The key insight: the dropdown's open/closed state should NOT
62
+ // control the input's value source. Only explicit user intent should.
63
+
64
+ type ValueSource = 'search' | 'display';
65
+
66
+ export function Combobox({
67
+ options,
68
+ value,
69
+ onChange,
70
+ onSearch,
71
+ placeholder = 'Search...',
72
+ label,
73
+ disabled = false,
74
+ minSearchLength = 1,
75
+ }: ComboboxProps) {
76
+ const [isOpen, setIsOpen] = useState(false);
77
+ const [searchQuery, setSearchQuery] = useState('');
78
+ const [valueSource, setValueSource] = useState<ValueSource>('display');
79
+ const [activeIndex, setActiveIndex] = useState(-1);
80
+
81
+ const inputRef = useRef<HTMLInputElement>(null);
82
+ const listboxRef = useRef<HTMLUListElement>(null);
83
+ const listboxId = useRef(`combobox-listbox-${Math.random().toString(36).slice(2)}`);
84
+
85
+ // Derive display value from selected option
86
+ const selectedOption = options.find(o => o.id === value);
87
+ const displayValue = selectedOption?.label ?? '';
88
+
89
+ // The input shows search query OR display value based on explicit source
90
+ const inputValue = valueSource === 'search' ? searchQuery : displayValue;
91
+
92
+ // Filter options locally (skip if onSearch handles it server-side)
93
+ const filteredOptions = onSearch
94
+ ? options // Server-side search — show whatever options are provided
95
+ : options.filter(o => {
96
+ if (searchQuery.length < minSearchLength) return false;
97
+ const text = o.searchText ?? o.label;
98
+ return text.toLowerCase().includes(searchQuery.toLowerCase());
99
+ });
100
+
101
+ // ── User types → switch to search source, open dropdown ──
102
+ const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
103
+ const query = e.target.value;
104
+ setSearchQuery(query);
105
+ setValueSource('search'); // Explicit: user is searching
106
+ setActiveIndex(-1);
107
+
108
+ if (query.length >= minSearchLength) {
109
+ setIsOpen(true);
110
+ onSearch?.(query);
111
+ }
112
+ // NOTE: Do NOT close the dropdown here even if query is short.
113
+ // Closing here would switch value source and wipe keystrokes.
114
+ // Let the empty results state handle "no results" display.
115
+ }, [minSearchLength, onSearch]);
116
+
117
+ // ── User selects → switch to display source, close ──
118
+ const handleSelect = useCallback((optionId: string) => {
119
+ onChange(optionId);
120
+ setValueSource('display'); // Explicit: user made a selection
121
+ setIsOpen(false);
122
+ setSearchQuery('');
123
+ setActiveIndex(-1);
124
+ inputRef.current?.focus();
125
+ }, [onChange]);
126
+
127
+ // ── User blurs → switch to display source, close ──
128
+ const handleBlur = useCallback((e: React.FocusEvent) => {
129
+ // Don't close if focus moved to the listbox
130
+ if (listboxRef.current?.contains(e.relatedTarget as Node)) return;
131
+ setValueSource('display');
132
+ setIsOpen(false);
133
+ setSearchQuery('');
134
+ setActiveIndex(-1);
135
+ }, []);
136
+
137
+ // ── Keyboard navigation ──
138
+ const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
139
+ switch (e.key) {
140
+ case 'ArrowDown':
141
+ e.preventDefault();
142
+ if (!isOpen) {
143
+ setIsOpen(true);
144
+ setActiveIndex(0);
145
+ } else {
146
+ setActiveIndex(i => Math.min(i + 1, filteredOptions.length - 1));
147
+ }
148
+ break;
149
+ case 'ArrowUp':
150
+ e.preventDefault();
151
+ setActiveIndex(i => Math.max(i - 1, 0));
152
+ break;
153
+ case 'Enter':
154
+ e.preventDefault();
155
+ if (isOpen && activeIndex >= 0 && filteredOptions[activeIndex]) {
156
+ handleSelect(filteredOptions[activeIndex].id);
157
+ }
158
+ break;
159
+ case 'Escape':
160
+ e.preventDefault();
161
+ setValueSource('display'); // Explicit: user cancelled
162
+ setIsOpen(false);
163
+ setSearchQuery('');
164
+ setActiveIndex(-1);
165
+ break;
166
+ case 'Home':
167
+ if (isOpen) { e.preventDefault(); setActiveIndex(0); }
168
+ break;
169
+ case 'End':
170
+ if (isOpen) { e.preventDefault(); setActiveIndex(filteredOptions.length - 1); }
171
+ break;
172
+ }
173
+ }, [isOpen, activeIndex, filteredOptions, handleSelect]);
174
+
175
+ // Scroll active option into view
176
+ useEffect(() => {
177
+ if (activeIndex >= 0 && listboxRef.current) {
178
+ const activeEl = listboxRef.current.children[activeIndex] as HTMLElement;
179
+ activeEl?.scrollIntoView({ block: 'nearest' });
180
+ }
181
+ }, [activeIndex]);
182
+
183
+ const activeDescendant = activeIndex >= 0
184
+ ? `${listboxId.current}-option-${activeIndex}`
185
+ : undefined;
186
+
187
+ return (
188
+ <div className="relative" onBlur={handleBlur}>
189
+ <label id={`${listboxId.current}-label`} className="block text-sm font-medium">
190
+ {label}
191
+ </label>
192
+ <input
193
+ ref={inputRef}
194
+ role="combobox"
195
+ aria-expanded={isOpen}
196
+ aria-controls={listboxId.current}
197
+ aria-labelledby={`${listboxId.current}-label`}
198
+ aria-activedescendant={activeDescendant}
199
+ aria-autocomplete="list"
200
+ value={inputValue}
201
+ onChange={handleInputChange}
202
+ onKeyDown={handleKeyDown}
203
+ onFocus={() => {
204
+ if (displayValue) {
205
+ // Pre-fill search with current value so user can refine
206
+ setSearchQuery(displayValue);
207
+ setValueSource('search');
208
+ setIsOpen(true);
209
+ }
210
+ }}
211
+ placeholder={placeholder}
212
+ disabled={disabled}
213
+ className="w-full border rounded px-3 py-2 focus-visible:ring-2 focus-visible:ring-blue-500"
214
+ />
215
+ {isOpen && (
216
+ <ul
217
+ ref={listboxRef}
218
+ id={listboxId.current}
219
+ role="listbox"
220
+ aria-labelledby={`${listboxId.current}-label`}
221
+ className="absolute z-10 w-full mt-1 bg-white border rounded shadow-lg max-h-60 overflow-auto"
222
+ >
223
+ {filteredOptions.length === 0 ? (
224
+ <li className="px-3 py-2 text-gray-500" role="option" aria-selected={false}>
225
+ No results found
226
+ </li>
227
+ ) : (
228
+ filteredOptions.map((option, index) => (
229
+ <li
230
+ key={option.id}
231
+ id={`${listboxId.current}-option-${index}`}
232
+ role="option"
233
+ aria-selected={option.id === value}
234
+ className={`px-3 py-2 cursor-pointer ${
235
+ index === activeIndex ? 'bg-blue-100' : ''
236
+ } ${option.id === value ? 'font-semibold' : ''}`}
237
+ onMouseDown={(e) => {
238
+ e.preventDefault(); // Prevent blur before select
239
+ handleSelect(option.id);
240
+ }}
241
+ onMouseEnter={() => setActiveIndex(index)}
242
+ >
243
+ {option.label}
244
+ </li>
245
+ ))
246
+ )}
247
+ </ul>
248
+ )}
249
+ </div>
250
+ );
251
+ }
252
+
253
+ // ── Server-Side Search Example (async) ──────────────────
254
+ //
255
+ // function CityCombobox() {
256
+ // const [cities, setCities] = useState<ComboboxOption[]>([]);
257
+ // const [selected, setSelected] = useState<string | null>(null);
258
+ //
259
+ // const handleSearch = useCallback(async (query: string) => {
260
+ // const results = await fetch(`/api/cities?q=${encodeURIComponent(query)}`);
261
+ // const data = await results.json();
262
+ // setCities(data.map((c: any) => ({ id: c.id, label: c.displayName })));
263
+ // }, []);
264
+ //
265
+ // return (
266
+ // <Combobox
267
+ // options={cities}
268
+ // value={selected}
269
+ // onChange={setSelected}
270
+ // onSearch={handleSearch}
271
+ // label="City"
272
+ // placeholder="Search cities..."
273
+ // minSearchLength={2}
274
+ // />
275
+ // );
276
+ // }
277
+ //
278
+ // ── Django + HTMX Adaptation ────────────────────────────
279
+ //
280
+ // Server-filtered combobox without client-side JS framework:
281
+ //
282
+ // <input
283
+ // type="text"
284
+ // name="city"
285
+ // hx-get="/api/cities/"
286
+ // hx-trigger="input changed delay:300ms"
287
+ // hx-target="#city-results"
288
+ // hx-swap="innerHTML"
289
+ // role="combobox"
290
+ // aria-expanded="false"
291
+ // aria-controls="city-results"
292
+ // autocomplete="off"
293
+ // />
294
+ // <ul id="city-results" role="listbox"></ul>
295
+ //
296
+ // Server returns <li role="option"> fragments. Client-side JS
297
+ // (minimal, not a framework) handles: aria-expanded toggle,
298
+ // aria-activedescendant on arrow keys, selection on Enter.
299
+ // The value source trap still applies — use a hidden input for
300
+ // the selected ID and keep the visible input for display/search.