@actuate-media/cms-admin 0.56.2 → 0.57.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 (72) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/dist/__tests__/views/ai-settings.render.test.js +34 -0
  3. package/dist/__tests__/views/ai-settings.render.test.js.map +1 -1
  4. package/dist/actuate-admin.css +1 -1
  5. package/dist/components/AICoauthorBubbleMenu.d.ts.map +1 -1
  6. package/dist/components/AICoauthorBubbleMenu.js +5 -1
  7. package/dist/components/AICoauthorBubbleMenu.js.map +1 -1
  8. package/dist/components/BrandVoiceAlignmentBadge.d.ts +6 -0
  9. package/dist/components/BrandVoiceAlignmentBadge.d.ts.map +1 -0
  10. package/dist/components/BrandVoiceAlignmentBadge.js +11 -0
  11. package/dist/components/BrandVoiceAlignmentBadge.js.map +1 -0
  12. package/dist/components/CoauthorResultPopover.d.ts +5 -1
  13. package/dist/components/CoauthorResultPopover.d.ts.map +1 -1
  14. package/dist/components/CoauthorResultPopover.js +35 -6
  15. package/dist/components/CoauthorResultPopover.js.map +1 -1
  16. package/dist/components/CommandPalette.d.ts.map +1 -1
  17. package/dist/components/CommandPalette.js +5 -1
  18. package/dist/components/CommandPalette.js.map +1 -1
  19. package/dist/components/seo/SeoEditorDrawer.d.ts.map +1 -1
  20. package/dist/components/seo/SeoEditorDrawer.js +43 -2
  21. package/dist/components/seo/SeoEditorDrawer.js.map +1 -1
  22. package/dist/components/ui/Badge.d.ts +1 -1
  23. package/dist/lib/brand-voice-client.d.ts +21 -0
  24. package/dist/lib/brand-voice-client.d.ts.map +1 -0
  25. package/dist/lib/brand-voice-client.js +27 -0
  26. package/dist/lib/brand-voice-client.js.map +1 -0
  27. package/dist/lib/coauthor-client.d.ts +2 -0
  28. package/dist/lib/coauthor-client.d.ts.map +1 -1
  29. package/dist/lib/coauthor-client.js +1 -0
  30. package/dist/lib/coauthor-client.js.map +1 -1
  31. package/dist/lib/seo-service.d.ts +12 -0
  32. package/dist/lib/seo-service.d.ts.map +1 -1
  33. package/dist/lib/seo-service.js +0 -10
  34. package/dist/lib/seo-service.js.map +1 -1
  35. package/dist/views/Settings.js +1 -1
  36. package/dist/views/Settings.js.map +1 -1
  37. package/dist/views/settings/AISettingsTab.d.ts +3 -1
  38. package/dist/views/settings/AISettingsTab.d.ts.map +1 -1
  39. package/dist/views/settings/AISettingsTab.js +2 -2
  40. package/dist/views/settings/AISettingsTab.js.map +1 -1
  41. package/dist/views/settings/BrandVoiceCard.d.ts +39 -2
  42. package/dist/views/settings/BrandVoiceCard.d.ts.map +1 -1
  43. package/dist/views/settings/BrandVoiceCard.js +215 -3
  44. package/dist/views/settings/BrandVoiceCard.js.map +1 -1
  45. package/dist/views/settings/brand-voice/BrandVoiceAlignmentPanel.d.ts +13 -0
  46. package/dist/views/settings/brand-voice/BrandVoiceAlignmentPanel.d.ts.map +1 -0
  47. package/dist/views/settings/brand-voice/BrandVoiceAlignmentPanel.js +74 -0
  48. package/dist/views/settings/brand-voice/BrandVoiceAlignmentPanel.js.map +1 -0
  49. package/dist/views/settings/brand-voice/BrandVoiceRulesSections.d.ts +7 -0
  50. package/dist/views/settings/brand-voice/BrandVoiceRulesSections.d.ts.map +1 -0
  51. package/dist/views/settings/brand-voice/BrandVoiceRulesSections.js +54 -0
  52. package/dist/views/settings/brand-voice/BrandVoiceRulesSections.js.map +1 -0
  53. package/dist/views/settings/useAiSettings.d.ts +32 -0
  54. package/dist/views/settings/useAiSettings.d.ts.map +1 -1
  55. package/dist/views/settings/useAiSettings.js +85 -3
  56. package/dist/views/settings/useAiSettings.js.map +1 -1
  57. package/package.json +2 -2
  58. package/src/__tests__/views/ai-settings.render.test.tsx +50 -0
  59. package/src/components/AICoauthorBubbleMenu.tsx +12 -0
  60. package/src/components/BrandVoiceAlignmentBadge.tsx +39 -0
  61. package/src/components/CoauthorResultPopover.tsx +62 -4
  62. package/src/components/CommandPalette.tsx +12 -0
  63. package/src/components/seo/SeoEditorDrawer.tsx +65 -1
  64. package/src/lib/brand-voice-client.ts +38 -0
  65. package/src/lib/coauthor-client.ts +4 -0
  66. package/src/lib/seo-service.ts +2 -0
  67. package/src/views/Settings.tsx +1 -1
  68. package/src/views/settings/AISettingsTab.tsx +13 -2
  69. package/src/views/settings/BrandVoiceCard.tsx +592 -76
  70. package/src/views/settings/brand-voice/BrandVoiceAlignmentPanel.tsx +205 -0
  71. package/src/views/settings/brand-voice/BrandVoiceRulesSections.tsx +321 -0
  72. package/src/views/settings/useAiSettings.ts +134 -3
