@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
@@ -0,0 +1,205 @@
1
+ 'use client'
2
+
3
+ import type { BrandAlignmentScoreResult } from '@actuate-media/cms-core/brand-voice'
4
+ import { BRAND_VOICE_CHANNEL_PRESETS } from '@actuate-media/cms-core/brand-voice'
5
+ import { Loader2, Wand2 } from 'lucide-react'
6
+ import { useId, useState } from 'react'
7
+ import { toast } from 'sonner'
8
+ import { fixBrandVoiceContent } from '../../../lib/brand-voice-client.js'
9
+
10
+ const INPUT_CLASS =
11
+ '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'
12
+ const TEXTAREA_CLASS = `${INPUT_CLASS} min-h-[120px] resize-y`
13
+ const LABEL_CLASS = 'mb-1 block text-sm font-medium text-foreground'
14
+
15
+ function scoreTone(score: number): string {
16
+ if (score >= 85) return 'text-success'
17
+ if (score >= 65) return 'text-warning'
18
+ return 'text-destructive'
19
+ }
20
+
21
+ export function BrandVoiceAlignmentPanel({
22
+ disabled,
23
+ onScore,
24
+ }: {
25
+ disabled: boolean
26
+ onScore: (input: { content: string; channel?: string }) => Promise<{
27
+ ok: boolean
28
+ result?: BrandAlignmentScoreResult
29
+ error?: string
30
+ }>
31
+ }) {
32
+ const contentId = useId()
33
+ const channelId = useId()
34
+ const [content, setContent] = useState('')
35
+ const [channel, setChannel] = useState('')
36
+ const [loading, setLoading] = useState(false)
37
+ const [fixing, setFixing] = useState(false)
38
+ const [result, setResult] = useState<BrandAlignmentScoreResult | null>(null)
39
+ const [error, setError] = useState<string | null>(null)
40
+
41
+ async function runScore() {
42
+ if (!content.trim()) return
43
+ setLoading(true)
44
+ setError(null)
45
+ try {
46
+ const res = await onScore({
47
+ content: content.trim(),
48
+ channel: channel || undefined,
49
+ })
50
+ if (!res.ok || !res.result) {
51
+ setResult(null)
52
+ setError(res.error ?? 'Could not score this copy.')
53
+ return
54
+ }
55
+ setResult(res.result)
56
+ } finally {
57
+ setLoading(false)
58
+ }
59
+ }
60
+
61
+ async function runFix() {
62
+ if (!content.trim()) return
63
+ setFixing(true)
64
+ setError(null)
65
+ try {
66
+ const res = await fixBrandVoiceContent({
67
+ content: content.trim(),
68
+ channel: channel || undefined,
69
+ })
70
+ if (!res.ok || !res.result) {
71
+ setError(res.error ?? 'Could not fix this copy.')
72
+ return
73
+ }
74
+ setContent(res.result.text)
75
+ setResult(res.result.afterScore)
76
+ toast.success(
77
+ res.result.usedAi
78
+ ? 'Copy revised to match brand voice.'
79
+ : 'Applied brand voice rule fixes.',
80
+ )
81
+ } finally {
82
+ setFixing(false)
83
+ }
84
+ }
85
+
86
+ return (
87
+ <div className="border-border space-y-4 rounded-md border border-dashed p-4">
88
+ <div>
89
+ <h4 className="text-foreground text-base font-medium">Check alignment</h4>
90
+ <p className="text-muted-foreground mt-1 text-sm">
91
+ Paste draft copy to score it against your vocabulary rules, mechanics, and channel limits.
92
+ </p>
93
+ </div>
94
+
95
+ <div className="grid gap-4 md:grid-cols-[1fr_220px]">
96
+ <div>
97
+ <label htmlFor={contentId} className={LABEL_CLASS}>
98
+ Draft copy
99
+ </label>
100
+ <textarea
101
+ id={contentId}
102
+ value={content}
103
+ disabled={disabled || loading}
104
+ placeholder="Paste a headline, meta description, or paragraph to score."
105
+ onChange={(e) => setContent(e.target.value)}
106
+ className={TEXTAREA_CLASS}
107
+ />
108
+ </div>
109
+ <div>
110
+ <label htmlFor={channelId} className={LABEL_CLASS}>
111
+ Channel (optional)
112
+ </label>
113
+ <select
114
+ id={channelId}
115
+ value={channel}
116
+ disabled={disabled || loading}
117
+ onChange={(e) => setChannel(e.target.value)}
118
+ className={INPUT_CLASS}
119
+ >
120
+ <option value="">General</option>
121
+ {BRAND_VOICE_CHANNEL_PRESETS.map((preset) => (
122
+ <option key={preset.id} value={preset.id}>
123
+ {preset.label}
124
+ </option>
125
+ ))}
126
+ </select>
127
+ <button
128
+ type="button"
129
+ onClick={runScore}
130
+ disabled={disabled || loading || !content.trim()}
131
+ className="bg-primary text-primary-foreground mt-3 inline-flex w-full items-center justify-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"
132
+ >
133
+ {loading && <Loader2 size={16} className="animate-spin" aria-hidden="true" />}
134
+ Score copy
135
+ </button>
136
+ </div>
137
+ </div>
138
+
139
+ {error && (
140
+ <div
141
+ role="alert"
142
+ className="border-destructive/30 bg-destructive/10 text-destructive rounded-md border p-3 text-sm"
143
+ >
144
+ {error}
145
+ </div>
146
+ )}
147
+
148
+ {result && (
149
+ <div className="space-y-3" aria-live="polite">
150
+ <div className="flex flex-wrap items-center gap-3">
151
+ <p className={`text-2xl font-medium ${scoreTone(result.score)}`}>{result.score}/100</p>
152
+ <p className="text-muted-foreground text-sm">
153
+ {result.channel ? `Scored for ${result.channel.replace(/_/g, ' ')}` : 'General score'}
154
+ </p>
155
+ {result.score < 85 && (
156
+ <button
157
+ type="button"
158
+ onClick={runFix}
159
+ disabled={disabled || fixing || loading}
160
+ className="border-border bg-card text-foreground hover:bg-accent inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm disabled:cursor-not-allowed disabled:opacity-50"
161
+ >
162
+ {fixing ? (
163
+ <Loader2 size={16} className="animate-spin" aria-hidden="true" />
164
+ ) : (
165
+ <Wand2 size={16} aria-hidden="true" />
166
+ )}
167
+ Fix to match brand voice
168
+ </button>
169
+ )}
170
+ </div>
171
+
172
+ {result.issues.length > 0 && (
173
+ <ul className="space-y-2">
174
+ {result.issues.map((issue) => (
175
+ <li
176
+ key={issue.id}
177
+ className="border-border bg-muted/30 rounded-md border px-3 py-2 text-sm"
178
+ >
179
+ <p className="text-foreground">{issue.message}</p>
180
+ {issue.suggestion && (
181
+ <p className="text-muted-foreground mt-1">{issue.suggestion}</p>
182
+ )}
183
+ </li>
184
+ ))}
185
+ </ul>
186
+ )}
187
+
188
+ {result.dimensions.length > 0 && (
189
+ <div className="grid gap-2 sm:grid-cols-2">
190
+ {result.dimensions.map((dimension) => (
191
+ <div key={dimension.name} className="border-border rounded-md border px-3 py-2">
192
+ <div className="flex items-center justify-between gap-2">
193
+ <p className="text-foreground text-sm font-medium">{dimension.name}</p>
194
+ <p className={`text-sm ${scoreTone(dimension.score)}`}>{dimension.score}</p>
195
+ </div>
196
+ <p className="text-muted-foreground mt-1 text-sm">{dimension.feedback}</p>
197
+ </div>
198
+ ))}
199
+ </div>
200
+ )}
201
+ </div>
202
+ )}
203
+ </div>
204
+ )
205
+ }
@@ -0,0 +1,321 @@
1
+ 'use client'
2
+
3
+ import type {
4
+ BrandVoiceBannedTerm,
5
+ BrandVoiceChannelRule,
6
+ BrandVoiceStructuredProfile,
7
+ BrandVoiceWorkedExample,
8
+ } from '@actuate-media/cms-core/brand-voice'
9
+ import {
10
+ MAX_BANNED_TERMS,
11
+ MAX_PREFERRED_TERMS,
12
+ MAX_WORKED_EXAMPLES,
13
+ } from '@actuate-media/cms-core/brand-voice'
14
+ import { Plus, Trash2 } from 'lucide-react'
15
+ import { useId } from 'react'
16
+
17
+ const INPUT_CLASS =
18
+ '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'
19
+ const LABEL_CLASS = 'mb-1 block text-sm font-medium text-foreground'
20
+
21
+ export function BrandVoiceRulesSections({
22
+ structured,
23
+ disabled,
24
+ onChange,
25
+ }: {
26
+ structured: BrandVoiceStructuredProfile
27
+ disabled: boolean
28
+ onChange: (next: BrandVoiceStructuredProfile) => void
29
+ }) {
30
+ const preferredId = useId()
31
+ const patternId = useId()
32
+
33
+ function update(patch: Partial<BrandVoiceStructuredProfile>) {
34
+ onChange({ ...structured, ...patch })
35
+ }
36
+
37
+ function updateBanned(index: number, patch: Partial<BrandVoiceBannedTerm>) {
38
+ const bannedTerms = structured.bannedTerms.map((row, i) =>
39
+ i === index ? { ...row, ...patch } : row,
40
+ )
41
+ update({ bannedTerms })
42
+ }
43
+
44
+ function updateChannel(index: number, patch: Partial<BrandVoiceChannelRule>) {
45
+ const channels = structured.channels.map((row, i) => (i === index ? { ...row, ...patch } : row))
46
+ update({ channels })
47
+ }
48
+
49
+ function updateExample(index: number, patch: Partial<BrandVoiceWorkedExample>) {
50
+ const workedExamples = structured.workedExamples.map((row, i) =>
51
+ i === index ? { ...row, ...patch } : row,
52
+ )
53
+ update({ workedExamples })
54
+ }
55
+
56
+ return (
57
+ <div className="space-y-6">
58
+ <section className="space-y-3">
59
+ <div>
60
+ <h4 className="text-foreground text-base font-medium">Vocabulary rules</h4>
61
+ <p className="text-muted-foreground mt-1 text-sm">
62
+ Banned terms produce deterministic alignment errors. Preferred terms are scored as
63
+ bonuses.
64
+ </p>
65
+ </div>
66
+
67
+ <div className="space-y-2">
68
+ <p className="text-foreground text-sm font-medium">Banned terms</p>
69
+ {structured.bannedTerms.length === 0 && (
70
+ <p className="text-muted-foreground text-sm">No banned terms yet.</p>
71
+ )}
72
+ {structured.bannedTerms.map((row, index) => (
73
+ <div key={`banned-${index}`} className="grid gap-2 md:grid-cols-[1fr_1fr_auto]">
74
+ <input
75
+ type="text"
76
+ value={row.term}
77
+ disabled={disabled}
78
+ placeholder="e.g. cheap"
79
+ aria-label={`Banned term ${index + 1}`}
80
+ onChange={(e) => updateBanned(index, { term: e.target.value })}
81
+ className={INPUT_CLASS}
82
+ />
83
+ <input
84
+ type="text"
85
+ value={row.useInstead}
86
+ disabled={disabled}
87
+ placeholder="Use instead (optional)"
88
+ aria-label={`Replacement for banned term ${index + 1}`}
89
+ onChange={(e) => updateBanned(index, { useInstead: e.target.value })}
90
+ className={INPUT_CLASS}
91
+ />
92
+ <button
93
+ type="button"
94
+ disabled={disabled}
95
+ aria-label={`Remove banned term ${index + 1}`}
96
+ onClick={() =>
97
+ update({ bannedTerms: structured.bannedTerms.filter((_, i) => i !== index) })
98
+ }
99
+ className="border-border text-muted-foreground hover:bg-accent inline-flex items-center justify-center rounded-md border px-3 py-2 transition-colors disabled:opacity-50"
100
+ >
101
+ <Trash2 size={16} aria-hidden="true" />
102
+ </button>
103
+ </div>
104
+ ))}
105
+ <button
106
+ type="button"
107
+ disabled={disabled || structured.bannedTerms.length >= MAX_BANNED_TERMS}
108
+ onClick={() =>
109
+ update({ bannedTerms: [...structured.bannedTerms, { term: '', useInstead: '' }] })
110
+ }
111
+ 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:opacity-50"
112
+ >
113
+ <Plus size={14} aria-hidden="true" />
114
+ Add banned term
115
+ </button>
116
+ </div>
117
+
118
+ <div>
119
+ <label htmlFor={preferredId} className={LABEL_CLASS}>
120
+ Preferred terms
121
+ </label>
122
+ <input
123
+ id={preferredId}
124
+ type="text"
125
+ disabled={disabled}
126
+ placeholder="Comma-separated, e.g. ecommerce, sign up"
127
+ value={structured.preferredTerms.join(', ')}
128
+ onChange={(e) =>
129
+ update({
130
+ preferredTerms: e.target.value
131
+ .split(',')
132
+ .map((t) => t.trim())
133
+ .filter(Boolean)
134
+ .slice(0, MAX_PREFERRED_TERMS),
135
+ })
136
+ }
137
+ className={INPUT_CLASS}
138
+ />
139
+ </div>
140
+
141
+ <div>
142
+ <label htmlFor={patternId} className={LABEL_CLASS}>
143
+ Avoid patterns
144
+ </label>
145
+ <textarea
146
+ id={patternId}
147
+ disabled={disabled}
148
+ placeholder={
149
+ 'One per line, e.g.\nhype stacking (multiple superlatives in one sentence)'
150
+ }
151
+ value={structured.avoidPatterns.join('\n')}
152
+ onChange={(e) =>
153
+ update({
154
+ avoidPatterns: e.target.value
155
+ .split('\n')
156
+ .map((line) => line.trim())
157
+ .filter(Boolean),
158
+ })
159
+ }
160
+ className={`${INPUT_CLASS} min-h-[88px] resize-y`}
161
+ />
162
+ </div>
163
+ </section>
164
+
165
+ <section className="space-y-3">
166
+ <div>
167
+ <h4 className="text-foreground text-base font-medium">Channel overrides</h4>
168
+ <p className="text-muted-foreground mt-1 text-sm">
169
+ Length limits and notes used when scoring copy for a specific channel.
170
+ </p>
171
+ </div>
172
+ <div className="space-y-3">
173
+ {structured.channels.map((channel, index) => (
174
+ <div key={channel.id} className="border-border rounded-md border p-4">
175
+ <p className="text-foreground mb-3 text-sm font-medium">{channel.label}</p>
176
+ <div className="grid gap-3 md:grid-cols-3">
177
+ {channel.id === 'google_ads' ? (
178
+ <>
179
+ <div>
180
+ <label className={LABEL_CLASS}>Max headline chars</label>
181
+ <input
182
+ type="number"
183
+ min={1}
184
+ disabled={disabled}
185
+ value={channel.maxHeadlineChars ?? ''}
186
+ onChange={(e) =>
187
+ updateChannel(index, {
188
+ maxHeadlineChars: e.target.value ? Number(e.target.value) : undefined,
189
+ })
190
+ }
191
+ className={INPUT_CLASS}
192
+ />
193
+ </div>
194
+ <div>
195
+ <label className={LABEL_CLASS}>Max description chars</label>
196
+ <input
197
+ type="number"
198
+ min={1}
199
+ disabled={disabled}
200
+ value={channel.maxDescriptionChars ?? ''}
201
+ onChange={(e) =>
202
+ updateChannel(index, {
203
+ maxDescriptionChars: e.target.value
204
+ ? Number(e.target.value)
205
+ : undefined,
206
+ })
207
+ }
208
+ className={INPUT_CLASS}
209
+ />
210
+ </div>
211
+ </>
212
+ ) : (
213
+ <div>
214
+ <label className={LABEL_CLASS}>Max characters</label>
215
+ <input
216
+ type="number"
217
+ min={1}
218
+ disabled={disabled}
219
+ value={channel.maxChars ?? ''}
220
+ onChange={(e) =>
221
+ updateChannel(index, {
222
+ maxChars: e.target.value ? Number(e.target.value) : undefined,
223
+ })
224
+ }
225
+ className={INPUT_CLASS}
226
+ />
227
+ </div>
228
+ )}
229
+ <div className={channel.id === 'google_ads' ? 'md:col-span-1' : 'md:col-span-2'}>
230
+ <label className={LABEL_CLASS}>Note</label>
231
+ <input
232
+ type="text"
233
+ disabled={disabled}
234
+ value={channel.note ?? ''}
235
+ onChange={(e) => updateChannel(index, { note: e.target.value })}
236
+ className={INPUT_CLASS}
237
+ />
238
+ </div>
239
+ </div>
240
+ </div>
241
+ ))}
242
+ </div>
243
+ </section>
244
+
245
+ <section className="space-y-3">
246
+ <div>
247
+ <h4 className="text-foreground text-base font-medium">Worked examples</h4>
248
+ <p className="text-muted-foreground mt-1 text-sm">
249
+ Off-brand vs on-brand pairs anchor scoring feedback and the sample copy field.
250
+ </p>
251
+ </div>
252
+ {structured.workedExamples.length === 0 && (
253
+ <p className="text-muted-foreground text-sm">No worked examples yet.</p>
254
+ )}
255
+ {structured.workedExamples.map((example, index) => (
256
+ <div key={`example-${index}`} className="border-border space-y-2 rounded-md border p-4">
257
+ <div className="flex items-center justify-between gap-3">
258
+ <p className="text-foreground text-sm font-medium">Example {index + 1}</p>
259
+ <button
260
+ type="button"
261
+ disabled={disabled}
262
+ aria-label={`Remove worked example ${index + 1}`}
263
+ onClick={() =>
264
+ update({
265
+ workedExamples: structured.workedExamples.filter((_, i) => i !== index),
266
+ })
267
+ }
268
+ className="border-border text-muted-foreground hover:bg-accent inline-flex items-center justify-center rounded-md border px-2 py-1 transition-colors disabled:opacity-50"
269
+ >
270
+ <Trash2 size={14} aria-hidden="true" />
271
+ </button>
272
+ </div>
273
+ <input
274
+ type="text"
275
+ disabled={disabled}
276
+ placeholder="Off-brand copy"
277
+ aria-label={`Off-brand example ${index + 1}`}
278
+ value={example.offBrand}
279
+ onChange={(e) => updateExample(index, { offBrand: e.target.value })}
280
+ className={INPUT_CLASS}
281
+ />
282
+ <input
283
+ type="text"
284
+ disabled={disabled}
285
+ placeholder="On-brand rewrite"
286
+ aria-label={`On-brand example ${index + 1}`}
287
+ value={example.onBrand}
288
+ onChange={(e) => updateExample(index, { onBrand: e.target.value })}
289
+ className={INPUT_CLASS}
290
+ />
291
+ <input
292
+ type="text"
293
+ disabled={disabled}
294
+ placeholder="Why this is better"
295
+ aria-label={`Why example ${index + 1} is on-brand`}
296
+ value={example.why}
297
+ onChange={(e) => updateExample(index, { why: e.target.value })}
298
+ className={INPUT_CLASS}
299
+ />
300
+ </div>
301
+ ))}
302
+ <button
303
+ type="button"
304
+ disabled={disabled || structured.workedExamples.length >= MAX_WORKED_EXAMPLES}
305
+ onClick={() =>
306
+ update({
307
+ workedExamples: [
308
+ ...structured.workedExamples,
309
+ { offBrand: '', onBrand: '', why: '' },
310
+ ],
311
+ })
312
+ }
313
+ 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:opacity-50"
314
+ >
315
+ <Plus size={14} aria-hidden="true" />
316
+ Add worked example
317
+ </button>
318
+ </section>
319
+ </div>
320
+ )
321
+ }