@actuate-media/cms-admin 0.35.0 → 0.36.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 (83) hide show
  1. package/dist/__tests__/views/ai-settings.render.test.js +22 -0
  2. package/dist/__tests__/views/ai-settings.render.test.js.map +1 -1
  3. package/dist/actuate-admin.css +1 -1
  4. package/dist/views/ApiKeys.d.ts.map +1 -1
  5. package/dist/views/ApiKeys.js +14 -13
  6. package/dist/views/ApiKeys.js.map +1 -1
  7. package/dist/views/Profile.d.ts.map +1 -1
  8. package/dist/views/Profile.js +1 -1
  9. package/dist/views/Profile.js.map +1 -1
  10. package/dist/views/Settings.d.ts.map +1 -1
  11. package/dist/views/Settings.js +25 -20
  12. package/dist/views/Settings.js.map +1 -1
  13. package/dist/views/Users.js +1 -1
  14. package/dist/views/Users.js.map +1 -1
  15. package/dist/views/settings/AISettingsTab.d.ts.map +1 -1
  16. package/dist/views/settings/AISettingsTab.js +42 -43
  17. package/dist/views/settings/AISettingsTab.js.map +1 -1
  18. package/dist/views/settings/AiFeaturesCard.d.ts.map +1 -1
  19. package/dist/views/settings/AiFeaturesCard.js +2 -3
  20. package/dist/views/settings/AiFeaturesCard.js.map +1 -1
  21. package/dist/views/settings/BrandVoiceCard.d.ts +12 -0
  22. package/dist/views/settings/BrandVoiceCard.d.ts.map +1 -0
  23. package/dist/views/settings/BrandVoiceCard.js +25 -0
  24. package/dist/views/settings/BrandVoiceCard.js.map +1 -0
  25. package/dist/views/settings/GeneralSettingsTab.js +1 -1
  26. package/dist/views/settings/LanguageRegionCard.d.ts.map +1 -1
  27. package/dist/views/settings/LanguageRegionCard.js +2 -1
  28. package/dist/views/settings/LanguageRegionCard.js.map +1 -1
  29. package/dist/views/settings/MonthlyUsageCard.d.ts.map +1 -1
  30. package/dist/views/settings/MonthlyUsageCard.js +3 -3
  31. package/dist/views/settings/MonthlyUsageCard.js.map +1 -1
  32. package/dist/views/settings/ProviderConnectionCard.js +1 -1
  33. package/dist/views/settings/ProviderConnectionCard.js.map +1 -1
  34. package/dist/views/settings/SecurityStatusCard.d.ts.map +1 -1
  35. package/dist/views/settings/SecurityStatusCard.js +2 -3
  36. package/dist/views/settings/SecurityStatusCard.js.map +1 -1
  37. package/dist/views/settings/SecurityTab.js +1 -1
  38. package/dist/views/settings/SeoRobotsDefaultsCard.d.ts.map +1 -1
  39. package/dist/views/settings/SeoRobotsDefaultsCard.js +3 -4
  40. package/dist/views/settings/SeoRobotsDefaultsCard.js.map +1 -1
  41. package/dist/views/settings/SeoSettingsTab.d.ts.map +1 -1
  42. package/dist/views/settings/SeoSettingsTab.js +5 -8
  43. package/dist/views/settings/SeoSettingsTab.js.map +1 -1
  44. package/dist/views/settings/SiteIndexStatusCard.d.ts.map +1 -1
  45. package/dist/views/settings/SiteIndexStatusCard.js +3 -2
  46. package/dist/views/settings/SiteIndexStatusCard.js.map +1 -1
  47. package/dist/views/settings/SiteInformationCard.d.ts.map +1 -1
  48. package/dist/views/settings/SiteInformationCard.js +4 -4
  49. package/dist/views/settings/SiteInformationCard.js.map +1 -1
  50. package/dist/views/settings/components.d.ts +31 -1
  51. package/dist/views/settings/components.d.ts.map +1 -1
  52. package/dist/views/settings/components.js +22 -0
  53. package/dist/views/settings/components.js.map +1 -1
  54. package/dist/views/settings/securityPrimitives.d.ts +11 -3
  55. package/dist/views/settings/securityPrimitives.d.ts.map +1 -1
  56. package/dist/views/settings/securityPrimitives.js +10 -3
  57. package/dist/views/settings/securityPrimitives.js.map +1 -1
  58. package/dist/views/settings/useAiSettings.d.ts +12 -0
  59. package/dist/views/settings/useAiSettings.d.ts.map +1 -1
  60. package/dist/views/settings/useAiSettings.js +51 -0
  61. package/dist/views/settings/useAiSettings.js.map +1 -1
  62. package/package.json +3 -3
  63. package/src/__tests__/views/ai-settings.render.test.tsx +36 -0
  64. package/src/views/ApiKeys.tsx +190 -154
  65. package/src/views/Profile.tsx +4 -2
  66. package/src/views/Settings.tsx +154 -143
  67. package/src/views/Users.tsx +1 -1
  68. package/src/views/settings/AISettingsTab.tsx +66 -76
  69. package/src/views/settings/AiFeaturesCard.tsx +6 -12
  70. package/src/views/settings/BrandVoiceCard.tsx +139 -0
  71. package/src/views/settings/GeneralSettingsTab.tsx +1 -1
  72. package/src/views/settings/LanguageRegionCard.tsx +5 -7
  73. package/src/views/settings/MonthlyUsageCard.tsx +6 -10
  74. package/src/views/settings/ProviderConnectionCard.tsx +1 -1
  75. package/src/views/settings/SecurityStatusCard.tsx +8 -11
  76. package/src/views/settings/SecurityTab.tsx +1 -1
  77. package/src/views/settings/SeoRobotsDefaultsCard.tsx +7 -14
  78. package/src/views/settings/SeoSettingsTab.tsx +17 -26
  79. package/src/views/settings/SiteIndexStatusCard.tsx +10 -22
  80. package/src/views/settings/SiteInformationCard.tsx +3 -9
  81. package/src/views/settings/components.tsx +72 -1
  82. package/src/views/settings/securityPrimitives.tsx +14 -9
  83. package/src/views/settings/useAiSettings.ts +76 -0
