@polderlabs/bizar 4.8.0 → 5.0.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 (129) hide show
  1. package/bizar-dash/dist/assets/icons-CFqu2M-c.js +656 -0
  2. package/bizar-dash/dist/assets/icons-CFqu2M-c.js.map +1 -0
  3. package/bizar-dash/dist/assets/index-DmpSFPJY.js +9 -0
  4. package/bizar-dash/dist/assets/index-DmpSFPJY.js.map +1 -0
  5. package/bizar-dash/dist/assets/main-Dl8yY5_H.js +16 -0
  6. package/bizar-dash/dist/assets/main-Dl8yY5_H.js.map +1 -0
  7. package/bizar-dash/dist/assets/{main-DX_Jh8Wc.css → main-ZAfGKENE.css} +1 -1
  8. package/bizar-dash/dist/assets/markdown-DIquRulQ.js +29 -0
  9. package/bizar-dash/dist/assets/markdown-DIquRulQ.js.map +1 -0
  10. package/bizar-dash/dist/assets/mobile-C2gysFOZ.js +2 -0
  11. package/bizar-dash/dist/assets/mobile-C2gysFOZ.js.map +1 -0
  12. package/bizar-dash/dist/assets/mobile-DHXXbn1A.js +1 -0
  13. package/bizar-dash/dist/assets/{mobile-Chvf9u_B.js.map → mobile-DHXXbn1A.js.map} +1 -1
  14. package/bizar-dash/dist/assets/react-vendor-DZRUXSPQ.js +40 -0
  15. package/bizar-dash/dist/assets/react-vendor-DZRUXSPQ.js.map +1 -0
  16. package/bizar-dash/dist/index.html +6 -3
  17. package/bizar-dash/dist/mobile.html +5 -2
  18. package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
  19. package/bizar-dash/skills/eval/SKILL.md +237 -0
  20. package/bizar-dash/src/server/api.mjs +28 -0
  21. package/bizar-dash/src/server/auth.mjs +155 -1
  22. package/bizar-dash/src/server/eval-store.mjs +226 -0
  23. package/bizar-dash/src/server/eval.mjs +347 -0
  24. package/bizar-dash/src/server/memory-lightrag.mjs +109 -0
  25. package/bizar-dash/src/server/memory-store.mjs +121 -0
  26. package/bizar-dash/src/server/ocr.mjs +55 -0
  27. package/bizar-dash/src/server/otel.mjs +133 -0
  28. package/bizar-dash/src/server/plugins/registry.mjs +363 -0
  29. package/bizar-dash/src/server/plugins/sandbox.mjs +655 -0
  30. package/bizar-dash/src/server/plugins/store.mjs +659 -0
  31. package/bizar-dash/src/server/routes/chat.mjs +246 -170
  32. package/bizar-dash/src/server/routes/clipboard.mjs +173 -0
  33. package/bizar-dash/src/server/routes/eval.mjs +147 -0
  34. package/bizar-dash/src/server/routes/memory.mjs +46 -0
  35. package/bizar-dash/src/server/routes/ocr.mjs +182 -0
  36. package/bizar-dash/src/server/routes/opencode-sessions.mjs +82 -48
  37. package/bizar-dash/src/server/routes/plugins.mjs +220 -0
  38. package/bizar-dash/src/server/routes/users.mjs +84 -0
  39. package/bizar-dash/src/server/routes/voice.mjs +131 -0
  40. package/bizar-dash/src/server/routes/workspaces.mjs +204 -0
  41. package/bizar-dash/src/server/server.mjs +40 -0
  42. package/bizar-dash/src/server/voice-store.mjs +202 -0
  43. package/bizar-dash/src/server/voice-transcribe.mjs +72 -0
  44. package/bizar-dash/src/server/workspaces.mjs +626 -0
  45. package/bizar-dash/src/web/components/InviteDialog.tsx +205 -0
  46. package/bizar-dash/src/web/components/ScreenshotCapture.tsx +138 -0
  47. package/bizar-dash/src/web/components/ScreenshotOCR.tsx +42 -0
  48. package/bizar-dash/src/web/components/SettingsSearch.tsx +204 -89
  49. package/bizar-dash/src/web/components/VoiceNotesPanel.tsx +182 -0
  50. package/bizar-dash/src/web/components/VoiceRecorder.tsx +104 -0
  51. package/bizar-dash/src/web/components/WorkspaceSelector.tsx +158 -0
  52. package/bizar-dash/src/web/lib/search.ts +115 -0
  53. package/bizar-dash/src/web/mobile/views/MobileSettings.tsx +10 -35
  54. package/bizar-dash/src/web/mobile/views/QrCodePanel.tsx +69 -0
  55. package/bizar-dash/src/web/styles/memory.css +166 -1
  56. package/bizar-dash/src/web/styles/settings.css +80 -0
  57. package/bizar-dash/src/web/views/Memory.tsx +22 -2
  58. package/bizar-dash/src/web/views/Settings.tsx +99 -0
  59. package/bizar-dash/src/web/views/Workspace.tsx +294 -0
  60. package/bizar-dash/src/web/views/memory/FromScreenshotPanel.tsx +23 -0
  61. package/bizar-dash/src/web/views/memory/MemoryGraphLegend.tsx +29 -0
  62. package/bizar-dash/src/web/views/memory/MemoryGraphPanel.tsx +192 -0
  63. package/bizar-dash/src/web/views/memory/MemoryGraphView.tsx +336 -0
  64. package/bizar-dash/src/web/views/memory/VaultFromClipboardPanel.tsx +101 -0
  65. package/bizar-dash/src/web/views/settings/WorkspacesSection.tsx +189 -0
  66. package/bizar-dash/tests/bundle-analysis.test.mjs +73 -0
  67. package/bizar-dash/tests/clipboard.test.mjs +147 -0
  68. package/bizar-dash/tests/components/screenshot-ocr.test.tsx +75 -0
  69. package/bizar-dash/tests/components/settings-search.test.tsx +180 -0
  70. package/bizar-dash/tests/components/workspace-selector.test.tsx +73 -0
  71. package/bizar-dash/tests/deploy-templates.test.mjs +100 -0
  72. package/bizar-dash/tests/docker-build.test.mjs +96 -0
  73. package/bizar-dash/tests/eval/fixtures.test.mjs +141 -0
  74. package/bizar-dash/tests/eval/report.test.mjs +284 -0
  75. package/bizar-dash/tests/eval/runner.test.mjs +471 -0
  76. package/bizar-dash/tests/lib/search-fuzzy.test.ts +149 -0
  77. package/bizar-dash/tests/memory-graph-view.test.tsx +69 -0
  78. package/bizar-dash/tests/memory-graph.test.mjs +95 -0
  79. package/bizar-dash/tests/ocr.test.mjs +87 -0
  80. package/bizar-dash/tests/otel.test.mjs +188 -0
  81. package/bizar-dash/tests/plugins-registry.test.mjs +387 -0
  82. package/bizar-dash/tests/plugins-sandbox.test.mjs +374 -0
  83. package/bizar-dash/tests/plugins-store.test.mjs +455 -0
  84. package/bizar-dash/tests/users.test.mjs +108 -0
  85. package/bizar-dash/tests/voice-recorder.test.tsx +95 -0
  86. package/bizar-dash/tests/voice-store.test.mjs +148 -0
  87. package/bizar-dash/tests/voice-transcribe.test.mjs +87 -0
  88. package/bizar-dash/tests/workspaces.test.mjs +527 -0
  89. package/cli/bin.mjs +72 -2
  90. package/cli/commands/clip.mjs +146 -0
  91. package/cli/commands/dash.mjs +6 -0
  92. package/cli/commands/deploy/cloudflare.mjs +250 -0
  93. package/cli/commands/deploy/docker.mjs +221 -0
  94. package/cli/commands/deploy/fly.mjs +161 -0
  95. package/cli/commands/deploy/vercel.mjs +225 -0
  96. package/cli/commands/deploy.mjs +240 -0
  97. package/cli/commands/eval.mjs +378 -0
  98. package/cli/commands/marketplace.mjs +64 -0
  99. package/cli/commands/ocr.mjs +165 -0
  100. package/cli/commands/plugin.mjs +358 -0
  101. package/cli/commands/voice.mjs +211 -0
  102. package/cli/commands/workspace.mjs +247 -0
  103. package/package.json +12 -2
  104. package/templates/deploy/cloudflare/README.md +32 -0
  105. package/templates/deploy/cloudflare/functions-index.template.js +15 -0
  106. package/templates/deploy/cloudflare/wrangler.toml.template +9 -0
  107. package/templates/deploy/docker/.env.template +16 -0
  108. package/templates/deploy/docker/README.md +58 -0
  109. package/templates/deploy/docker/docker-compose.template.yml +23 -0
  110. package/templates/deploy/fly/README.md +35 -0
  111. package/templates/deploy/fly/fly.toml.template +28 -0
  112. package/templates/deploy/vercel/README.md +29 -0
  113. package/templates/deploy/vercel/api-index.template.js +18 -0
  114. package/templates/deploy/vercel/vercel.json.template +16 -0
  115. package/templates/eval-fixtures/README.md +58 -0
  116. package/templates/eval-fixtures/code-search-basic.json +28 -0
  117. package/templates/eval-fixtures/latency-bounds.json +16 -0
  118. package/templates/eval-fixtures/regression-suite.json +79 -0
  119. package/templates/eval-fixtures/response-format.json +30 -0
  120. package/templates/eval-fixtures/tool-call-correctness.json +24 -0
  121. package/templates/plugin-template/README.md +121 -0
  122. package/templates/plugin-template/index.js +66 -0
  123. package/templates/plugin-template/plugin.json +42 -0
  124. package/templates/plugin-template/tests/plugin.test.js +83 -0
  125. package/bizar-dash/dist/assets/main-DHZmbnxQ.js +0 -361
  126. package/bizar-dash/dist/assets/main-DHZmbnxQ.js.map +0 -1
  127. package/bizar-dash/dist/assets/mobile-BK8-ythT.js +0 -351
  128. package/bizar-dash/dist/assets/mobile-BK8-ythT.js.map +0 -1
  129. package/bizar-dash/dist/assets/mobile-Chvf9u_B.js +0 -1