@@ -1,18 +1,93 @@
1
1
  'use client'
2
2
 
3
- import { useId } from 'react'
3
+ import {
4
+ applyStructuredProfileToDocument,
5
+ BRAND_VOICE_EXPORT_FILENAME,
6
+ ensureBrandVoiceDocument,
7
+ parseBrandVoiceDocument,
8
+ parseStructuredProfile,
9
+ type BrandAlignmentScoreResult,
10
+ type BrandVoiceStructuredProfile,
11
+ } from '@actuate-media/cms-core/brand-voice'
12
+ import { Download, FileUp, Link2, Loader2, Sparkles } from 'lucide-react'
13
+ import { useCallback, useId, useMemo, useRef, useState } from 'react'
14
+ import { toast } from 'sonner'
15
+ import { BrandVoiceAlignmentPanel } from './brand-voice/BrandVoiceAlignmentPanel.js'
16
+ import { BrandVoiceRulesSections } from './brand-voice/BrandVoiceRulesSections.js'
4
17
  import { SettingsCard, SettingsToggleRow } from './components.js'
5
- import type { BrandVoiceForm } from './useAiSettings.js'
18
+ import type { BrandVoiceEditMode, BrandVoiceForm } from './useAiSettings.js'
6
19
 
7
20
  const INPUT_CLASS =
8
21
  'w-full rounded-md border border-border bg-input-background px-3 py-2 text-base text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-60'
9
22
  const TEXTAREA_CLASS = `${INPUT_CLASS} min-h-[88px] resize-y`
23
+ const DOCUMENT_TEXTAREA_CLASS = `${INPUT_CLASS} min-h-[280px] resize-y font-mono text-sm`
10
24
  const LABEL_CLASS = 'mb-1 block text-sm font-medium text-foreground'
11
25
 
12
26
  const TONE_MAX = 400
13
27
  const GUIDELINES_MAX = 2000
14
28
  const SAMPLE_MAX = 2000
15
29
 
