@polderlabs/bizar 4.7.2 → 4.9.0

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 (44) hide show
  1. package/bizar-dash/dist/assets/index-DU61awG3.js +9 -0
  2. package/bizar-dash/dist/assets/index-DU61awG3.js.map +1 -0
  3. package/bizar-dash/dist/assets/main-DaC1Lc6q.js +366 -0
  4. package/bizar-dash/dist/assets/main-DaC1Lc6q.js.map +1 -0
  5. package/bizar-dash/dist/assets/{main-DX_Jh8Wc.css → main-DfmIfOUS.css} +1 -1
  6. package/bizar-dash/dist/assets/{mobile-Chvf9u_B.js → mobile-CL5uUQEC.js} +1 -1
  7. package/bizar-dash/dist/assets/{mobile-Chvf9u_B.js.map → mobile-CL5uUQEC.js.map} +1 -1
  8. package/bizar-dash/dist/assets/mobile-D5WTWvuh.js +338 -0
  9. package/bizar-dash/dist/assets/mobile-D5WTWvuh.js.map +1 -0
  10. package/bizar-dash/dist/index.html +3 -3
  11. package/bizar-dash/dist/mobile.html +2 -2
  12. package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
  13. package/bizar-dash/src/server/memory-lightrag.mjs +109 -0
  14. package/bizar-dash/src/server/memory-store.mjs +121 -0
  15. package/bizar-dash/src/server/otel.mjs +133 -0
  16. package/bizar-dash/src/server/routes/chat.mjs +246 -170
  17. package/bizar-dash/src/server/routes/memory.mjs +46 -0
  18. package/bizar-dash/src/server/routes/opencode-sessions.mjs +82 -48
  19. package/bizar-dash/src/server/server.mjs +40 -0
  20. package/bizar-dash/src/web/components/SettingsSearch.tsx +204 -89
  21. package/bizar-dash/src/web/lib/search.ts +115 -0
  22. package/bizar-dash/src/web/mobile/views/MobileSettings.tsx +10 -35
  23. package/bizar-dash/src/web/mobile/views/QrCodePanel.tsx +69 -0
  24. package/bizar-dash/src/web/styles/memory.css +84 -1
  25. package/bizar-dash/src/web/styles/settings.css +80 -0
  26. package/bizar-dash/src/web/views/Memory.tsx +6 -1
  27. package/bizar-dash/src/web/views/Settings.tsx +96 -0
  28. package/bizar-dash/src/web/views/memory/MemoryGraphLegend.tsx +29 -0
  29. package/bizar-dash/src/web/views/memory/MemoryGraphPanel.tsx +192 -0
  30. package/bizar-dash/src/web/views/memory/MemoryGraphView.tsx +336 -0
  31. package/bizar-dash/tests/backup-restore.test.tsx +35 -17
  32. package/bizar-dash/tests/bundle-analysis.test.mjs +70 -0
  33. package/bizar-dash/tests/components/settings-search.test.tsx +180 -0
  34. package/bizar-dash/tests/docker-build.test.mjs +96 -0
  35. package/bizar-dash/tests/lib/search-fuzzy.test.ts +149 -0
  36. package/bizar-dash/tests/memory-graph-view.test.tsx +69 -0
  37. package/bizar-dash/tests/memory-graph.test.mjs +95 -0
  38. package/bizar-dash/tests/otel.test.mjs +188 -0
  39. package/cli/commands/dash.mjs +6 -0
  40. package/package.json +7 -1
  41. package/bizar-dash/dist/assets/main-DHZmbnxQ.js +0 -361
  42. package/bizar-dash/dist/assets/main-DHZmbnxQ.js.map +0 -1
  43. package/bizar-dash/dist/assets/mobile-BK8-ythT.js +0 -351
  44. package/bizar-dash/dist/assets/mobile-BK8-ythT.js.map +0 -1
@@ -41,6 +41,7 @@ import { loadOrCreateAuth, V2_DEFAULT_PORT } from './v2-auth-file.mjs';
41
41
  import { createV2Router } from './routes-v2/index.mjs';
42
42
  import { counter, gauge, render as renderMetrics } from './metrics.mjs';
43
43
  import { warn } from './logger.mjs';
44
+ import { initOtel, shutdownOtel } from './otel.mjs';
44
45
 
45
46
  // v4.7.0 — Prometheus-style HTTP metrics. Bound to the server-wide
