clawport-ui 0.1.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 (132) hide show
  1. package/.env.example +35 -0
  2. package/BRANDING.md +131 -0
  3. package/CLAUDE.md +252 -0
  4. package/README.md +262 -0
  5. package/SETUP.md +337 -0
  6. package/app/agents/[id]/page.tsx +727 -0
  7. package/app/api/agents/route.ts +12 -0
  8. package/app/api/chat/[id]/route.ts +139 -0
  9. package/app/api/cron-runs/route.ts +13 -0
  10. package/app/api/crons/route.ts +12 -0
  11. package/app/api/kanban/chat/[id]/route.ts +119 -0
  12. package/app/api/kanban/chat-history/[ticketId]/route.ts +36 -0
  13. package/app/api/memory/route.ts +12 -0
  14. package/app/api/transcribe/route.ts +37 -0
  15. package/app/api/tts/route.ts +42 -0
  16. package/app/chat/[id]/page.tsx +10 -0
  17. package/app/chat/page.tsx +200 -0
  18. package/app/crons/page.tsx +870 -0
  19. package/app/docs/page.tsx +399 -0
  20. package/app/favicon.ico +0 -0
  21. package/app/globals.css +692 -0
  22. package/app/kanban/page.tsx +327 -0
  23. package/app/layout.tsx +45 -0
  24. package/app/memory/page.tsx +685 -0
  25. package/app/page.tsx +817 -0
  26. package/app/providers.tsx +37 -0
  27. package/app/settings/page.tsx +901 -0
  28. package/app/settings-provider.tsx +209 -0
  29. package/components/AgentAvatar.tsx +54 -0
  30. package/components/AgentNode.tsx +122 -0
  31. package/components/Breadcrumbs.tsx +126 -0
  32. package/components/DynamicFavicon.tsx +62 -0
  33. package/components/ErrorState.tsx +97 -0
  34. package/components/FeedView.tsx +494 -0
  35. package/components/GlobalSearch.tsx +571 -0
  36. package/components/GridView.tsx +532 -0
  37. package/components/ManorMap.tsx +157 -0
  38. package/components/MobileSidebar.tsx +251 -0
  39. package/components/NavLinks.tsx +271 -0
  40. package/components/OnboardingWizard.tsx +1067 -0
  41. package/components/Sidebar.tsx +115 -0
  42. package/components/ThemeToggle.tsx +108 -0
  43. package/components/chat/AgentList.tsx +537 -0
  44. package/components/chat/ConversationView.tsx +1047 -0
  45. package/components/chat/FileAttachment.tsx +140 -0
  46. package/components/chat/MediaPreview.tsx +111 -0
  47. package/components/chat/VoiceMessage.tsx +139 -0
  48. package/components/crons/PipelineGraph.tsx +327 -0
  49. package/components/crons/WeeklySchedule.tsx +630 -0
  50. package/components/docs/AgentsSection.tsx +209 -0
  51. package/components/docs/ApiReferenceSection.tsx +256 -0
  52. package/components/docs/ArchitectureSection.tsx +221 -0
  53. package/components/docs/ComponentsSection.tsx +253 -0
  54. package/components/docs/CronSystemSection.tsx +235 -0
  55. package/components/docs/DocSection.tsx +346 -0
  56. package/components/docs/GettingStartedSection.tsx +169 -0
  57. package/components/docs/ThemingSection.tsx +257 -0
  58. package/components/docs/TroubleshootingSection.tsx +200 -0
  59. package/components/kanban/AgentPicker.tsx +321 -0
  60. package/components/kanban/CreateTicketModal.tsx +333 -0
  61. package/components/kanban/KanbanBoard.tsx +70 -0
  62. package/components/kanban/KanbanColumn.tsx +166 -0
  63. package/components/kanban/TicketCard.tsx +245 -0
  64. package/components/kanban/TicketDetailPanel.tsx +850 -0
  65. package/components/ui/badge.tsx +48 -0
  66. package/components/ui/button.tsx +64 -0
  67. package/components/ui/card.tsx +92 -0
  68. package/components/ui/dialog.tsx +158 -0
  69. package/components/ui/scroll-area.tsx +58 -0
  70. package/components/ui/separator.tsx +28 -0
  71. package/components/ui/skeleton.tsx +27 -0
  72. package/components/ui/tabs.tsx +91 -0
  73. package/components/ui/tooltip.tsx +57 -0
  74. package/components.json +23 -0
  75. package/docs/API.md +648 -0
  76. package/docs/COMPONENTS.md +1059 -0
  77. package/docs/THEMING.md +795 -0
  78. package/lib/agents-registry.ts +35 -0
  79. package/lib/agents.json +282 -0
  80. package/lib/agents.test.ts +367 -0
  81. package/lib/agents.ts +32 -0
  82. package/lib/anthropic.test.ts +422 -0
  83. package/lib/anthropic.ts +220 -0
  84. package/lib/api-error.ts +16 -0
  85. package/lib/audio-recorder.test.ts +72 -0
  86. package/lib/audio-recorder.ts +169 -0
  87. package/lib/conversations.test.ts +331 -0
  88. package/lib/conversations.ts +117 -0
  89. package/lib/cron-pipelines.test.ts +69 -0
  90. package/lib/cron-pipelines.ts +58 -0
  91. package/lib/cron-runs.test.ts +118 -0
  92. package/lib/cron-runs.ts +67 -0
  93. package/lib/cron-utils.test.ts +222 -0
  94. package/lib/cron-utils.ts +160 -0
  95. package/lib/crons.test.ts +502 -0
  96. package/lib/crons.ts +114 -0
  97. package/lib/env.test.ts +44 -0
  98. package/lib/env.ts +14 -0
  99. package/lib/kanban/automation.test.ts +245 -0
  100. package/lib/kanban/automation.ts +143 -0
  101. package/lib/kanban/chat-store.test.ts +149 -0
  102. package/lib/kanban/chat-store.ts +81 -0
  103. package/lib/kanban/store.test.ts +238 -0
  104. package/lib/kanban/store.ts +98 -0
  105. package/lib/kanban/types.ts +50 -0
  106. package/lib/kanban/useAgentWork.ts +78 -0
  107. package/lib/memory.ts +45 -0
  108. package/lib/multimodal.test.ts +219 -0
  109. package/lib/multimodal.ts +68 -0
  110. package/lib/pipeline.integration.test.ts +343 -0
  111. package/lib/sanitize.ts +194 -0
  112. package/lib/settings.test.ts +137 -0
  113. package/lib/settings.ts +94 -0
  114. package/lib/styles.ts +24 -0
  115. package/lib/themes.ts +9 -0
  116. package/lib/transcribe.test.ts +141 -0
  117. package/lib/transcribe.ts +111 -0
  118. package/lib/types.ts +66 -0
  119. package/lib/utils.ts +6 -0
  120. package/lib/validation.test.ts +132 -0
  121. package/lib/validation.ts +80 -0
  122. package/next.config.ts +7 -0
  123. package/package.json +56 -0
  124. package/postcss.config.mjs +7 -0
  125. package/public/file.svg +1 -0
  126. package/public/globe.svg +1 -0
  127. package/public/next.svg +1 -0
  128. package/public/vercel.svg +1 -0
  129. package/public/window.svg +1 -0
  130. package/scripts/setup.mjs +215 -0
  131. package/tsconfig.json +34 -0
  132. package/vitest.config.ts +17 -0