30
+ export interface BrandVoiceCollectionOption {
31
+ slug: string
32
+ label: string
33
+ }
34
+
35
+ export interface BrandVoiceAnalyzeFn {
36
+ (input: {
37
+ collections: string[]
38
+ urls?: string[]
39
+ uploads?: Array<{ name: string; text: string }>
40
+ }): Promise<{
41
+ ok: boolean
42
+ draft?: Partial<BrandVoiceForm>
43
+ sampleCount?: number
44
+ error?: string
45
+ }>
46
+ }
47
+
48
+ type BrandVoiceScoreInput = {
49
+ content: string
50
+ channel?: string
51
+ }
52
+
53
+ export interface BrandVoiceScoreFn {
54
+ (input: BrandVoiceScoreInput): Promise<{
55
+ ok: boolean
56
+ result?: BrandAlignmentScoreResult
57
+ error?: string
58
+ }>
59
+ }
60
+
61
+ function contentCollections(config: unknown): BrandVoiceCollectionOption[] {
62
+ const cfg = config as { collections?: Record<string, unknown> | unknown[] } | undefined
63
+ if (!cfg?.collections) return []
64
+ const raw = cfg.collections
65
+ const list: Array<{
66
+ slug?: string
67
+ type?: string
68
+ labels?: { plural?: string }
69
+ admin?: { hidden?: boolean }
70
+ }> = Array.isArray(raw)
71
+ ? (raw as typeof list)
72
+ : Object.values(raw as Record<string, (typeof list)[number]>)
73
+ return list
74
+ .filter((c) => (c.type === 'page' || c.type === 'post') && !c.admin?.hidden && c.slug)
75
+ .map((c) => ({
76
+ slug: c.slug!,
77
+ label: c.labels?.plural ?? c.slug!.charAt(0).toUpperCase() + c.slug!.slice(1),
78
+ }))
79
+ }
80
+
81
+ function downloadTextFile(filename: string, contents: string) {
82
+ const blob = new Blob([contents], { type: 'text/markdown;charset=utf-8' })
83
+ const url = URL.createObjectURL(blob)
84
+ const anchor = document.createElement('a')
85
+ anchor.href = url
86
+ anchor.download = filename
87
+ anchor.click()
88
+ URL.revokeObjectURL(url)
89
+ }
90
+
16
91
  /**
17
92
  * Brand voice — a reusable description of how generated copy should sound. When
18
93
  * enabled, it is injected into the system prompt of content-generation features
@@ -22,18 +97,226 @@ export function BrandVoiceCard({
22
97
  value,
23
98
  canEdit,
24
99
  onChange,
100
+ config,
101
+ onAnalyze,
102
+ onScore,
103
+ editMode,
104
+ onEditModeChange,
25
105
  }: {
26
106
  value: BrandVoiceForm
27
107
  canEdit: boolean
28
108
  onChange: (patch: Partial<BrandVoiceForm>) => void
109
+ config?: unknown
110
+ onAnalyze?: BrandVoiceAnalyzeFn
111
+ onScore?: BrandVoiceScoreFn
112
+ editMode: BrandVoiceEditMode
113
+ onEditModeChange: (mode: BrandVoiceEditMode) => void
29
114
  }) {
30
115
  const nameId = useId()
31
116
  const toneId = useId()
32
117
  const audienceId = useId()
33
118
  const guidelinesId = useId()
34
119
  const sampleId = useId()
120
+ const documentId = useId()
121
+ const generatePanelId = useId()
122
+ const importInputId = useId()
123
+ const urlInputId = useId()
124
+ const uploadInputId = useId()
125
+ const importRef = useRef<HTMLInputElement>(null)
126
+ const uploadRef = useRef<HTMLInputElement>(null)
35
127
  const disabled = !canEdit
36
128
 
129
+ const collections = useMemo(() => contentCollections(config), [config])
130
+ const [selectedCollections, setSelectedCollections] = useState<string[]>(() =>
131
+ collections.map((c) => c.slug),
132
+ )
133
+ const [analyzing, setAnalyzing] = useState(false)
134
+ const [lastSampleCount, setLastSampleCount] = useState<number | null>(null)
135
+ const [urlInput, setUrlInput] = useState('')
136
+ const [uploads, setUploads] = useState<Array<{ name: string; text: string }>>([])
137
+ const [importError, setImportError] = useState<string | null>(null)
138
+ const showAdvanced = editMode === 'document'
139
+
140
+ const handleStructuredChange = useCallback(
141
+ (structured: BrandVoiceStructuredProfile) => {
142
+ const document = applyStructuredProfileToDocument(
143
+ value.document.trim() || ensureBrandVoiceDocument(value),
144
+ structured,
145
+ )
146
+ const sampleText =
147
+ structured.workedExamples.find((example) => example.onBrand.trim())?.onBrand ??
148
+ value.sampleText
149
+ onChange({ structured, document, sampleText })
150
+ },
151
+ [onChange, value],
152
+ )
153
+
154
+ const toggleCollection = useCallback((slug: string, checked: boolean) => {
155
+ setSelectedCollections((prev) => {
156
+ if (checked) return prev.includes(slug) ? prev : [...prev, slug]
157
+ return prev.filter((s) => s !== slug)
158
+ })
159
+ }, [])
160
+
161
+ const parsedUrls = useMemo(
162
+ () =>
163
+ urlInput
164
+ .split('\n')
165
+ .map((line) => line.trim())
166
+ .filter(Boolean)
167
+ .slice(0, 5),
168
+ [urlInput],
169
+ )
170
+
171
+ const canAnalyze =
172
+ !disabled &&
173
+ !analyzing &&
174
+ (selectedCollections.length > 0 || parsedUrls.length > 0 || uploads.length > 0)
175
+
176
+ const handleAnalyze = useCallback(async () => {
177
+ if (!onAnalyze || !canAnalyze) return
178
+ setAnalyzing(true)
179
+ setLastSampleCount(null)
180
+ try {
181
+ const res = await onAnalyze({
182
+ collections: selectedCollections,
183
+ urls: parsedUrls.length ? parsedUrls : undefined,
184
+ uploads: uploads.length ? uploads : undefined,
185
+ })
186
+ if (!res.ok || !res.draft) {
187
+ toast.error(res.error ?? 'Brand voice analysis failed.')
188
+ return
189
+ }
190
+ onChange({
191
+ ...res.draft,
192
+ enabled: value.enabled || true,
193
+ version: res.draft.version ?? 1,
194
+ status: res.draft.status ?? 'draft',
195
+ generatedFrom: res.draft.generatedFrom ?? [],
196
+ document: res.draft.document ?? '',
197
+ structured: res.draft.document?.trim()
198
+ ? parseStructuredProfile(res.draft.document)
199
+ : value.structured,
200
+ })
201
+ onEditModeChange('form')
202
+ setLastSampleCount(res.sampleCount ?? null)
203
+ toast.success(
204
+ res.sampleCount
205
+ ? `Draft profile generated from ${res.sampleCount} content sample${res.sampleCount === 1 ? '' : 's'}. Review and save when ready.`
206
+ : 'Draft profile generated. Review and save when ready.',
207
+ )
208
+ } finally {
209
+ setAnalyzing(false)
210
+ }
211
+ }, [
212
+ canAnalyze,
213
+ onAnalyze,
214
+ onChange,
215
+ onEditModeChange,
216
+ parsedUrls,
217
+ selectedCollections,
218
+ uploads,
219
+ value.enabled,
220
+ ])
221
+
222
+ const handleExport = useCallback(() => {
223
+ const markdown = ensureBrandVoiceDocument(value)
224
+ downloadTextFile(BRAND_VOICE_EXPORT_FILENAME, markdown)
225
+ toast.success('Brand voice profile exported.')
226
+ }, [value])
227
+
228
+ const handleImportFile = useCallback(
229
+ async (file: File | undefined) => {
230
+ if (!file) return
231
+ setImportError(null)
232
+ try {
233
+ const text = await file.text()
234
+ const parsed = parseBrandVoiceDocument(text)
235
+ if (!parsed.ok) {
236
+ setImportError(parsed.error)
237
+ toast.error(parsed.error)
238
+ return
239
+ }
240
+ onChange({
241
+ name: parsed.value.fields.name,
242
+ tone: parsed.value.fields.tone,
243
+ audience: parsed.value.fields.audience,
244
+ guidelines: parsed.value.fields.guidelines,
245
+ sampleText: parsed.value.fields.sampleText,
246
+ document: parsed.value.document,
247
+ version: parsed.value.meta.version,
248
+ status: parsed.value.meta.status,
249
+ generatedFrom: parsed.value.meta.generatedFrom,
250
+ structured: parseStructuredProfile(parsed.value.document),
251
+ enabled: value.enabled || true,
252
+ })
253
+ onEditModeChange('document')
254
+ toast.success('Profile imported. Review and save when ready.')
255
+ } catch {
256
+ const message = 'Could not read the selected file.'
257
+ setImportError(message)
258
+ toast.error(message)
259
+ } finally {
260
+ if (importRef.current) importRef.current.value = ''
261
+ }
262
+ },
263
+ [onChange, onEditModeChange, value.enabled],
264
+ )
265
+
266
+ const openAdvanced = useCallback(() => {
267
+ onEditModeChange('document')
268
+ if (!value.document.trim()) {
269
+ onChange({ document: ensureBrandVoiceDocument(value) })
270
+ }
271
+ }, [onChange, onEditModeChange, value])
272
+
273
+ const applyDocumentToFields = useCallback(() => {
274
+ const parsed = parseBrandVoiceDocument(value.document)
275
+ if (!parsed.ok) {
276
+ setImportError(parsed.error)
277
+ toast.error(parsed.error)
278
+ return
279
+ }
280
+ setImportError(null)
281
+ onChange({
282
+ name: parsed.value.fields.name,
283
+ tone: parsed.value.fields.tone,
284
+ audience: parsed.value.fields.audience,
285
+ guidelines: parsed.value.fields.guidelines,
286
+ sampleText: parsed.value.fields.sampleText,
287
+ version: parsed.value.meta.version,
288
+ status: parsed.value.meta.status,
289
+ generatedFrom: parsed.value.meta.generatedFrom,
290
+ structured: parseStructuredProfile(value.document),
291
+ })
292
+ onEditModeChange('form')
293
+ toast.success('Quick-edit fields updated from the profile document.')
294
+ }, [onChange, onEditModeChange, value.document])
295
+
296
+ const handleUploadFiles = useCallback(async (files: FileList | null) => {
297
+ if (!files?.length) return
298
+ const next: Array<{ name: string; text: string }> = []
299
+ for (const file of Array.from(files).slice(0, 5)) {
300
+ if (
301
+ !file.type.startsWith('text/') &&
302
+ !file.name.endsWith('.md') &&
303
+ !file.name.endsWith('.txt')
304
+ ) {
305
+ toast.error(`Skipped ${file.name}: only text or markdown files are supported.`)
306
+ continue
307
+ }
308
+ const text = await file.text()
309
+ if (!text.trim()) continue
310
+ next.push({ name: file.name, text })
311
+ }
312
+ if (next.length === 0) {
313
+ toast.error('No readable text found in the selected files.')
314
+ return
315
+ }
316
+ setUploads((prev) => [...prev, ...next].slice(0, 5))
317
+ toast.success(`Added ${next.length} upload${next.length === 1 ? '' : 's'}.`)
318
+ }, [])
319
+
37
320
  return (
38
321
  <SettingsCard
39
322
  title="Brand voice"
@@ -48,91 +331,324 @@ export function BrandVoiceCard({
48
331
  onChange={(v) => onChange({ enabled: v })}
49
332
  />
50
333
 
51
- <div className="border-border space-y-4 border-t pt-4">
52
- <div>
53
- <label htmlFor={nameId} className={LABEL_CLASS}>
54
- Name
55
- </label>
56
- <input
57
- id={nameId}
58
- type="text"
59
- value={value.name}
334
+ <div className="flex flex-wrap items-center gap-2">
335
+ <button
336
+ type="button"
337
+ onClick={handleExport}
338
+ disabled={disabled}
339
+ className="border-border text-foreground hover:bg-accent inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-50"
340
+ >
341
+ <Download size={14} aria-hidden="true" />
342
+ Export .md
343
+ </button>
344
+ <button
345
+ type="button"
346
+ onClick={() => importRef.current?.click()}
347
+ disabled={disabled}
348
+ className="border-border text-foreground hover:bg-accent inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-50"
349
+ >
350
+ <FileUp size={14} aria-hidden="true" />
351
+ Import .md
352
+ </button>
353
+ <input
354
+ ref={importRef}
355
+ id={importInputId}
356
+ type="file"
357
+ accept=".md,text/markdown,text/plain"
358
+ className="sr-only"
359
+ disabled={disabled}
360
+ onChange={(e) => void handleImportFile(e.target.files?.[0])}
361
+ />
362
+ {!showAdvanced ? (
363
+ <button
364
+ type="button"
365
+ onClick={openAdvanced}
60
366
  disabled={disabled}
61
- placeholder="e.g. Acme Marketing"
62
- onChange={(e) => onChange({ name: e.target.value })}
63
- className={INPUT_CLASS}
64
- />
65
- </div>
66
-
67
- <div>
68
- <label htmlFor={toneId} className={LABEL_CLASS}>
69
- Tone
70
- </label>
71
- <input
72
- id={toneId}
73
- type="text"
74
- value={value.tone}
367
+ className="text-primary hover:text-primary/80 text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-50"
368
+ >
369
+ Advanced profile document
370
+ </button>
371
+ ) : (
372
+ <button
373
+ type="button"
374
+ onClick={() => onEditModeChange('form')}
75
375
  disabled={disabled}
76
- maxLength={TONE_MAX}
77
- placeholder="e.g. confident, warm, plain-spoken"
78
- onChange={(e) => onChange({ tone: e.target.value })}
79
- className={INPUT_CLASS}
80
- />
376
+ className="text-primary hover:text-primary/80 text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-50"
377
+ >
378
+ Back to quick edit
379
+ </button>
380
+ )}
381
+ </div>
382
+
383
+ {importError && (
384
+ <div
385
+ role="alert"
386
+ className="border-destructive/30 bg-destructive/10 text-destructive rounded-md border p-3 text-sm"
387
+ >
388
+ {importError}
81
389
  </div>
390
+ )}
82
391
 
83
- <div>
84
- <label htmlFor={audienceId} className={LABEL_CLASS}>
85
- Audience <span className="text-muted-foreground font-normal">(optional)</span>
86
- </label>
87
- <input
88
- id={audienceId}
89
- type="text"
90
- value={value.audience}
91
- disabled={disabled}
92
- placeholder="e.g. busy IT directors at mid-market SaaS companies"
93
- onChange={(e) => onChange({ audience: e.target.value })}
94
- className={INPUT_CLASS}
95
- />
392
+ {onAnalyze && (
393
+ <div
394
+ id={generatePanelId}
395
+ className="border-border space-y-3 rounded-md border border-dashed p-4"
396
+ >
397
+ <div>
398
+ <p className="text-foreground text-base font-medium">Generate from content</p>
399
+ <p className="text-muted-foreground mt-1 text-sm">
400
+ Scan published pages and posts, public URLs, or uploaded text files to draft a
401
+ profile you can edit before saving.
402
+ </p>
403
+ </div>
404
+
405
+ {collections.length > 0 && (
406
+ <fieldset className="space-y-2" disabled={disabled || analyzing}>
407
+ <legend className="text-foreground mb-1 text-sm font-medium">
408
+ CMS collections
409
+ </legend>
410
+ {collections.map((col) => {
411
+ const inputId = `${generatePanelId}-${col.slug}`
412
+ const checked = selectedCollections.includes(col.slug)
413
+ return (
414
+ <label
415
+ key={col.slug}
416
+ htmlFor={inputId}
417
+ className="text-foreground flex cursor-pointer items-center gap-2 text-sm"
418
+ >
419
+ <input
420
+ id={inputId}
421
+ type="checkbox"
422
+ checked={checked}
423
+ disabled={disabled || analyzing}
424
+ onChange={(e) => toggleCollection(col.slug, e.target.checked)}
425
+ className="border-border text-primary focus-visible:ring-ring size-4 rounded border focus-visible:ring-2 focus-visible:outline-none"
426
+ />
427
+ {col.label}
428
+ </label>
429
+ )
430
+ })}
431
+ </fieldset>
432
+ )}
433
+
434
+ <div>
435
+ <label htmlFor={urlInputId} className={LABEL_CLASS}>
436
+ Public URLs (optional)
437
+ </label>
438
+ <textarea
439
+ id={urlInputId}
440
+ value={urlInput}
441
+ disabled={disabled || analyzing}
442
+ placeholder={'https://example.com/about\nOne URL per line, up to 5'}
443
+ onChange={(e) => setUrlInput(e.target.value)}
444
+ className={`${TEXTAREA_CLASS} min-h-[72px] font-mono text-sm`}
445
+ />
446
+ </div>
447
+
448
+ <div>
449
+ <label htmlFor={uploadInputId} className={LABEL_CLASS}>
450
+ Upload text files (optional)
451
+ </label>
452
+ <div className="flex flex-wrap items-center gap-3">
453
+ <button
454
+ type="button"
455
+ disabled={disabled || analyzing || uploads.length >= 5}
456
+ onClick={() => uploadRef.current?.click()}
457
+ className="border-border text-foreground hover:bg-accent inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-50"
458
+ >
459
+ <FileUp size={16} aria-hidden="true" />
460
+ Choose files
461
+ </button>
462
+ {uploads.length > 0 && (
463
+ <ul className="text-muted-foreground text-sm">
464
+ {uploads.map((file) => (
465
+ <li key={file.name} className="flex items-center gap-2">
466
+ <Link2 size={14} aria-hidden="true" />
467
+ {file.name}
468
+ <button
469
+ type="button"
470
+ className="text-primary text-xs"
471
+ onClick={() =>
472
+ setUploads((prev) => prev.filter((u) => u.name !== file.name))
473
+ }
474
+ >
475
+ Remove
476
+ </button>
477
+ </li>
478
+ ))}
479
+ </ul>
480
+ )}
481
+ </div>
482
+ <input
483
+ ref={uploadRef}
484
+ id={uploadInputId}
485
+ type="file"
486
+ accept=".txt,.md,text/plain,text/markdown"
487
+ multiple
488
+ className="sr-only"
489
+ onChange={(e) => {
490
+ void handleUploadFiles(e.target.files)
491
+ e.target.value = ''
492
+ }}
493
+ />
494
+ </div>
495
+
496
+ <div className="flex flex-wrap items-center gap-3">
497
+ <button
498
+ type="button"
499
+ onClick={handleAnalyze}
500
+ disabled={!canAnalyze}
501
+ className="bg-primary text-primary-foreground inline-flex items-center gap-2 rounded-md px-4 py-2 text-sm font-medium transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
502
+ >
503
+ {analyzing ? (
504
+ <Loader2 size={16} className="animate-spin" aria-hidden="true" />
505
+ ) : (
506
+ <Sparkles size={16} aria-hidden="true" />
507
+ )}
508
+ {analyzing ? 'Analyzing…' : 'Analyze & draft profile'}
509
+ </button>
510
+ {lastSampleCount != null && (
511
+ <p className="text-muted-foreground text-sm" aria-live="polite">
512
+ Last run used {lastSampleCount} sample{lastSampleCount === 1 ? '' : 's'}.
513
+ </p>
514
+ )}
515
+ </div>
96
516
  </div>
517
+ )}
97
518
 
98
- <div>
99
- <label htmlFor={guidelinesId} className={LABEL_CLASS}>
100
- Guidelines <span className="text-muted-foreground font-normal">(optional)</span>
101
- </label>
102
- <textarea
103
- id={guidelinesId}
104
- value={value.guidelines}
105
- disabled={disabled}
106
- maxLength={GUIDELINES_MAX}
107
- placeholder={
108
- 'Do: lead with the benefit, use active voice.\nDon\u2019t: use jargon, exclamation points, or hype.'
109
- }
110
- onChange={(e) => onChange({ guidelines: e.target.value })}
111
- className={TEXTAREA_CLASS}
112
- />
113
- <p className="text-muted-foreground mt-1 text-sm">
114
- {value.guidelines.length}/{GUIDELINES_MAX}
115
- </p>
519
+ {showAdvanced ? (
520
+ <div className="border-border space-y-3 border-t pt-4">
521
+ <div className="flex flex-wrap items-center justify-between gap-3">
522
+ <div>
523
+ <p className="text-foreground text-base font-medium">Profile document</p>
524
+ <p className="text-muted-foreground mt-1 text-sm">
525
+ Portable markdown with YAML rules and human guidance. Saving uses this document as
526
+ the source of truth.
527
+ </p>
528
+ </div>
529
+ <p className="text-muted-foreground text-sm">
530
+ v{value.version} · {value.status}
531
+ </p>
532
+ </div>
533
+ <div>
534
+ <label htmlFor={documentId} className={LABEL_CLASS}>
535
+ brand-voice-profile.md
536
+ </label>
537
+ <textarea
538
+ id={documentId}
539
+ value={value.document}
540
+ disabled={disabled}
541
+ onChange={(e) => onChange({ document: e.target.value })}
542
+ className={DOCUMENT_TEXTAREA_CLASS}
543
+ spellCheck={false}
544
+ />
545
+ </div>
546
+ <div className="flex flex-wrap gap-3">
547
+ <button
548
+ type="button"
549
+ onClick={applyDocumentToFields}
550
+ disabled={disabled || !value.document.trim()}
551
+ className="border-border text-foreground hover:bg-accent rounded-md border px-3 py-2 text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-50"
552
+ >
553
+ Apply to quick-edit fields
554
+ </button>
555
+ </div>
116
556
  </div>
557
+ ) : (
558
+ <div className="border-border space-y-4 border-t pt-4">
559
+ <div>
560
+ <label htmlFor={nameId} className={LABEL_CLASS}>
561
+ Name
562
+ </label>
563
+ <input
564
+ id={nameId}
565
+ type="text"
566
+ value={value.name}
567
+ disabled={disabled}
568
+ placeholder="e.g. Acme Marketing"
569
+ onChange={(e) => onChange({ name: e.target.value })}
570
+ className={INPUT_CLASS}
571
+ />
572
+ </div>
573
+
574
+ <div>
575
+ <label htmlFor={toneId} className={LABEL_CLASS}>
576
+ Tone
577
+ </label>
578
+ <input
579
+ id={toneId}
580
+ type="text"
581
+ value={value.tone}
582
+ disabled={disabled}
583
+ maxLength={TONE_MAX}
584
+ placeholder="e.g. confident, warm, plain-spoken"
585
+ onChange={(e) => onChange({ tone: e.target.value })}
586
+ className={INPUT_CLASS}
587
+ />
588
+ </div>
117
589
 
118
- <div>
119
- <label htmlFor={sampleId} className={LABEL_CLASS}>
120
- Sample copy <span className="text-muted-foreground font-normal">(optional)</span>
121
- </label>
122
- <textarea
123
- id={sampleId}
124
- value={value.sampleText}
590
+ <div>
591
+ <label htmlFor={audienceId} className={LABEL_CLASS}>
592
+ Audience <span className="text-muted-foreground font-normal">(optional)</span>
593
+ </label>
594
+ <input
595
+ id={audienceId}
596
+ type="text"
597
+ value={value.audience}
598
+ disabled={disabled}
599
+ placeholder="e.g. busy IT directors at mid-market SaaS companies"
600
+ onChange={(e) => onChange({ audience: e.target.value })}
601
+ className={INPUT_CLASS}
602
+ />
603
+ </div>
604
+
605
+ <div>
606
+ <label htmlFor={guidelinesId} className={LABEL_CLASS}>
607
+ Guidelines <span className="text-muted-foreground font-normal">(optional)</span>
608
+ </label>
609
+ <textarea
610
+ id={guidelinesId}
611
+ value={value.guidelines}
612
+ disabled={disabled}
613
+ maxLength={GUIDELINES_MAX}
614
+ placeholder={
615
+ 'Do: lead with the benefit, use active voice.\nDon\u2019t: use jargon, exclamation points, or hype.'
616
+ }
617
+ onChange={(e) => onChange({ guidelines: e.target.value })}
618
+ className={TEXTAREA_CLASS}
619
+ />
620
+ <p className="text-muted-foreground mt-1 text-sm">
621
+ {value.guidelines.length}/{GUIDELINES_MAX}
622
+ </p>
623
+ </div>
624
+
625
+ <div>
626
+ <label htmlFor={sampleId} className={LABEL_CLASS}>
627
+ Sample copy <span className="text-muted-foreground font-normal">(optional)</span>
628
+ </label>
629
+ <textarea
630
+ id={sampleId}
631
+ value={value.sampleText}
632
+ disabled={disabled}
633
+ maxLength={SAMPLE_MAX}
634
+ placeholder="Paste a paragraph of on-brand copy the AI can pattern-match against."
635
+ onChange={(e) => onChange({ sampleText: e.target.value })}
636
+ className={TEXTAREA_CLASS}
637
+ />
638
+ <p className="text-muted-foreground mt-1 text-sm">
639
+ {value.sampleText.length}/{SAMPLE_MAX}
640
+ </p>
641
+ </div>
642
+
643
+ <BrandVoiceRulesSections
644
+ structured={value.structured}
125
645
  disabled={disabled}
126
- maxLength={SAMPLE_MAX}
127
- placeholder="Paste a paragraph of on-brand copy the AI can pattern-match against."
128
- onChange={(e) => onChange({ sampleText: e.target.value })}
129
- className={TEXTAREA_CLASS}
646
+ onChange={handleStructuredChange}
130
647
  />
131
- <p className="text-muted-foreground mt-1 text-sm">
132
- {value.sampleText.length}/{SAMPLE_MAX}
133
- </p>
648
+
649
+ {onScore && <BrandVoiceAlignmentPanel disabled={disabled} onScore={onScore} />}
134
650
  </div>
135
- </div>
651
+ )}
136
652
  </div>
137
653
  </SettingsCard>
138
654
  )