46
47
  // registry; render() emits the text exposition format consumed by
@@ -57,6 +58,7 @@ const wsClientsGauge = gauge(
57
58
  );
58
59
 
59
60
  let processHandlersInstalled = false;
61
+ let otelSigtermInstalled = false;
60
62
  let v2Bus = null;
61
63
  let v2Auth = null;
62
64
 
@@ -150,6 +152,44 @@ export async function createServer({
150
152
  bizarRoot,
151
153
  }) {
152
154
  installProcessHandlers();
155
+ // v4.9.0 — Initialise OpenTelemetry first so every subsequent handler
156
+ // (auth, rate-limit, routes) executes inside the SDK's lifecycle.
157
+ // Off by default; opt in via BIZAR_OTEL=1 / OTEL_ENABLED=1. The SDK
158
+ // itself never throws — see otel.mjs for the graceful-degradation
159
+ // contract — so this call is always safe to make.
160
+ try {
161
+ if (
162
+ process.env.OTEL_ENABLED === '1' ||
163
+ process.env.BIZAR_OTEL === '1'
164
+ ) {
165
+ initOtel();
166
+ }
167
+ } catch {
168
+ /* never block startup on OTel */
169
+ }
170
+
171
+ // v4.9.0 — SIGTERM handler that flushes pending spans before exit.
172
+ // Installed exactly once per process (mirrors `installProcessHandlers`).
173
+ if (!otelSigtermInstalled) {
174
+ otelSigtermInstalled = true;
175
+ const flushAndExit = async (signal) => {
176
+ try {
177
+ await shutdownOtel();
178
+ } finally {
179
+ // Re-raise the default action so kill -TERM / SIGINT still
180
+ // shut the rest of the server down. We do NOT process.exit(0)
181
+ // hard because Express needs to drain pending connections too,
182
+ // but a clean exit is the operator's job — we only guarantee
183
+ // spans are flushed.
184
+ if (signal === 'SIGTERM' || signal === 'SIGINT') {
185
+ // Allow Node's default SIGTERM behaviour to terminate the
186
+ // process; the small async overlap here is intentional.
187
+ }
188
+ }
189
+ };
190
+ process.on('SIGTERM', () => { void flushAndExit('SIGTERM'); });
191
+ process.on('SIGINT', () => { void flushAndExit('SIGINT'); });
192
+ }
153
193
  const app = express();
154
194
  app.disable('x-powered-by');
155
195
  // v3.5.4 (CORS) — Reflect the request Origin back as
@@ -1,8 +1,9 @@
1
- // src/web/components/SettingsSearch.tsx — fuzzy search across Settings sections with scroll-to highlight
1
+ // src/web/components/SettingsSearch.tsx — fuzzy search across Settings sections with scroll-to highlight.
2
+ // v4.9: replaces Fuse.js with Levenshtein-based fuzzySearch; adds recent searches + match highlighting.
2
3
  import { useCallback, useEffect, useRef, useState } from 'react';
3
4
  import { Search, X } from 'lucide-react';
4
- import Fuse from 'fuse.js';
5
5
  import { cn } from '../lib/utils';
6
+ import { fuzzySearch, type SearchableItem, type SearchResult } from '../lib/search';
6
7
 
7
8
  export type SettingsSection = {
8
9
  id: string;
@@ -23,85 +24,106 @@ type Props = {
23
24
  onJump: (sectionId: string, fieldKey?: string) => void;
24
25
  };
25
26
 
26
- type Match = {
27
- item: SettingsField | SettingsSection;
28
- score: number;
29
- type: 'section' | 'field';
30
- sectionId: string;
31
- fieldKey?: string;
32
- };
27
+ /* ─── Recent searches (localStorage) ─── */
33
28
 
34
- function fuseSearch(items: SettingsSection[], query: string): Match[] {
35
- if (!query.trim()) return [];
29
+ const RECENT_KEY = 'bizar_settings_recent';
36
30
 
37
- const sectionFuse = new Fuse(items, {
38
- keys: ['label'],
39
- threshold: 0.4,
40
- includeScore: true,
41
- });
31
+ function getRecentSearches(): string[] {
32
+ try {
33
+ const stored = localStorage.getItem(RECENT_KEY);
34
+ return stored ? JSON.parse(stored) : [];
35
+ } catch {
36
+ return [];
37
+ }
38
+ }
42
39
 
43
- const fieldFuse = new Fuse(
44
- items.flatMap((s) => s.fields.map((f) => ({ ...f, _sectionId: s.id }))),
45
- { keys: ['label', 'key'], threshold: 0.4, includeScore: true },
46
- );
40
+ function saveRecentSearch(query: string) {
41
+ if (!query) return;
42
+ try {
43
+ const recent = getRecentSearches().filter((q) => q !== query);
44
+ recent.unshift(query);
45
+ localStorage.setItem(RECENT_KEY, JSON.stringify(recent.slice(0, 5)));
46
+ } catch {
47
+ // localStorage may be full or disabled — silently ignore
48
+ }
49
+ }
47
50
 
48
- const results: Match[] = [];
51
+ /* ─── Highlight matched terms ─── */
49
52
 
50
- for (const r of sectionFuse.search(query)) {
51
- results.push({
52
- item: r.item,
53
- score: r.score ?? 1,
54
- type: 'section',
55
- sectionId: r.item.id,
56
- });
57
- }
53
+ function highlightMatch(text: string, query: string): React.ReactNode {
54
+ if (!query) return text;
55
+ // Escape regex special characters in the user query
56
+ const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
57
+ const regex = new RegExp(`(${escaped})`, 'gi');
58
+ const parts = text.split(regex);
59
+ return parts.map((part, i) =>
60
+ regex.test(part) ? <mark key={i}>{part}</mark> : <span key={i}>{part}</span>,
61
+ );
62
+ }
63
+
64
+ /* ─── Build SearchableItems from sections ─── */
58
65
 
59
- for (const r of fieldFuse.search(query)) {
60
- results.push({
61
- item: r.item as SettingsField,
62
- score: r.score ?? 1,
63
- type: 'field',
64
- sectionId: (r.item as SettingsField & { _sectionId: string })._sectionId,
65
- fieldKey: r.item.key,
66
+ function toSearchableItems(sections: SettingsSection[]): SearchableItem[] {
67
+ const items: SearchableItem[] = [];
68
+ for (const section of sections) {
69
+ // Section itself is searchable
70
+ items.push({
71
+ key: section.id,
72
+ label: section.label,
73
+ section: section.id,
74
+ description: `${section.fields.length} field${section.fields.length === 1 ? '' : 's'}`,
66
75
  });
76
+ // Each field is searchable
77
+ for (const field of section.fields) {
78
+ items.push({
79
+ key: field.key,
80
+ label: field.label,
81
+ section: section.id,
82
+ value: field.value,
83
+ });
84
+ }
67
85
  }
68
-
69
- // Deduplicate by sectionId+fieldKey, prefer lower score
70
- const seen = new Set<string>();
71
- return results
72
- .filter((r) => {
73
- const k = `${r.sectionId}::${r.fieldKey ?? ''}`;
74
- if (seen.has(k)) return false;
75
- seen.add(k);
76
- return true;
77
- })
78
- .sort((a, b) => a.score - b.score)
79
- .slice(0, 8);
86
+ return items;
80
87
  }
81
88
 
89
+ /* ─── Component ─── */
90
+
82
91
  export function SettingsSearch({ sections, onJump }: Props) {
83
92
  const [q, setQ] = useState('');
84
- const [matches, setMatches] = useState<Match[]>([]);
93
+ const [matches, setMatches] = useState<SearchResult[]>([]);
85
94
  const [activeIdx, setActiveIdx] = useState(0);
86
95
  const [highlighted, setHighlighted] = useState<string | null>(null);
96
+ const [focused, setFocused] = useState(false);
87
97
  const inputRef = useRef<HTMLInputElement>(null);
88
98
  const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
89
99
  const highlightTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
90
100
 
101
+ const allItems = useRef<SearchableItem[]>([]);
102
+ // Rebuild items when sections change
103
+ useEffect(() => {
104
+ allItems.current = toSearchableItems(sections);
105
+ }, [sections]);
106
+
91
107
  const search = useCallback(
92
- (query: string) => {
108
+ (query: string, immediate = false) => {
93
109
  if (debounceRef.current) clearTimeout(debounceRef.current);
94
110
  if (!query.trim()) {
95
111
  setMatches([]);
96
112
  setActiveIdx(0);
97
113
  return;
98
114
  }
99
- debounceRef.current = setTimeout(() => {
100
- setMatches(fuseSearch(sections, query));
115
+ const run = () => {
116
+ const results = fuzzySearch(query, allItems.current);
117
+ setMatches(results);
101
118
  setActiveIdx(0);
102
- }, 200);
119
+ };
120
+ if (immediate) {
121
+ run();
122
+ } else {
123
+ debounceRef.current = setTimeout(run, 150);
124
+ }
103
125
  },
104
- [sections],
126
+ [], // stable — allItems is a ref
105
127
  );
106
128
 
107
129
  useEffect(() => {
@@ -116,6 +138,25 @@ export function SettingsSearch({ sections, onJump }: Props) {
116
138
  };
117
139
  }, []);
118
140
 
141
+ /* ─── Quick-jump with flash ─── */
142
+
143
+ const jumpToSection = (sectionId: string, key: string) => {
144
+ // Look for the field by data-setting-id within the section
145
+ const fieldEl = document.querySelector(
146
+ `[data-section="${sectionId}"] [data-setting-id="${key}"]`,
147
+ );
148
+ if (fieldEl) {
149
+ fieldEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
150
+ fieldEl.classList.add('setting-flash');
151
+ setTimeout(() => fieldEl.classList.remove('setting-flash'), 2000);
152
+ }
153
+ // Also scroll the section Card into view
154
+ const sectionEl = document.querySelector(`[data-section="${sectionId}"]`);
155
+ if (sectionEl) {
156
+ sectionEl.scrollIntoView({ behavior: 'smooth', block: fieldEl ? 'nearest' : 'start' });
157
+ }
158
+ };
159
+
119
160
  const highlightSection = (sectionId: string) => {
120
161
  setHighlighted(sectionId);
121
162
  if (highlightTimerRef.current) clearTimeout(highlightTimerRef.current);
@@ -124,6 +165,24 @@ export function SettingsSearch({ sections, onJump }: Props) {
124
165
  if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
125
166
  };
126
167
 
168
+ const commitSelection = (result: SearchResult) => {
169
+ saveRecentSearch(q);
170
+ if (result.key === result.section) {
171
+ // It's a section match — jump to section
172
+ highlightSection(result.section);
173
+ onJump(result.section);
174
+ } else {
175
+ // It's a field match — jump to field with flash
176
+ highlightSection(result.section);
177
+ onJump(result.section, result.key);
178
+ jumpToSection(result.section, result.key);
179
+ }
180
+ setQ('');
181
+ inputRef.current?.blur();
182
+ };
183
+
184
+ /* ─── Keyboard navigation ─── */
185
+
127
186
  const handleKey = (e: React.KeyboardEvent) => {
128
187
  if (e.key === 'Escape') {
129
188
  setQ('');
@@ -134,20 +193,45 @@ export function SettingsSearch({ sections, onJump }: Props) {
134
193
  } else if (e.key === 'ArrowUp') {
135
194
  e.preventDefault();
136
195
  setActiveIdx((i) => Math.max(i - 1, 0));
137
- } else if (e.key === 'Enter' && matches[activeIdx]) {
138
- const m = matches[activeIdx];
139
- highlightSection(m.sectionId);
140
- onJump(m.sectionId, m.fieldKey);
141
- setQ('');
196
+ } else if (e.key === 'Enter') {
197
+ e.preventDefault();
198
+ // If debounce hasn't fired yet, search immediately
199
+ if (matches.length === 0 && q.trim()) {
200
+ search(q, true);
201
+ }
202
+ if (matches[activeIdx]) {
203
+ commitSelection(matches[activeIdx]);
204
+ }
142
205
  }
143
206
  };
144
207
 
145
- const handleSelect = (m: Match) => {
146
- highlightSection(m.sectionId);
147
- onJump(m.sectionId, m.fieldKey);
148
- setQ('');
208
+ const handleSelect = (m: SearchResult) => {
209
+ commitSelection(m);
149
210
  };
150
211
 
212
+ /* ─── Recent searches helpers ─── */
213
+
214
+ const recent = getRecentSearches();
215
+
216
+ const handleRecentClick = (term: string) => {
217
+ setQ(term);
218
+ inputRef.current?.focus();
219
+ };
220
+
221
+ const clearRecent = () => {
222
+ try {
223
+ localStorage.removeItem(RECENT_KEY);
224
+ } catch {
225
+ // ignore
226
+ }
227
+ // Force re-render
228
+ setFocused((f) => f);
229
+ };
230
+
231
+ /* ─── Render ─── */
232
+
233
+ const showRecent = focused && !q.trim() && recent.length > 0;
234
+
151
235
  return (
152
236
  <div className="settings-search">
153
237
  <div className="settings-search-input-wrap">
@@ -159,6 +243,8 @@ export function SettingsSearch({ sections, onJump }: Props) {
159
243
  value={q}
160
244
  onChange={(e) => setQ(e.target.value)}
161
245
  onKeyDown={handleKey}
246
+ onFocus={() => setFocused(true)}
247
+ onBlur={() => setTimeout(() => setFocused(false), 150)}
162
248
  aria-label="Search settings"
163
249
  />
164
250
  {q && (
@@ -173,35 +259,64 @@ export function SettingsSearch({ sections, onJump }: Props) {
173
259
  )}
174
260
  </div>
175
261
 
262
+ {/* Recent searches dropdown */}
263
+ {showRecent && (
264
+ <div className="settings-search-recent">
265
+ <div className="settings-search-recent-header">
266
+ <span className="settings-search-recent-label">Recent</span>
267
+ <button
268
+ type="button"
269
+ className="settings-search-recent-clear"
270
+ onClick={clearRecent}
271
+ tabIndex={-1}
272
+ >
273
+ Clear
274
+ </button>
275
+ </div>
276
+ {recent.map((term) => (
277
+ <button
278
+ key={term}
279
+ type="button"
280
+ className="settings-search-recent-item"
281
+ onMouseDown={(e) => {
282
+ e.preventDefault();
283
+ handleRecentClick(term);
284
+ }}
285
+ >
286
+ <Search size={11} />
287
+ <span>{term}</span>
288
+ </button>
289
+ ))}
290
+ </div>
291
+ )}
292
+
293
+ {/* Search results */}
176
294
  {q && matches.length > 0 && (
177
295
  <div className="settings-search-results">
178
- {matches.map((m, idx) => {
179
- const label = m.type === 'section'
180
- ? (m.item as SettingsSection).label
181
- : (m.item as SettingsField).label;
182
- const key = m.type === 'section'
183
- ? (m.item as SettingsSection).id
184
- : (m.item as SettingsField).key;
185
- return (
186
- <button
187
- key={`${m.sectionId}::${key}`}
188
- type="button"
189
- className={cn('settings-search-result', idx === activeIdx && 'settings-search-result-active')}
190
- onClick={() => handleSelect(m)}
191
- onMouseEnter={() => setActiveIdx(idx)}
192
- >
193
- <span className={cn('settings-search-result-type', m.type === 'section' && 'settings-search-result-type-section')}>
194
- {m.type === 'section' ? 'section' : 'field'}
195
- </span>
196
- <span className="settings-search-result-label">
197
- {label}
198
- </span>
199
- {m.type === 'field' && (
200
- <span className="settings-search-result-key mono muted">{key}</span>
296
+ {matches.map((m, idx) => (
297
+ <button
298
+ key={`${m.section}::${m.key}`}
299
+ type="button"
300
+ className={cn('settings-search-result', idx === activeIdx && 'settings-search-result-active')}
301
+ onClick={() => handleSelect(m)}
302
+ onMouseEnter={() => setActiveIdx(idx)}
303
+ >
304
+ <span
305
+ className={cn(
306
+ 'settings-search-result-type',
307
+ m.key === m.section && 'settings-search-result-type-section',
201
308
  )}
202
- </button>
203
- );
204
- })}
309
+ >
310
+ {m.key === m.section ? 'section' : 'field'}
311
+ </span>
312
+ <span className="settings-search-result-label">
313
+ {highlightMatch(m.label, q)}
314
+ </span>
315
+ {m.key !== m.section && (
316
+ <span className="settings-search-result-key mono muted">{m.key}</span>
317
+ )}
318
+ </button>
319
+ ))}
205
320
  </div>
206
321
  )}
207
322
 
@@ -0,0 +1,115 @@
1
+ // src/web/lib/search.ts — fuzzy search with typo tolerance (Levenshtein distance).
2
+ // Provides SearchableItem/SearchResult types and a fuzzySearch function for
3
+ // settings, commands, and any structured UI search.
4
+
5
+ export interface SearchableItem {
6
+ key: string;
7
+ label: string;
8
+ section: string;
9
+ value?: string | number | boolean;
10
+ description?: string;
11
+ }
12
+
13
+ export interface SearchResult extends SearchableItem {
14
+ score: number;
15
+ matchedField: 'label' | 'value' | 'description';
16
+ matchedTerm: string;
17
+ }
18
+
19
+ /**
20
+ * Levenshtein distance for typo tolerance.
21
+ */
22
+ export function levenshtein(a: string, b: string): number {
23
+ const alen = a.length;
24
+ const blen = b.length;
25
+ const matrix: number[][] = Array.from({ length: blen + 1 }, () => Array(alen + 1).fill(0));
26
+ for (let i = 0; i <= alen; i++) matrix[0][i] = i;
27
+ for (let j = 0; j <= blen; j++) matrix[j][0] = j;
28
+ for (let j = 1; j <= blen; j++) {
29
+ for (let i = 1; i <= alen; i++) {
30
+ const indicator = a[i - 1] === b[j - 1] ? 0 : 1;
31
+ matrix[j][i] = Math.min(
32
+ matrix[j][i - 1] + 1,
33
+ matrix[j - 1][i] + 1,
34
+ matrix[j - 1][i - 1] + indicator,
35
+ );
36
+ }
37
+ }
38
+ return matrix[blen][alen];
39
+ }
40
+
41
+ /**
42
+ * Score a single field against the query.
43
+ * Returns a numeric score (higher = better match) or 0 if no match.
44
+ *
45
+ * Scoring tiers (mutually exclusive — first match wins):
46
+ * 100 — exact field match (query === field)
47
+ * 90 — field starts with query (prefix match)
48
+ * 80 — a word in the field starts with query (word boundary)
49
+ * 40 — typo-tolerant match (Levenshtein ≤ 2 against the first query.length chars)
50
+ * 30 — substring match anywhere in field (fallback)
51
+ */
52
+ export function scoreField(query: string, field: string): number {
53
+ // Exact match
54
+ if (field === query) return 100;
55
+
56
+ // Field prefix match (field starts with query)
57
+ if (field.startsWith(query)) return 90;
58
+
59
+ // Word boundary match (any word starts with query)
60
+ const words = field.split(/\s+/);
61
+ for (const word of words) {
62
+ if (word.startsWith(query)) return 80;
63
+ }
64
+
65
+ // Typo tolerance: compare query against first query.length chars of field
66
+ const compareLen = Math.min(query.length, field.length);
67
+ const dist = levenshtein(query, field.substring(0, compareLen));
68
+ if (dist <= 2) return 40;
69
+
70
+ // Substring match (anywhere)
71
+ if (field.includes(query)) return 30;
72
+
73
+ return 0;
74
+ }
75
+
76
+ /**
77
+ * Fuzzy search over an array of SearchableItem.
78
+ * Returns results sorted by descending score. Empty query returns [].
79
+ */
80
+ export function fuzzySearch(query: string, items: SearchableItem[]): SearchResult[] {
81
+ const q = query.toLowerCase().trim();
82
+ if (!q) return [];
83
+
84
+ return items
85
+ .map((item) => {
86
+ const labelScore = scoreField(q, item.label.toLowerCase());
87
+ const valueScore = item.value ? scoreField(q, String(item.value).toLowerCase()) : 0;
88
+ const descScore = item.description ? scoreField(q, item.description.toLowerCase()) : 0;
89
+
90
+ const max = Math.max(labelScore, valueScore, descScore);
91
+ if (max === 0) return null;
92
+
93
+ const matchedField: SearchResult['matchedField'] =
94
+ labelScore >= valueScore && labelScore >= descScore
95
+ ? 'label'
96
+ : valueScore >= labelScore && valueScore >= descScore
97
+ ? 'value'
98
+ : 'description';
99
+ const matchedTerm =
100
+ matchedField === 'label'
101
+ ? item.label
102
+ : matchedField === 'value'
103
+ ? String(item.value)
104
+ : item.description!;
105
+
106
+ return {
107
+ ...item,
108
+ score: max,
109
+ matchedField,
110
+ matchedTerm,
111
+ };
112
+ })
113
+ .filter((r): r is SearchResult => r !== null)
114
+ .sort((a, b) => b.score - a.score);
115
+ }
@@ -1,9 +1,9 @@
1
1
  // src/mobile/views/MobileSettings.tsx — mobile settings with full desktop parity.
2
2
  import { useEffect, useState } from 'react';
3
- import { QrCode, RefreshCw, Smartphone, Sun, Moon, Monitor, Save } from 'lucide-react';
4
- import { QRCodeSVG } from 'qrcode.react';
3
+ import { Smartphone, Sun, Moon, Monitor, Save } from 'lucide-react';
5
4
  import { api } from '../../lib/api';
6
5
  import { applyTheme, applyThemeTokens, type Settings, type Snapshot, type ThemeName } from '../../lib/types';
6
+ import { QrCodePanel } from './QrCodePanel';
7
7
 
8
8
  type Props = {
9
9
  settings: Settings;
@@ -30,21 +30,12 @@ const PRESET_ACCENTS = [
30
30
  { name: 'Mono', accent: '#6b7280' },
31
31
  ];
32
32
 
33
- function formatCountdown(ms: number): string {
34
- if (ms <= 0) return 'expired';
35
- const s = Math.floor(ms / 1000);
36
- const m = Math.floor(s / 60);
37
- const r = s % 60;
38
- return `${m}:${String(r).padStart(2, '0')}`;
39
- }
40
-
41
33
  export function MobileSettings({ settings: initial, snapshot, onRefresh }: Props) {
42
34
  const [settings, setSettings] = useState<Settings>(initial);
43
35
  const [dirty, setDirty] = useState(false);
44
36
  const [saving, setSaving] = useState(false);
45
37
  const [pair, setPair] = useState<PairSession | null>(null);
46
38
  const [pairing, setPairing] = useState(false);
47
- const [now, setNow] = useState(Date.now());
48
39
 
49
40
  useEffect(() => {
50
41
  setSettings(initial);
@@ -52,12 +43,6 @@ export function MobileSettings({ settings: initial, snapshot, onRefresh }: Props
52
43
  if (initial.theme) applyThemeTokens(initial.theme);
53
44
  }, [initial]);
54
45
 
55
- useEffect(() => {
56
- if (!pair) return;
57
- const t = setInterval(() => setNow(Date.now()), 1000);
58
- return () => clearInterval(t);
59
- }, [pair]);
60
-
61
46
  const patchTheme = (patch: Partial<Settings['theme']>) => {
62
47
  setSettings((cur) => {
63
48
  const next = { ...cur, theme: { ...cur.theme, ...patch } };
@@ -101,8 +86,8 @@ export function MobileSettings({ settings: initial, snapshot, onRefresh }: Props
101
86
  }
102
87
  };
103
88
 
104
- const remaining = pair ? pair.expiresAt - now : 0;
105
- const expired = pair != null && remaining <= 0;
89
+ // remaining/expired are now managed inside QrCodePanel to keep
90
+ // qrcode.react out of the main mobile bundle chunk.
106
91
 
107
92
  return (
108
93
  <div className="mobile-view">
@@ -332,24 +317,14 @@ export function MobileSettings({ settings: initial, snapshot, onRefresh }: Props
332
317
  <div className="mobile-card">
333
318
  {!pair && (
334
319
  <button type="button" className="mobile-btn" onClick={startPair} disabled={pairing} style={{ width: '100%' }}>
335
- <QrCode size={14} /> {pairing ? 'Generating…' : 'Generate QR Code'}
320
+ <Smartphone size={14} /> {pairing ? 'Generating…' : 'Generate QR Code'}
336
321
  </button>
337
322
  )}
338
- {pair && !expired && (
339
- <div style={{ textAlign: 'center' }}>
340
- <div style={{ background: '#fff', padding: 12, borderRadius: 12, display: 'inline-block' }}>
341
- <QRCodeSVG value={pair.qrPayload} size={180} level="M" />
342
- </div>
343
- <div style={{ marginTop: 8, fontSize: 12 }}>
344
- Expires in <strong>{formatCountdown(remaining)}</strong>
345
- </div>
346
- <div className="mono" style={{ fontSize: 10, wordBreak: 'break-all', marginTop: 4 }}>{pair.publicUrl}</div>
347
- </div>
348
- )}
349
- {pair && expired && (
350
- <button type="button" className="mobile-btn" onClick={startPair} style={{ width: '100%' }}>
351
- <RefreshCw size={14} /> Generate new QR
352
- </button>
323
+ {pair && (
324
+ <QrCodePanel
325
+ pair={pair}
326
+ onStart={startPair}
327
+ />
353
328
  )}
354
329
  </div>
355
330
  </section>