@@ -0,0 +1,194 @@
1
+ /**
2
+ * HTML sanitization and safe markdown rendering utilities.
3
+ *
4
+ * Design:
5
+ * - escapeHtml() handles the 5 critical HTML special characters
6
+ * - MarkdownRenderer is a configurable pipeline: escape first, then transform
7
+ * - Open/Closed: add new renderers via the `rules` array without modifying core
8
+ * - Dependency Inversion: consumers depend on the MarkdownRule interface, not
9
+ * a specific implementation
10
+ */
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Core escape function
14
+ // ---------------------------------------------------------------------------
15
+
16
+ const HTML_ESCAPE_MAP: Record<string, string> = {
17
+ "&": "&amp;",
18
+ "<": "&lt;",
19
+ ">": "&gt;",
20
+ '"': "&quot;",
21
+ "'": "&#x27;",
22
+ };
23
+
24
+ const HTML_ESCAPE_RE = /[&<>"']/g;
25
+
26
+ /**
27
+ * Escape all HTML-significant characters so that the string is safe
28
+ * to embed inside an HTML document (both element content and attributes).
29
+ */
30
+ export function escapeHtml(text: string): string {
31
+ return text.replace(HTML_ESCAPE_RE, (ch) => HTML_ESCAPE_MAP[ch]);
32
+ }
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // Markdown rendering pipeline
36
+ // ---------------------------------------------------------------------------
37
+
38
+ /**
39
+ * A single markdown-to-HTML transformation rule.
40
+ * Rules are applied in order after the input has already been HTML-escaped.
41
+ */
42
+ export interface MarkdownRule {
43
+ /** Human-readable name for debugging / extensibility */
44
+ name: string;
45
+ /** Regex to match against the escaped text */
46
+ pattern: RegExp;
47
+ /** Replacement string (may use $1, $2, etc.) */
48
+ replacement: string;
49
+ }
50
+
51
+ /** Default rules that ship with the renderer. */
52
+ export const DEFAULT_MARKDOWN_RULES: MarkdownRule[] = [
53
+ {
54
+ name: "h4",
55
+ pattern: /^#### (.+)$/gm,
56
+ replacement:
57
+ '<h4 class="text-[15px] font-semibold" style="color:var(--text-primary);margin-top:1rem;margin-bottom:0.25rem">$1</h4>',
58
+ },
59
+ {
60
+ name: "h3",
61
+ pattern: /^### (.+)$/gm,
62
+ replacement:
63
+ '<h3 class="text-[17px] font-semibold" style="color:var(--text-primary);margin-top:1.25rem;margin-bottom:0.375rem">$1</h3>',
64
+ },
65
+ {
66
+ name: "h2",
67
+ pattern: /^## (.+)$/gm,
68
+ replacement:
69
+ '<h2 class="text-[22px] font-semibold" style="color:var(--text-primary);margin-top:1.5rem;margin-bottom:0.5rem;padding-bottom:0.25rem;border-bottom:1px solid var(--separator)">$1</h2>',
70
+ },
71
+ {
72
+ name: "h1",
73
+ pattern: /^# (.+)$/gm,
74
+ replacement:
75
+ '<h1 class="text-[28px] font-bold" style="color:var(--text-primary);margin-top:1rem;margin-bottom:0.75rem">$1</h1>',
76
+ },
77
+ {
78
+ name: "bold",
79
+ pattern: /\*\*(.+?)\*\*/g,
80
+ replacement:
81
+ '<strong class="font-semibold" style="color:var(--text-primary)">$1</strong>',
82
+ },
83
+ {
84
+ name: "inline-code",
85
+ pattern: /`([^`]+)`/g,
86
+ replacement:
87
+ '<code style="background:var(--fill-secondary);color:var(--accent);padding:2px 6px;border-radius:6px;font-size:13px;font-family:var(--font-mono)">$1</code>',
88
+ },
89
+ {
90
+ name: "unordered-list",
91
+ pattern: /^- (.+)$/gm,
92
+ replacement:
93
+ '<li class="ml-4 text-[15px] leading-[1.7] list-disc" style="color:var(--text-secondary)">$1</li>',
94
+ },
95
+ {
96
+ name: "ordered-list",
97
+ pattern: /^(\d+)\. (.+)$/gm,
98
+ replacement:
99
+ '<li class="ml-4 text-[15px] leading-[1.7] list-decimal" style="color:var(--text-secondary)">$2</li>',
100
+ },
101
+ {
102
+ name: "paragraph-break",
103
+ pattern: /\n{2,}/g,
104
+ replacement:
105
+ '</p><p class="mb-3" style="color:var(--text-secondary)">',
106
+ },
107
+ {
108
+ name: "line-break",
109
+ pattern: /\n/g,
110
+ replacement: "<br/>",
111
+ },
112
+ ];
113
+
114
+ export interface MarkdownRendererOptions {
115
+ /** Override or extend the default rules */
116
+ rules?: MarkdownRule[];
117
+ }
118
+
119
+ /**
120
+ * Render a plain-text markdown string to safe HTML.
121
+ *
122
+ * The pipeline is:
123
+ * 1. Escape ALL HTML entities (neutralises any injected markup)
124
+ * 2. Apply markdown transformation rules in order
125
+ *
126
+ * Because escaping happens first, captured groups ($1 etc.) only ever
127
+ * contain escaped text — no raw HTML can slip through.
128
+ */
129
+ export function renderMarkdown(
130
+ text: string,
131
+ options?: MarkdownRendererOptions
132
+ ): string {
133
+ const rules = options?.rules ?? DEFAULT_MARKDOWN_RULES;
134
+
135
+ // Step 1 — escape (this is the security boundary)
136
+ let html = escapeHtml(text);
137
+
138
+ // Step 2 — apply markdown transformations on the safe string
139
+ for (const rule of rules) {
140
+ html = html.replace(rule.pattern, rule.replacement);
141
+ }
142
+
143
+ return html;
144
+ }
145
+
146
+ // ---------------------------------------------------------------------------
147
+ // JSON colorizer (safe)
148
+ // ---------------------------------------------------------------------------
149
+
150
+ /** Default rules for JSON syntax highlighting (applied after escaping). */
151
+ export const JSON_COLORIZE_RULES: MarkdownRule[] = [
152
+ {
153
+ name: "json-key",
154
+ pattern: /&quot;((?:(?!&quot;).)*?)&quot;(?=\s*:)/g,
155
+ replacement:
156
+ '<span style="color:var(--accent)">&quot;$1&quot;</span>',
157
+ },
158
+ {
159
+ name: "json-string-value",
160
+ pattern: /:\s*&quot;((?:(?!&quot;).)*?)&quot;/g,
161
+ replacement:
162
+ ': <span style="color:var(--system-green)">&quot;$1&quot;</span>',
163
+ },
164
+ {
165
+ name: "json-number",
166
+ pattern: /:\s*(\d+\.?\d*)/g,
167
+ replacement: ': <span style="color:var(--system-blue)">$1</span>',
168
+ },
169
+ {
170
+ name: "json-boolean",
171
+ pattern: /:\s*(true|false)/g,
172
+ replacement: ': <span style="color:#bf5af2">$1</span>',
173
+ },
174
+ {
175
+ name: "json-null",
176
+ pattern: /:\s*(null)/g,
177
+ replacement:
178
+ ': <span style="color:var(--text-tertiary)">$1</span>',
179
+ },
180
+ ];
181
+
182
+ /**
183
+ * Syntax-highlight a JSON string safely.
184
+ * Escapes HTML first, then applies colorization rules.
185
+ */
186
+ export function colorizeJson(json: string): string {
187
+ let html = escapeHtml(json);
188
+
189
+ for (const rule of JSON_COLORIZE_RULES) {
190
+ html = html.replace(rule.pattern, rule.replacement);
191
+ }
192
+
193
+ return html;
194
+ }
@@ -0,0 +1,137 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest'
2
+ import { loadSettings, saveSettings, hexToAccentFill } from './settings'
3
+
4
+ // Mock localStorage
5
+ const store: Record<string, string> = {}
6
+ const localStorageMock = {
7
+ getItem: vi.fn((key: string) => store[key] ?? null),
8
+ setItem: vi.fn((key: string, value: string) => {
9
+ store[key] = value
10
+ }),
11
+ removeItem: vi.fn((key: string) => {
12
+ delete store[key]
13
+ }),
14
+ clear: vi.fn(() => {
15
+ Object.keys(store).forEach((k) => delete store[k])
16
+ }),
17
+ }
18
+ Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock })
19
+
20
+ beforeEach(() => {
21
+ localStorageMock.clear()
22
+ vi.clearAllMocks()
23
+ })
24
+
25
+ describe('loadSettings', () => {
26
+ it('returns defaults when nothing is stored', () => {
27
+ const s = loadSettings()
28
+ expect(s).toEqual({
29
+ accentColor: null,
30
+ portalName: null,
31
+ portalSubtitle: null,
32
+ portalEmoji: null,
33
+ portalIcon: null,
34
+ iconBgHidden: false,
35
+ emojiOnly: false,
36
+ operatorName: null,
37
+ agentOverrides: {},
38
+ })
39
+ })
40
+
41
+ it('parses stored settings correctly', () => {
42
+ store['clawport-settings'] = JSON.stringify({
43
+ accentColor: '#3B82F6',
44
+ portalName: 'HQ',
45
+ portalSubtitle: 'Base',
46
+ portalEmoji: '🚀',
47
+ portalIcon: 'data:image/jpeg;base64,icon123',
48
+ agentOverrides: { jarvis: { emoji: '🎯' } },
49
+ })
50
+ const s = loadSettings()
51
+ expect(s.accentColor).toBe('#3B82F6')
52
+ expect(s.portalName).toBe('HQ')
53
+ expect(s.portalSubtitle).toBe('Base')
54
+ expect(s.portalEmoji).toBe('🚀')
55
+ expect(s.portalIcon).toBe('data:image/jpeg;base64,icon123')
56
+ expect(s.agentOverrides.jarvis).toEqual({ emoji: '🎯' })
57
+ })
58
+
59
+ it('returns defaults for invalid JSON', () => {
60
+ store['clawport-settings'] = 'not-json{{'
61
+ const s = loadSettings()
62
+ expect(s.accentColor).toBeNull()
63
+ expect(s.agentOverrides).toEqual({})
64
+ })
65
+
66
+ it('handles partial/malformed data gracefully', () => {
67
+ store['clawport-settings'] = JSON.stringify({
68
+ accentColor: 42,
69
+ portalName: true,
70
+ agentOverrides: 'not-an-object',
71
+ })
72
+ const s = loadSettings()
73
+ expect(s.accentColor).toBeNull()
74
+ expect(s.portalName).toBeNull()
75
+ expect(s.portalEmoji).toBeNull()
76
+ expect(s.portalIcon).toBeNull()
77
+ expect(s.agentOverrides).toEqual({})
78
+ })
79
+ })
80
+
81
+ describe('saveSettings', () => {
82
+ it('persists settings to localStorage', () => {
83
+ const settings = {
84
+ accentColor: '#EF4444',
85
+ portalName: 'Test',
86
+ portalSubtitle: null,
87
+ portalEmoji: null,
88
+ portalIcon: null,
89
+ iconBgHidden: false,
90
+ emojiOnly: false,
91
+ operatorName: null,
92
+ agentOverrides: {},
93
+ }
94
+ saveSettings(settings)
95
+ expect(localStorageMock.setItem).toHaveBeenCalledWith(
96
+ 'clawport-settings',
97
+ JSON.stringify(settings),
98
+ )
99
+ })
100
+
101
+ it('round-trips through load', () => {
102
+ const settings = {
103
+ accentColor: '#22C55E',
104
+ portalName: 'Green HQ',
105
+ portalSubtitle: 'Ops Center',
106
+ portalEmoji: '🏠',
107
+ portalIcon: 'data:image/png;base64,test',
108
+ iconBgHidden: false,
109
+ emojiOnly: false,
110
+ operatorName: null,
111
+ agentOverrides: {
112
+ vera: { emoji: '🧙', profileImage: 'data:image/jpeg;base64,abc' },
113
+ },
114
+ }
115
+ saveSettings(settings)
116
+ const loaded = loadSettings()
117
+ expect(loaded).toEqual(settings)
118
+ })
119
+ })
120
+
121
+ describe('hexToAccentFill', () => {
122
+ it('converts gold hex to rgba at 0.15 alpha', () => {
123
+ expect(hexToAccentFill('#F5C518')).toBe('rgba(245,197,24,0.15)')
124
+ })
125
+
126
+ it('converts blue hex correctly', () => {
127
+ expect(hexToAccentFill('#3B82F6')).toBe('rgba(59,130,246,0.15)')
128
+ })
129
+
130
+ it('converts black hex correctly', () => {
131
+ expect(hexToAccentFill('#000000')).toBe('rgba(0,0,0,0.15)')
132
+ })
133
+
134
+ it('converts white hex correctly', () => {
135
+ expect(hexToAccentFill('#FFFFFF')).toBe('rgba(255,255,255,0.15)')
136
+ })
137
+ })
@@ -0,0 +1,94 @@
1
+ // Settings types + localStorage helpers for ClawPort
2
+
3
+ export interface AgentOverride {
4
+ emoji?: string
5
+ profileImage?: string // base64 data URL
6
+ }
7
+
8
+ export interface ClawPortSettings {
9
+ accentColor: string | null
10
+ portalName: string | null
11
+ portalSubtitle: string | null
12
+ portalEmoji: string | null
13
+ portalIcon: string | null // base64 data URL for custom icon image
14
+ iconBgHidden: boolean // hide colored background on sidebar logo
15
+ emojiOnly: boolean // show emoji avatars without colored background
16
+ operatorName: string | null
17
+ agentOverrides: Record<string, AgentOverride>
18
+ }
19
+
20
+ const STORAGE_KEY = 'clawport-settings'
21
+ const LEGACY_KEY = 'manor-settings'
22
+
23
+ export const DEFAULTS: ClawPortSettings = {
24
+ accentColor: null,
25
+ portalName: null,
26
+ portalSubtitle: null,
27
+ portalEmoji: null,
28
+ portalIcon: null,
29
+ iconBgHidden: false,
30
+ emojiOnly: false,
31
+ operatorName: null,
32
+ agentOverrides: {},
33
+ }
34
+
35
+ export function loadSettings(): ClawPortSettings {
36
+ if (typeof window === 'undefined') return { ...DEFAULTS }
37
+ try {
38
+ let raw = localStorage.getItem(STORAGE_KEY)
39
+ // Migrate from legacy key
40
+ if (!raw) {
41
+ raw = localStorage.getItem(LEGACY_KEY)
42
+ if (raw) {
43
+ localStorage.setItem(STORAGE_KEY, raw)
44
+ localStorage.removeItem(LEGACY_KEY)
45
+ }
46
+ }
47
+ if (!raw) return { ...DEFAULTS }
48
+ const parsed = JSON.parse(raw)
49
+ return {
50
+ accentColor: typeof parsed.accentColor === 'string' ? parsed.accentColor : null,
51
+ portalName: typeof parsed.portalName === 'string' ? parsed.portalName : typeof parsed.manorName === 'string' ? parsed.manorName : null,
52
+ portalSubtitle: typeof parsed.portalSubtitle === 'string' ? parsed.portalSubtitle : typeof parsed.manorSubtitle === 'string' ? parsed.manorSubtitle : null,
53
+ portalEmoji: typeof parsed.portalEmoji === 'string' ? parsed.portalEmoji : typeof parsed.manorEmoji === 'string' ? parsed.manorEmoji : null,
54
+ portalIcon: typeof parsed.portalIcon === 'string' ? parsed.portalIcon : typeof parsed.manorIcon === 'string' ? parsed.manorIcon : null,
55
+ iconBgHidden: typeof parsed.iconBgHidden === 'boolean' ? parsed.iconBgHidden : false,
56
+ emojiOnly: typeof parsed.emojiOnly === 'boolean' ? parsed.emojiOnly : false,
57
+ operatorName: typeof parsed.operatorName === 'string' ? parsed.operatorName : null,
58
+ agentOverrides:
59
+ parsed.agentOverrides && typeof parsed.agentOverrides === 'object'
60
+ ? parsed.agentOverrides
61
+ : {},
62
+ }
63
+ } catch {
64
+ return { ...DEFAULTS }
65
+ }
66
+ }
67
+
68
+ export function saveSettings(settings: ClawPortSettings): void {
69
+ if (typeof window === 'undefined') return
70
+ try {
71
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(settings))
72
+ } catch {}
73
+ }
74
+
75
+ /** Convert hex color to rgba at 0.15 alpha for accent-fill backgrounds */
76
+ export function hexToAccentFill(hex: string): string {
77
+ const r = parseInt(hex.slice(1, 3), 16)
78
+ const g = parseInt(hex.slice(3, 5), 16)
79
+ const b = parseInt(hex.slice(5, 7), 16)
80
+ return `rgba(${r},${g},${b},0.15)`
81
+ }
82
+
83
+ /** Return '#fff' or '#000' depending on which has better contrast against the given hex color */
84
+ export function hexToContrastText(hex: string): string {
85
+ const r = parseInt(hex.slice(1, 3), 16) / 255
86
+ const g = parseInt(hex.slice(3, 5), 16) / 255
87
+ const b = parseInt(hex.slice(5, 7), 16) / 255
88
+ // sRGB luminance (WCAG 2.0)
89
+ const lum =
90
+ 0.2126 * (r <= 0.03928 ? r / 12.92 : ((r + 0.055) / 1.055) ** 2.4) +
91
+ 0.7152 * (g <= 0.03928 ? g / 12.92 : ((g + 0.055) / 1.055) ** 2.4) +
92
+ 0.0722 * (b <= 0.03928 ? b / 12.92 : ((b + 0.055) / 1.055) ** 2.4)
93
+ return lum > 0.4 ? '#000' : '#fff'
94
+ }
package/lib/styles.ts ADDED
@@ -0,0 +1,24 @@
1
+ // Semantic style objects for TypeScript components
2
+ // Use when CSS classes aren't practical (dynamic styles)
3
+
4
+ export const typography = {
5
+ largeTitle: { fontSize: 'var(--text-large-title)', fontWeight: 'var(--weight-bold)', letterSpacing: 'var(--tracking-tight)', lineHeight: 'var(--leading-tight)' },
6
+ title1: { fontSize: 'var(--text-title1)', fontWeight: 'var(--weight-bold)', letterSpacing: 'var(--tracking-tight)', lineHeight: 'var(--leading-tight)' },
7
+ title2: { fontSize: 'var(--text-title2)', fontWeight: 'var(--weight-semibold)', letterSpacing: 'var(--tracking-normal)', lineHeight: 'var(--leading-snug)' },
8
+ title3: { fontSize: 'var(--text-title3)', fontWeight: 'var(--weight-semibold)', letterSpacing: 'var(--tracking-normal)', lineHeight: 'var(--leading-snug)' },
9
+ body: { fontSize: 'var(--text-body)', fontWeight: 'var(--weight-regular)', lineHeight: 'var(--leading-normal)' },
10
+ subheadline: { fontSize: 'var(--text-subheadline)', fontWeight: 'var(--weight-regular)', lineHeight: 'var(--leading-normal)' },
11
+ footnote: { fontSize: 'var(--text-footnote)', fontWeight: 'var(--weight-regular)', lineHeight: 'var(--leading-normal)' },
12
+ caption1: { fontSize: 'var(--text-caption1)', fontWeight: 'var(--weight-regular)', lineHeight: 'var(--leading-normal)' },
13
+ caption2: { fontSize: 'var(--text-caption2)', fontWeight: 'var(--weight-regular)', lineHeight: 'var(--leading-normal)' },
14
+ sectionHeader: { fontSize: 'var(--text-caption2)', fontWeight: 'var(--weight-semibold)', letterSpacing: 'var(--tracking-wide)', textTransform: 'uppercase' as const, color: 'var(--text-tertiary)' },
15
+ } as const
16
+
17
+ export const layout = {
18
+ sidebarWidth: 220,
19
+ detailPanelWidth: 360,
20
+ chatSidebarWidth: 300,
21
+ memorySidebarWidth: 260,
22
+ maxContentWidth: 1200,
23
+ headerHeight: 52,
24
+ } as const
package/lib/themes.ts ADDED
@@ -0,0 +1,9 @@
1
+ export type ThemeId = 'dark' | 'glass' | 'color' | 'light' | 'system';
2
+
3
+ export const THEMES: { id: ThemeId; label: string; emoji: string }[] = [
4
+ { id: 'dark', label: 'Dark', emoji: '🌑' },
5
+ { id: 'glass', label: 'Glass', emoji: '🪟' },
6
+ { id: 'color', label: 'Color', emoji: '🎨' },
7
+ { id: 'light', label: 'Light', emoji: '☀️' },
8
+ { id: 'system', label: 'System', emoji: '⚙️' },
9
+ ];
@@ -0,0 +1,141 @@
1
+ // @vitest-environment node
2
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
3
+ import { transcribeViaApi, transcribe } from './transcribe'
4
+
5
+ // --- transcribeViaApi ---
6
+
7
+ describe('transcribeViaApi', () => {
8
+ const originalFetch = globalThis.fetch
9
+
10
+ beforeEach(() => {
11
+ globalThis.fetch = vi.fn()
12
+ })
13
+
14
+ afterEach(() => {
15
+ globalThis.fetch = originalFetch
16
+ })
17
+
18
+ it('returns transcript text on successful API response', async () => {
19
+ ;(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
20
+ ok: true,
21
+ json: async () => ({ text: 'hello world' }),
22
+ })
23
+
24
+ const blob = new Blob(['fake audio'], { type: 'audio/webm' })
25
+ const result = await transcribeViaApi(blob)
26
+ expect(result).toBe('hello world')
27
+ })
28
+
29
+ it('calls /api/transcribe with POST and FormData', async () => {
30
+ ;(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
31
+ ok: true,
32
+ json: async () => ({ text: 'test' }),
33
+ })
34
+
35
+ const blob = new Blob(['audio'], { type: 'audio/webm' })
36
+ await transcribeViaApi(blob)
37
+
38
+ expect(globalThis.fetch).toHaveBeenCalledWith(
39
+ '/api/transcribe',
40
+ expect.objectContaining({ method: 'POST' })
41
+ )
42
+ const callArgs = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0]
43
+ expect(callArgs[1].body).toBeInstanceOf(FormData)
44
+ })
45
+
46
+ it('returns null when API returns non-ok response', async () => {
47
+ ;(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
48
+ ok: false,
49
+ status: 500,
50
+ })
51
+
52
+ const blob = new Blob(['audio'], { type: 'audio/webm' })
53
+ const result = await transcribeViaApi(blob)
54
+ expect(result).toBeNull()
55
+ })
56
+
57
+ it('returns null when API returns empty text', async () => {
58
+ ;(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
59
+ ok: true,
60
+ json: async () => ({ text: '' }),
61
+ })
62
+
63
+ const blob = new Blob(['audio'], { type: 'audio/webm' })
64
+ const result = await transcribeViaApi(blob)
65
+ expect(result).toBeNull()
66
+ })
67
+
68
+ it('returns null when API response has no text field', async () => {
69
+ ;(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
70
+ ok: true,
71
+ json: async () => ({ error: 'no transcript' }),
72
+ })
73
+
74
+ const blob = new Blob(['audio'], { type: 'audio/webm' })
75
+ const result = await transcribeViaApi(blob)
76
+ expect(result).toBeNull()
77
+ })
78
+
79
+ it('returns null on network error', async () => {
80
+ ;(globalThis.fetch as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('Network error'))
81
+
82
+ const blob = new Blob(['audio'], { type: 'audio/webm' })
83
+ const result = await transcribeViaApi(blob)
84
+ expect(result).toBeNull()
85
+ })
86
+ })
87
+
88
+ // --- transcribe (orchestrator) ---
89
+
90
+ describe('transcribe', () => {
91
+ const originalFetch = globalThis.fetch
92
+
93
+ beforeEach(() => {
94
+ globalThis.fetch = vi.fn()
95
+ })
96
+
97
+ afterEach(() => {
98
+ globalThis.fetch = originalFetch
99
+ })
100
+
101
+ it('returns whisper source when API transcription succeeds', async () => {
102
+ ;(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
103
+ ok: true,
104
+ json: async () => ({ text: 'transcribed text' }),
105
+ })
106
+
107
+ const blob = new Blob(['audio'], { type: 'audio/webm' })
108
+ const result = await transcribe(blob)
109
+ expect(result).toEqual({ text: 'transcribed text', source: 'whisper' })
110
+ })
111
+
112
+ it('returns failed source when API transcription fails', async () => {
113
+ ;(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
114
+ ok: false,
115
+ status: 500,
116
+ })
117
+
118
+ const blob = new Blob(['audio'], { type: 'audio/webm' })
119
+ const result = await transcribe(blob)
120
+ expect(result).toEqual({ text: null, source: 'failed' })
121
+ })
122
+
123
+ it('returns failed source on network error', async () => {
124
+ ;(globalThis.fetch as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('offline'))
125
+
126
+ const blob = new Blob(['audio'], { type: 'audio/webm' })
127
+ const result = await transcribe(blob)
128
+ expect(result).toEqual({ text: null, source: 'failed' })
129
+ })
130
+
131
+ it('returns failed source when API returns empty text', async () => {
132
+ ;(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
133
+ ok: true,
134
+ json: async () => ({ text: '' }),
135
+ })
136
+
137
+ const blob = new Blob(['audio'], { type: 'audio/webm' })
138
+ const result = await transcribe(blob)
139
+ expect(result).toEqual({ text: null, source: 'failed' })
140
+ })
141
+ })