@actuate-media/cms-admin 0.56.2 → 0.57.1

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