@@ -17,6 +17,7 @@ import { MediaPickerModal } from '../../components/MediaPickerModal.js'
17
17
  import { SEOConfigPanel } from '../../components/SEOConfigPanel.js'
18
18
  import {
19
19
  ConfirmDangerousSettingDialog,
20
+ SettingsCard,
20
21
  SettingsSaveBar,
21
22
  SettingsSourceBadge,
22
23
  SettingsToggleRow,
@@ -82,7 +83,7 @@ export function SeoSettingsTab({ onNavigate, role }: SeoSettingsTabProps) {
82
83
  <div className="space-y-4" aria-busy="true" aria-live="polite">
83
84
  <span className="sr-only">Loading SEO settings…</span>
84
85
  {[0, 1, 2, 3].map((i) => (
85
- <div key={i} className="border-border bg-card rounded-lg border p-4">
86
+ <div key={i} className="border-border bg-card rounded-lg border p-6">
86
87
  <div className="bg-muted mb-4 h-4 w-40 animate-pulse rounded" />
87
88
  <div className="space-y-3">
88
89
  <div className="bg-muted h-9 w-full animate-pulse rounded" />
@@ -208,7 +209,6 @@ function SeoWorkspaceCallout({ onNavigate }: { onNavigate?: (path: string) => vo
208
209
  // ---------------------------------------------------------------------------
209
210
 
210
211
  function MetaDefaultsCard({ seo, canEdit }: { seo: UseSeoSettings; canEdit: boolean }) {
211
- const headingId = useId()
212
212
  const templateId = useId()
213
213
  const descId = useId()
214
214
  const [pickerOpen, setPickerOpen] = useState(false)
@@ -229,11 +229,10 @@ function MetaDefaultsCard({ seo, canEdit }: { seo: UseSeoSettings; canEdit: bool
229
229
  const imageEditable = sources.defaultOgImage?.editable !== false && canEdit
230
230
 
231
231
  return (
232
- <section aria-labelledby={headingId} className="border-border bg-card rounded-lg border p-4">
233
- <h3 id={headingId} className="text-foreground mb-4 text-base font-medium">
234
- Meta Defaults
235
- </h3>
236
-
232
+ <SettingsCard
233
+ title="Meta Defaults"
234
+ description="Fallback page title, description, and social image applied when a page defines none of its own."
235
+ >
237
236
  <div className="space-y-4">
238
237
  {/* Title template */}
239
238
  <div>
@@ -362,7 +361,7 @@ function MetaDefaultsCard({ seo, canEdit }: { seo: UseSeoSettings; canEdit: bool
362
361
  onSelect={(url) => setField('defaultOgImage', url)}
363
362
  onSelectItem={(item) => setField('defaultSocialImageAssetId', item.id)}
364
363
  />
365
- </section>
364
+ </SettingsCard>
366
365
  )
367
366
  }
368
367
 
@@ -458,20 +457,14 @@ function VerificationField({
458
457
  }
459
458
 
460
459
  function SearchEngineVerificationCard({ seo, canEdit }: { seo: UseSeoSettings; canEdit: boolean }) {
461
- const headingId = useId()
462
460
  const { form, setField } = seo
463
461
  const onChange = (key: keyof SeoSettingsForm, value: string) => setField(key, value as never)
464
462
 
465
463
  return (
466
- <section aria-labelledby={headingId} className="border-border bg-card rounded-lg border p-4">
467
- <h3 id={headingId} className="text-foreground mb-1 text-base font-medium">
468
- Search Engine Verification
469
- </h3>
470
- <p className="text-muted-foreground mb-4 text-sm">
471
- Paste the verification token from each provider. We render only safe meta tags into your
472
- site&apos;s head — never arbitrary HTML.
473
- </p>
474
-
464
+ <SettingsCard
465
+ title="Search Engine Verification"
466
+ description="Paste the verification token from each provider. We render only safe meta tags into your site's head — never arbitrary HTML."
467
+ >
475
468
  <div className="space-y-4">
476
469
  <VerificationField
477
470
  label="Google Search Console"
@@ -523,7 +516,7 @@ function SearchEngineVerificationCard({ seo, canEdit }: { seo: UseSeoSettings; c
523
516
  />
524
517
  </div>
525
518
  </details>
526
- </section>
519
+ </SettingsCard>
527
520
  )
528
521
  }
529
522
 
@@ -540,7 +533,6 @@ function SitemapDefaultsCard({
540
533
  canEdit: boolean
541
534
  onNavigate?: (path: string) => void
542
535
  }) {
543
- const headingId = useId()
544
536
  const [pendingDisable, setPendingDisable] = useState(false)
545
537
  const { form, setField, sitemap, siteUrl, isProduction, regenerateSitemap, regenerating } = seo
546
538
 
@@ -558,11 +550,10 @@ function SitemapDefaultsCard({
558
550
  : 'Never'
559
551
 
560
552
  return (
561
- <section aria-labelledby={headingId} className="border-border bg-card rounded-lg border p-4">
562
- <h3 id={headingId} className="text-foreground mb-4 text-base font-medium">
563
- Sitemap
564
- </h3>
565
-
553
+ <SettingsCard
554
+ title="Sitemap"
555
+ description="Control XML sitemap generation and whether search engines are pinged on publish."
556
+ >
566
557
  {!siteUrl && (
567
558
  <div className="border-warning/30 bg-warning/10 text-warning mb-4 flex items-start gap-2 rounded-md border p-3 text-sm">
568
559
  <AlertTriangle size={16} className="mt-0.5 shrink-0" aria-hidden="true" />
@@ -644,7 +635,7 @@ function SitemapDefaultsCard({
644
635
  }}
645
636
  onCancel={() => setPendingDisable(false)}
646
637
  />
647
- </section>
638
+ </SettingsCard>
648
639
  )
649
640
  }
650
641
 
@@ -5,11 +5,11 @@ import {
5
5
  XCircle,
6
6
  AlertTriangle,
7
7
  HelpCircle,
8
- ShieldCheck,
9
8
  ExternalLink,
10
9
  Search,
11
10
  } from 'lucide-react'
12
11
  import type { ReactNode } from 'react'
12
+ import { SettingsCard } from './components.js'
13
13
  import type { SiteIndexStatus } from './useGeneralSettings.js'
14
14
 
15
15
  interface SiteIndexStatusCardProps {
@@ -61,23 +61,11 @@ export function SiteIndexStatusCard({
61
61
  onRunAudit,
62
62
  }: SiteIndexStatusCardProps) {
63
63
  return (
64
- <section
65
- aria-labelledby="site-index-status-heading"
66
- className="border-border bg-card rounded-lg border p-4"
67
- >
68
- <div className="mb-3 flex items-start justify-between gap-3">
69
- <div className="flex items-start gap-2">
70
- <ShieldCheck size={18} className="text-muted-foreground mt-0.5" aria-hidden="true" />
71
- <div>
72
- <h3 id="site-index-status-heading" className="text-foreground text-base font-medium">
73
- Site Index Status
74
- </h3>
75
- <p className="text-muted-foreground text-sm">
76
- Current effective crawl and indexing state
77
- </p>
78
- </div>
79
- </div>
80
- {status && (
64
+ <SettingsCard
65
+ title="Site Index Status"
66
+ description="Current effective crawl and indexing state."
67
+ actions={
68
+ status ? (
81
69
  <span
82
70
  className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium ${SEVERITY_STYLES[status.severity].pill}`}
83
71
  >
@@ -85,9 +73,9 @@ export function SiteIndexStatusCard({
85
73
  <span className="sr-only">Status: </span>
86
74
  {status.label}
87
75
  </span>
88
- )}
89
- </div>
90
-
76
+ ) : undefined
77
+ }
78
+ >
91
79
  {loading && !status ? (
92
80
  <div className="space-y-2" aria-hidden="true">
93
81
  <div className="bg-muted h-4 w-2/3 animate-pulse rounded" />
@@ -128,7 +116,7 @@ export function SiteIndexStatusCard({
128
116
  )}
129
117
  </>
130
118
  )}
131
- </section>
119
+ </SettingsCard>
132
120
  )
133
121
  }
134
122
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { useId } from 'react'
4
4
  import { CheckCircle2 } from 'lucide-react'
5
- import { SettingsSourceBadge } from './components.js'
5
+ import { SettingsCard, SettingsSourceBadge } from './components.js'
6
6
  import type {
7
7
  GeneralSettingsForm,
8
8
  SettingsSourceMetadata,
@@ -39,13 +39,7 @@ export function SiteInformationCard({
39
39
  const urlValid = !urlIssue && form.siteUrl.trim().length > 0
40
40
 
41
41
  return (
42
- <section
43
- aria-labelledby={`${titleId}-heading`}
44
- className="border-border bg-card rounded-lg border p-4"
45
- >
46
- <h3 id={`${titleId}-heading`} className="text-foreground mb-4 text-base font-medium">
47
- Site Information
48
- </h3>
42
+ <SettingsCard title="Site Information" description="Your site's name, tagline, and public URL.">
49
43
  <div className="space-y-4">
50
44
  <div>
51
45
  <label htmlFor={titleId} className={LABEL_CLASS}>
@@ -117,6 +111,6 @@ export function SiteInformationCard({
117
111
  )}
118
112
  </div>
119
113
  </div>
120
- </section>
114
+ </SettingsCard>
121
115
  )
122
116
  }
@@ -2,9 +2,80 @@
2
2
 
3
3
  import * as Dialog from '@radix-ui/react-dialog'
4
4
  import { AlertTriangle, Check, Loader2, Lock } from 'lucide-react'
5
- import type { ReactNode } from 'react'
5
+ import { useId, type ReactNode } from 'react'
6
6
  import type { SettingsSourceMetadata, ValidationIssue } from './useGeneralSettings.js'
7
7
 
8
+ /**
9
+ * The canonical Settings section card: a bordered `bg-card` surface with a
10
+ * header (title + optional description) and an optional right-aligned actions
11
+ * slot. This is the single source of truth for the Settings design language —
12
+ * every tab's sections render through it so spacing, padding, typography, and
13
+ * the title/description rhythm stay identical across General, Appearance,
14
+ * Security, SEO, AI, and beyond. Match the Appearance tab exactly: `p-6`,
15
+ * `text-lg` medium title, `text-sm` muted description.
16
+ */
17
+ export function SettingsCard({
18
+ title,
19
+ description,
20
+ actions,
21
+ children,
22
+ className,
23
+ headingLevel: Heading = 'h3',
24
+ }: {
25
+ title: ReactNode
26
+ description?: ReactNode
27
+ /** Right-aligned controls in the header (e.g. a Refresh or Add button). */
28
+ actions?: ReactNode
29
+ children: ReactNode
30
+ className?: string
31
+ /** Override the heading element for correct document outline nesting. */
32
+ headingLevel?: 'h2' | 'h3'
33
+ }) {
34
+ const headingId = useId()
35
+ return (
36
+ <section
37
+ aria-labelledby={headingId}
38
+ className={`border-border bg-card rounded-lg border p-6 ${className ?? ''}`}
39
+ >
40
+ <header className="mb-4 flex items-start justify-between gap-3">
41
+ <div className="min-w-0">
42
+ <Heading id={headingId} className="text-foreground text-lg font-medium">
43
+ {title}
44
+ </Heading>
45
+ {description && <p className="text-muted-foreground mt-0.5 text-sm">{description}</p>}
46
+ </div>
47
+ {actions && <div className="flex shrink-0 items-center gap-2">{actions}</div>}
48
+ </header>
49
+ {children}
50
+ </section>
51
+ )
52
+ }
53
+
54
+ /**
55
+ * A sub-section inside a {@link SettingsCard}: a top divider plus a
56
+ * `text-base` medium label and optional description. Use for grouped controls
57
+ * within a single card (e.g. "Admin branding" under "Branding").
58
+ */
59
+ export function SettingsSubsection({
60
+ title,
61
+ description,
62
+ children,
63
+ className,
64
+ }: {
65
+ title: ReactNode
66
+ description?: ReactNode
67
+ children?: ReactNode
68
+ className?: string
69
+ }) {
70
+ return (
71
+ <div className={`border-border border-t pt-4 ${className ?? ''}`}>
72
+ <div className="text-foreground text-base font-medium">{title}</div>
73
+ {description && <p className="text-muted-foreground mt-0.5 text-sm">{description}</p>}
74
+ {children}
75
+ </div>
76
+ )
77
+ }
78
+
8
79
  const SOURCE_LABEL: Record<SettingsSourceMetadata['source'], string> = {
9
80
  config: 'Config-managed',
10
81
  environment: 'Environment-managed',
@@ -3,6 +3,7 @@
3
3
  import { AlertTriangle, Check, CircleHelp, X } from 'lucide-react'
4
4
  import type { ReactNode } from 'react'
5
5
  import type { CheckStatus } from '@actuate-media/cms-core/security-center'
6
+ import { SettingsCard } from './components.js'
6
7
 
7
8
  /** Small status icon used in check/summary rows. Text label always accompanies it. */
8
9
  export function CheckIcon({ status }: { status: CheckStatus }) {
@@ -51,25 +52,29 @@ export function StatusRow({
51
52
  )
52
53
  }
53
54
 
54
- /** Section wrapper matching the other settings cards. */
55
+ /**
56
+ * Section wrapper for the Security tab. Delegates to the shared
57
+ * {@link SettingsCard} so Security matches the canonical Settings design
58
+ * language (p-6, text-lg title, muted description). `headingId` is accepted
59
+ * for backward compatibility but ignored — {@link SettingsCard} generates and
60
+ * wires its own accessible heading id.
61
+ */
55
62
  export function SecurityCard({
56
- headingId,
57
63
  title,
58
64
  description,
65
+ actions,
59
66
  children,
60
67
  }: {
61
- headingId: string
68
+ /** @deprecated No longer used; SettingsCard manages the heading id. */
69
+ headingId?: string
62
70
  title: string
63
71
  description: string
72
+ actions?: ReactNode
64
73
  children: ReactNode
65
74
  }) {
66
75
  return (
67
- <section aria-labelledby={headingId} className="border-border bg-card rounded-lg border p-4">
68
- <h3 id={headingId} className="text-foreground mb-1 text-base font-medium">
69
- {title}
70
- </h3>
71
- <p className="text-muted-foreground mb-4 text-sm">{description}</p>
76
+ <SettingsCard title={title} description={description} actions={actions}>
72
77
  {children}
73
- </section>
78
+ </SettingsCard>
74
79
  )
75
80
  }
@@ -93,12 +93,32 @@ export interface AiUsageView {
93
93
  runCount: number
94
94
  }
95
95
 
96
+ /** Editable brand voice fields (the buffered Save-bar portion). */
97
+ export interface BrandVoiceForm {
98
+ enabled: boolean
99
+ name: string
100
+ tone: string
101
+ audience: string
102
+ guidelines: string
103
+ sampleText: string
104
+ }
105
+
106
+ const EMPTY_BRAND_VOICE: BrandVoiceForm = {
107
+ enabled: false,
108
+ name: '',
109
+ tone: '',
110
+ audience: '',
111
+ guidelines: '',
112
+ sampleText: '',
113
+ }
114
+
96
115
  /** The buffered (Save-bar) portion of AI settings. */
97
116
  export interface AiSettingsForm {
98
117
  defaultProviderId: string
99
118
  defaultModelId: string
100
119
  features: AiFeatureView[]
101
120
  monthlyTokenLimit: string
121
+ brandVoice: BrandVoiceForm
102
122
  }
103
123
 
104
124
  interface AiSettingsResponse {
@@ -107,6 +127,14 @@ interface AiSettingsResponse {
107
127
  defaultModelId?: string
108
128
  features: AiFeatureView[]
109
129
  budget: { monthlyTokenLimit?: number }
130
+ brandVoice?: {
131
+ enabled?: boolean
132
+ name?: string
133
+ tone?: string
134
+ audience?: string
135
+ guidelines?: string
136
+ sampleText?: string
137
+ }
110
138
  }
111
139
  providers: AiProviderView[]
112
140
  defaultModel: AiModelView | null
@@ -127,6 +155,14 @@ function formFromResponse(res: AiSettingsResponse): AiSettingsForm {
127
155
  typeof res.settings.budget.monthlyTokenLimit === 'number'
128
156
  ? String(res.settings.budget.monthlyTokenLimit)
129
157
  : '',
158
+ brandVoice: {
159
+ enabled: res.settings.brandVoice?.enabled ?? false,
160
+ name: res.settings.brandVoice?.name ?? '',
161
+ tone: res.settings.brandVoice?.tone ?? '',
162
+ audience: res.settings.brandVoice?.audience ?? '',
163
+ guidelines: res.settings.brandVoice?.guidelines ?? '',
164
+ sampleText: res.settings.brandVoice?.sampleText ?? '',
165
+ },
130
166
  }
131
167
  }
132
168
 
@@ -135,6 +171,19 @@ const EMPTY_FORM: AiSettingsForm = {
135
171
  defaultModelId: '',
136
172
  features: [],
137
173
  monthlyTokenLimit: '',
174
+ brandVoice: { ...EMPTY_BRAND_VOICE },
175
+ }
176
+
177
+ /** A brand voice with no content and disabled clears the stored profile (send null). */
178
+ function brandVoiceIsEmpty(bv: BrandVoiceForm): boolean {
179
+ return (
180
+ !bv.enabled &&
181
+ !bv.name.trim() &&
182
+ !bv.tone.trim() &&
183
+ !bv.audience.trim() &&
184
+ !bv.guidelines.trim() &&
185
+ !bv.sampleText.trim()
186
+ )
138
187
  }
139
188
 
140
189
  export interface ProviderInput {
@@ -162,6 +211,8 @@ export interface UseAiSettings {
162
211
  toggleFeature: (key: string, enabled: boolean) => void
163
212
  /** Set a per-feature model override. Empty string reverts to the default model. */
164
213
  setFeatureModel: (key: string, modelId: string) => void
214
+ /** Patch one or more brand voice fields. */
215
+ setBrandVoice: (patch: Partial<BrandVoiceForm>) => void
165
216
  dirty: boolean
166
217
  budgetError: string | null
167
218
  saveState: SaveState
@@ -273,6 +324,11 @@ export function useAiSettings(canEdit: boolean): UseAiSettings {
273
324
  setSaveState('idle')
274
325
  }, [])
275
326
 
327
+ const setBrandVoice = useCallback((patch: Partial<BrandVoiceForm>) => {
328
+ setForm((prev) => ({ ...prev, brandVoice: { ...prev.brandVoice, ...patch } }))
329
+ setSaveState('idle')
330
+ }, [])
331
+
276
332
  const budgetError = useMemo(() => {
277
333
  if (form.monthlyTokenLimit === '') return null
278
334
  const n = Number(form.monthlyTokenLimit)
@@ -285,6 +341,15 @@ export function useAiSettings(canEdit: boolean): UseAiSettings {
285
341
  if (form.defaultModelId !== baseline.defaultModelId) return true
286
342
  if (form.monthlyTokenLimit !== baseline.monthlyTokenLimit) return true
287
343
  if (form.features.length !== baseline.features.length) return true
344
+ const bvKeys: (keyof BrandVoiceForm)[] = [
345
+ 'enabled',
346
+ 'name',
347
+ 'tone',
348
+ 'audience',
349
+ 'guidelines',
350
+ 'sampleText',
351
+ ]
352
+ if (bvKeys.some((k) => form.brandVoice[k] !== baseline.brandVoice[k])) return true
288
353
  return form.features.some((f, i) => {
289
354
  const base = baseline.features[i]
290
355
  return f.enabled !== base?.enabled || (f.modelId ?? '') !== (base?.modelId ?? '')
@@ -310,6 +375,16 @@ export function useAiSettings(canEdit: boolean): UseAiSettings {
310
375
  monthlyTokenLimit:
311
376
  form.monthlyTokenLimit === '' ? null : Number(form.monthlyTokenLimit),
312
377
  },
378
+ brandVoice: brandVoiceIsEmpty(form.brandVoice)
379
+ ? null
380
+ : {
381
+ enabled: form.brandVoice.enabled,
382
+ name: form.brandVoice.name,
383
+ tone: form.brandVoice.tone,
384
+ audience: form.brandVoice.audience,
385
+ guidelines: form.brandVoice.guidelines,
386
+ sampleText: form.brandVoice.sampleText,
387
+ },
313
388
  }),
314
389
  })
315
390
  if (res.error) throw new Error(res.error)
@@ -459,6 +534,7 @@ export function useAiSettings(canEdit: boolean): UseAiSettings {
459
534
  setMonthlyTokenLimit,
460
535
  toggleFeature,
461
536
  setFeatureModel,
537
+ setBrandVoice,
462
538
  dirty,
463
539
  budgetError,
464
540
  saveState,