@@ -0,0 +1,158 @@
1
+ // src/web/components/WorkspaceSelector.tsx — v5.0.0 — dropdown to switch active workspace.
2
+ import { useState, useEffect, useRef } from 'react';
3
+ import { ChevronDown, Check, Plus, Users } from 'lucide-react';
4
+ import { api } from '../lib/api';
5
+ import { cn } from '../lib/utils';
6
+ import { Button } from './Button';
7
+
8
+ export type WorkspaceInfo = {
9
+ id: string;
10
+ name: string;
11
+ role: string;
12
+ };
13
+
14
+ type Props = {
15
+ currentWorkspaceId: string | null;
16
+ onWorkspaceChange: (workspaceId: string) => void;
17
+ };
18
+
19
+ // Keep a global ref of workspaces so sibling components can refresh
20
+ let _cachedWorkspaces: WorkspaceInfo[] = [];
21
+ const _listeners = new Set<(ws: WorkspaceInfo[]) => void>();
22
+
23
+ function notifyListeners() {
24
+ _listeners.forEach((fn) => fn(_cachedWorkspaces));
25
+ }
26
+
27
+ export function useWorkspaceList() {
28
+ const [workspaces, setWorkspaces] = useState<WorkspaceInfo[]>(_cachedWorkspaces);
29
+
30
+ useEffect(() => {
31
+ _listeners.add(setWorkspaces);
32
+ return () => { _listeners.delete(setWorkspaces); };
33
+ }, []);
34
+
35
+ return workspaces;
36
+ }
37
+
38
+ async function fetchWorkspaces(): Promise<WorkspaceInfo[]> {
39
+ try {
40
+ const r = await api.get<{ workspaces: Array<{ workspace: { id: string; name: string }; role: string }> }>('/workspaces');
41
+ const list = r.workspaces.map((w) => ({ id: w.workspace.id, name: w.workspace.name, role: w.role }));
42
+ _cachedWorkspaces = list;
43
+ notifyListeners();
44
+ return list;
45
+ } catch {
46
+ return [];
47
+ }
48
+ }
49
+
50
+ export function WorkspaceSelector({ currentWorkspaceId, onWorkspaceChange }: Props) {
51
+ const [open, setOpen] = useState(false);
52
+ const [workspaces, setWorkspaces] = useState<WorkspaceInfo[]>(_cachedWorkspaces);
53
+ const [loading, setLoading] = useState(false);
54
+ const ref = useRef<HTMLDivElement>(null);
55
+
56
+ // Listen for global cache updates
57
+ useEffect(() => {
58
+ _listeners.add(setWorkspaces);
59
+ return () => { _listeners.delete(setWorkspaces); };
60
+ }, []);
61
+
62
+ useEffect(() => {
63
+ fetchWorkspaces().catch(() => undefined);
64
+ }, []);
65
+
66
+ // Close on outside click
67
+ useEffect(() => {
68
+ if (!open) return;
69
+ function onOutside(e: MouseEvent) {
70
+ if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
71
+ }
72
+ document.addEventListener('mousedown', onOutside);
73
+ return () => document.removeEventListener('mousedown', onOutside);
74
+ }, [open]);
75
+
76
+ const current = workspaces.find((w) => w.id === currentWorkspaceId) || workspaces[0];
77
+ const canManage = current && (current.role === 'admin');
78
+
79
+ const handleSelect = async (wsId: string) => {
80
+ setOpen(false);
81
+ if (wsId === currentWorkspaceId) return;
82
+ onWorkspaceChange(wsId);
83
+ };
84
+
85
+ const handleRefresh = async () => {
86
+ setLoading(true);
87
+ await fetchWorkspaces();
88
+ setLoading(false);
89
+ };
90
+
91
+ return (
92
+ <div className="workspace-selector" ref={ref}>
93
+ <button
94
+ type="button"
95
+ className={cn('workspace-selector-trigger', open && 'workspace-selector-open')}
96
+ onClick={() => setOpen((v) => !v)}
97
+ aria-expanded={open}
98
+ title={current?.name || 'Select workspace'}
99
+ >
100
+ <Users size={14} />
101
+ <span className="workspace-selector-label">{current?.name || 'Workspace'}</span>
102
+ {canManage && <ChevronDown size={12} className={cn('workspace-selector-chevron', open && 'workspace-selector-chevron-open')} />}
103
+ </button>
104
+
105
+ {open && (
106
+ <div className="workspace-selector-dropdown" role="menu">
107
+ <div className="workspace-selector-header">
108
+ <span className="workspace-selector-title">Workspaces</span>
109
+ <Button
110
+ variant="ghost"
111
+ size="sm"
112
+ iconOnly
113
+ onClick={handleRefresh}
114
+ loading={loading}
115
+ title="Refresh workspaces"
116
+ >
117
+ <span style={{ transform: 'rotate(90deg)' }}>⟳</span>
118
+ </Button>
119
+ </div>
120
+
121
+ <div className="workspace-selector-list">
122
+ {workspaces.length === 0 && (
123
+ <div className="workspace-selector-empty">No workspaces</div>
124
+ )}
125
+ {workspaces.map((ws) => (
126
+ <button
127
+ key={ws.id}
128
+ type="button"
129
+ className={cn('workspace-selector-item', ws.id === currentWorkspaceId && 'workspace-selector-item-active')}
130
+ onClick={() => handleSelect(ws.id)}
131
+ role="menuitem"
132
+ >
133
+ <span className="workspace-selector-item-name">{ws.name}</span>
134
+ <span className={cn('workspace-selector-item-role', `role-${ws.role}`)}>{ws.role}</span>
135
+ {ws.id === currentWorkspaceId && <Check size={12} className="workspace-selector-item-check" />}
136
+ </button>
137
+ ))}
138
+ </div>
139
+
140
+ <div className="workspace-selector-footer">
141
+ <button
142
+ type="button"
143
+ className="workspace-selector-create"
144
+ onClick={() => {
145
+ setOpen(false);
146
+ // Trigger creation flow — emit a custom event that Workspace.tsx listens for
147
+ window.dispatchEvent(new CustomEvent('bizar:workspace:create'));
148
+ }}
149
+ >
150
+ <Plus size={12} />
151
+ New workspace
152
+ </button>
153
+ </div>
154
+ </div>
155
+ )}
156
+ </div>
157
+ );
158
+ }
@@ -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>
@@ -0,0 +1,69 @@
1
+ // src/mobile/views/QrCodePanel.tsx — lazy-loaded QR code panel.
2
+ // Split from MobileSettings to keep qrcode.react (~30 KB) out of the
3
+ // mobile entry chunk; it is only loaded when the user navigates to
4
+ // the Companion App section and taps "Generate QR Code".
5
+ import { useEffect, useState } from 'react';
6
+ import { RefreshCw } from 'lucide-react';
7
+
8
+ type PairSession = { token: string; qrPayload: string; publicUrl: string; expiresAt: number };
9
+
10
+ function formatCountdown(ms: number): string {
11
+ if (ms <= 0) return 'expired';
12
+ const s = Math.floor(ms / 1000);
13
+ const m = Math.floor(s / 60);
14
+ const r = s % 60;
15
+ return `${m}:${String(r).padStart(2, '0')}`;
16
+ }
17
+
18
+ export function QrCodePanel({ pair, onStart }: {
19
+ pair: PairSession;
20
+ onStart: () => void;
21
+ }) {
22
+ const [QRCodeSVG, setQRCodeSVG] = useState<React.ComponentType<{ value: string; size: number; level: string }> | null>(null);
23
+ const [remaining, setRemaining] = useState(() => pair.expiresAt - Date.now());
24
+ const expired = remaining <= 0;
25
+
26
+ useEffect(() => {
27
+ import('qrcode.react').then((mod) => {
28
+ setQRCodeSVG(mod.QRCodeSVG as React.ComponentType<{ value: string; size: number; level: string }>);
29
+ });
30
+ }, []);
31
+
32
+ useEffect(() => {
33
+ if (!pair) return;
34
+ const tick = setInterval(() => {
35
+ setRemaining(pair.expiresAt - Date.now());
36
+ }, 1000);
37
+ return () => clearInterval(tick);
38
+ }, [pair]);
39
+
40
+ if (!QRCodeSVG) {
41
+ return (
42
+ <div style={{ textAlign: 'center', padding: '16px' }}>
43
+ <span style={{ color: 'var(--text-dim)', fontSize: 13 }}>Loading QR library…</span>
44
+ </div>
45
+ );
46
+ }
47
+
48
+ return (
49
+ <div style={{ textAlign: 'center' }}>
50
+ <div style={{ background: '#fff', padding: 12, borderRadius: 12, display: 'inline-block' }}>
51
+ <QRCodeSVG value={pair.qrPayload} size={180} level="M" />
52
+ </div>
53
+ <div style={{ marginTop: 8, fontSize: 12 }}>
54
+ Expires in <strong>{formatCountdown(remaining)}</strong>
55
+ </div>
56
+ <div className="mono" style={{ fontSize: 10, wordBreak: 'break-all', marginTop: 4 }}>{pair.publicUrl}</div>
57
+ {expired && (
58
+ <button
59
+ type="button"
60
+ className="mobile-btn"
61
+ onClick={onStart}
62
+ style={{ marginTop: 12, width: '100%' }}
63
+ >
64
+ <RefreshCw size={14} /> Generate new QR
65
+ </button>
66
+ )}
67
+ </div>
68
+ );
69
+ }
@@ -952,4 +952,169 @@ dl.memory-config-row dd {
952
952
 
953
953
  .memory-status-card-sub {
954
954
  font-size: 11px;
955
- }
955
+ }
956
+
957
+ /* ── Memory Graph ─────────────────────────────────────────────────────── */
958
+
959
+ .memory-graph-canvas {
960
+ display: block;
961
+ background: var(--bg, #0b0e14);
962
+ border: 1px solid var(--border, #232a39);
963
+ border-radius: var(--radius, 8px);
964
+ user-select: none;
965
+ }
966
+
967
+ .memory-graph-canvas-svg {
968
+ height: 480px;
969
+ }
970
+
971
+ .memory-graph-node {
972
+ transition: opacity var(--motion-fast, 120ms);
973
+ }
974
+
975
+ .memory-graph-node:hover circle {
976
+ opacity: 1;
977
+ }
978
+
979
+ .memory-graph-tooltip {
980
+ position: absolute;
981
+ padding: 6px 10px;
982
+ background: var(--bg-elev, #12161f);
983
+ border: 1px solid var(--border, #232a39);
984
+ border-radius: var(--radius-sm, 6px);
985
+ font-size: 12px;
986
+ color: var(--text, #c9d1d9);
987
+ pointer-events: none;
988
+ z-index: 10;
989
+ max-width: 200px;
990
+ white-space: nowrap;
991
+ overflow: hidden;
992
+ text-overflow: ellipsis;
993
+ }
994
+
995
+ .memory-graph-controls {
996
+ display: flex;
997
+ flex-direction: column;
998
+ gap: 10px;
999
+ margin-top: 12px;
1000
+ }
1001
+
1002
+ .memory-graph-search-row {
1003
+ display: flex;
1004
+ gap: 8px;
1005
+ align-items: center;
1006
+ flex-wrap: wrap;
1007
+ }
1008
+
1009
+ .memory-graph-legend {
1010
+ display: flex;
1011
+ gap: 12px;
1012
+ flex-wrap: wrap;
1013
+ align-items: center;
1014
+ }
1015
+
1016
+ .memory-graph-legend-item {
1017
+ display: flex;
1018
+ align-items: center;
1019
+ gap: 6px;
1020
+ font-size: 12px;
1021
+ color: var(--text-dim, #b4bcd0);
1022
+ }
1023
+
1024
+ .memory-graph-legend-dot {
1025
+ display: inline-block;
1026
+ width: 10px;
1027
+ height: 10px;
1028
+ border-radius: 50%;
1029
+ flex-shrink: 0;
1030
+ }
1031
+
1032
+ .memory-graph-stats {
1033
+ display: flex;
1034
+ gap: 8px;
1035
+ align-items: center;
1036
+ font-size: 13px;
1037
+ color: var(--text, #c9d1d9);
1038
+ }
1039
+
1040
+ /* ── Voice Notes ─────────────────────────────────────────────────────────── */
1041
+
1042
+ .voice-recorder {
1043
+ display: flex;
1044
+ align-items: center;
1045
+ gap: 8px;
1046
+ }
1047
+
1048
+ .voice-recorder-row {
1049
+ display: flex;
1050
+ align-items: center;
1051
+ gap: 8px;
1052
+ padding: 8px 0;
1053
+ }
1054
+
1055
+ .voice-note-list {
1056
+ display: flex;
1057
+ flex-direction: column;
1058
+ gap: 6px;
1059
+ }
1060
+
1061
+ .voice-note-card {
1062
+ display: flex;
1063
+ flex-direction: column;
1064
+ gap: 6px;
1065
+ padding: 10px 12px;
1066
+ background: var(--bg-elev, #12161f);
1067
+ border: 1px solid var(--border, #232a39);
1068
+ border-radius: var(--radius, 8px);
1069
+ transition: border-color var(--motion-fast, 120ms);
1070
+ }
1071
+
1072
+ .voice-note-card:hover {
1073
+ border-color: var(--border-strong, #2d3648);
1074
+ }
1075
+
1076
+ .voice-note-card-head {
1077
+ display: flex;
1078
+ align-items: center;
1079
+ justify-content: space-between;
1080
+ gap: 8px;
1081
+ }
1082
+
1083
+ .voice-note-card-meta {
1084
+ display: flex;
1085
+ align-items: center;
1086
+ gap: 8px;
1087
+ font-size: 11px;
1088
+ color: var(--text-dim, #b4bcd0);
1089
+ }
1090
+
1091
+ .voice-note-date {
1092
+ font-family: var(--font-mono, monospace);
1093
+ }
1094
+
1095
+ .voice-note-duration {
1096
+ font-family: var(--font-mono, monospace);
1097
+ padding: 1px 6px;
1098
+ background: var(--bg-elev-2, #1a1f2b);
1099
+ border-radius: 4px;
1100
+ }
1101
+
1102
+ .voice-note-card-actions {
1103
+ display: flex;
1104
+ gap: 4px;
1105
+ flex-shrink: 0;
1106
+ }
1107
+
1108
+ .voice-note-transcript {
1109
+ margin: 0;
1110
+ font-size: 13px;
1111
+ color: var(--text, #c9d1d9);
1112
+ white-space: pre-wrap;
1113
+ word-break: break-word;
1114
+ line-height: 1.5;
1115
+ }
1116
+
1117
+ .voice-note-no-transcript {
1118
+ margin: 0;
1119
+ font-style: italic;
1120
+ }
@@ -77,6 +77,86 @@
77
77
  min-width: 0;
78
78
  }
79
79
 
80
+ /* ─── Setting flash animation (quick-jump highlight) ─── */
81
+ .setting-flash {
82
+ animation: setting-flash 2s ease-out;
83
+ }
84
+
85
+ @keyframes setting-flash {
86
+ 0%, 100% { background-color: transparent; }
87
+ 50% { background-color: var(--accent, rgba(139, 92, 246, 0.1)); border-radius: 6px; }
88
+ }
89
+
90
+ /* Highlight matched terms in search results */
91
+ .settings-search-result-label mark {
92
+ background: var(--accent, rgba(139, 92, 246, 0.2));
93
+ color: inherit;
94
+ border-radius: 2px;
95
+ padding: 0 2px;
96
+ }
97
+
98
+ /* Recent searches dropdown */
99
+ .settings-search-recent {
100
+ position: absolute;
101
+ top: calc(100% + 4px);
102
+ left: 0;
103
+ right: 0;
104
+ background: var(--bg-elev);
105
+ border: 1px solid var(--border);
106
+ border-radius: var(--radius-md);
107
+ box-shadow: var(--shadow-2);
108
+ z-index: 100;
109
+ padding: 4px;
110
+ display: flex;
111
+ flex-direction: column;
112
+ gap: 2px;
113
+ }
114
+
115
+ .settings-search-recent-header {
116
+ display: flex;
117
+ align-items: center;
118
+ justify-content: space-between;
119
+ padding: 4px 8px 2px;
120
+ }
121
+
122
+ .settings-search-recent-label {
123
+ font-size: 10px;
124
+ text-transform: uppercase;
125
+ letter-spacing: 0.06em;
126
+ color: var(--text-dim);
127
+ font-weight: 600;
128
+ }
129
+
130
+ .settings-search-recent-clear {
131
+ font-size: 10px;
132
+ color: var(--accent-2);
133
+ background: none;
134
+ border: none;
135
+ cursor: pointer;
136
+ text-decoration: underline;
137
+ }
138
+
139
+ .settings-search-recent-item {
140
+ display: flex;
141
+ align-items: center;
142
+ gap: 6px;
143
+ padding: 5px 8px;
144
+ border-radius: var(--radius-sm);
145
+ border: none;
146
+ background: transparent;
147
+ color: var(--text-dim);
148
+ font-size: 12px;
149
+ cursor: pointer;
150
+ text-align: left;
151
+ width: 100%;
152
+ transition: background var(--motion-fast);
153
+ }
154
+
155
+ .settings-search-recent-item:hover {
156
+ background: var(--bg-elev-2);
157
+ color: var(--text);
158
+ }
159
+
80
160
  /* Search within settings */
81
161
  .settings-search {
82
162
  position: relative;