@growth-labs/cms 0.5.8 → 0.5.10

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 (48) hide show
  1. package/README.md +26 -0
  2. package/dist/engine/content-insights.d.ts +11 -4
  3. package/dist/engine/content-insights.d.ts.map +1 -1
  4. package/dist/engine/content-insights.js +22 -7
  5. package/dist/engine/content-insights.js.map +1 -1
  6. package/dist/index.d.ts +1 -1
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +1 -1
  9. package/dist/index.js.map +1 -1
  10. package/dist/routes/content-insights.d.ts +1 -1
  11. package/dist/routes/content-insights.d.ts.map +1 -1
  12. package/dist/routes/content-insights.js +20 -5
  13. package/dist/routes/content-insights.js.map +1 -1
  14. package/dist/schema/index.d.ts +1 -0
  15. package/dist/schema/index.d.ts.map +1 -1
  16. package/dist/schema/index.js +1 -0
  17. package/dist/schema/index.js.map +1 -1
  18. package/dist/schema/insights-ingest.d.ts +2386 -0
  19. package/dist/schema/insights-ingest.d.ts.map +1 -1
  20. package/dist/schema/insights-ingest.js +78 -0
  21. package/dist/schema/insights-ingest.js.map +1 -1
  22. package/dist/schema/migrations.d.ts.map +1 -1
  23. package/dist/schema/migrations.js +9 -0
  24. package/dist/schema/migrations.js.map +1 -1
  25. package/dist/schema/types.d.ts +67 -0
  26. package/dist/schema/types.d.ts.map +1 -1
  27. package/dist/schema/types.js +10 -0
  28. package/dist/schema/types.js.map +1 -1
  29. package/dist/surveys/schema.d.ts +48 -48
  30. package/dist/ui/screens/ContentInsightsScreen.d.ts +28 -0
  31. package/dist/ui/screens/ContentInsightsScreen.d.ts.map +1 -1
  32. package/dist/ui/screens/ContentInsightsScreen.js +111 -36
  33. package/dist/ui/screens/ContentInsightsScreen.js.map +1 -1
  34. package/dist/ui/screens/content-insights-data.d.ts +13 -1
  35. package/dist/ui/screens/content-insights-data.d.ts.map +1 -1
  36. package/dist/ui/screens/content-insights-data.js +29 -1
  37. package/dist/ui/screens/content-insights-data.js.map +1 -1
  38. package/migrations/0022_content_insight_dismissal_reasons.sql +8 -0
  39. package/package.json +1 -1
  40. package/src/engine/content-insights.ts +32 -7
  41. package/src/index.ts +2 -0
  42. package/src/routes/content-insights.ts +31 -5
  43. package/src/schema/index.ts +1 -0
  44. package/src/schema/insights-ingest.ts +94 -0
  45. package/src/schema/migrations.ts +9 -0
  46. package/src/schema/types.ts +92 -0
  47. package/src/ui/screens/ContentInsightsScreen.tsx +416 -195
  48. package/src/ui/screens/content-insights-data.ts +40 -2
@@ -1,28 +1,58 @@
1
1
  // src/ui/screens/ContentInsightsScreen.tsx — Content Insights demand-intelligence board (bead-09).
2
- // Near-clone of TopicsScreen: desk groups (geographic theater) + a pinned Best Immediate Bets
3
- // ranking + a Trackers section; each card expands into the full editorial mini-brief; dismiss
4
- // persists and filters subsequent loads. Native reads from SITE_DB via /admin/api/content-insights.
5
- // beat is rendered as a secondary chip next to signal_type (Adaptation 3 — spec §desk-taxonomy).
6
- // Broadsheet idiom only; honest loading/empty/error/schema-not-ready/last-good-board states.
2
+ // Lane-first editorial decision docket. Each card appears once in its Insight Engine action
3
+ // lane and expands into its bounded evidence plus the full editorial mini-brief. Legacy rows
4
+ // remain readable in an unclassified appendix; dismiss persists and filters subsequent loads.
7
5
  import { useCallback, useEffect, useReducer, useState } from 'react'
6
+ import type { InsightDismissalReasonCode } from '../../schema/types.js'
8
7
  import { Icon } from '../icons.js'
9
8
  import {
10
- audienceQuestionGroups,
11
- bestBets,
12
- competitorWatchGroups,
13
- demandDeskGroups,
14
9
  discoveryKindOf,
15
10
  formatDemandEvidence,
16
11
  type InsightCard,
17
12
  initInsightsState,
18
13
  insightsReducer,
19
- trackers,
14
+ laneGroups,
15
+ shouldRenderInsightLanes,
20
16
  } from './content-insights-data.js'
21
17
 
18
+ type DismissReasonSelection = InsightDismissalReasonCode | ''
19
+
20
+ export interface DismissRequestBody {
21
+ intentKey: string
22
+ reasonCode: InsightDismissalReasonCode
23
+ note?: string
24
+ }
25
+
26
+ /** Build the exact bounded route body; route validation remains authoritative. */
27
+ export function buildDismissRequest(
28
+ intentKey: string,
29
+ reasonCode: DismissReasonSelection,
30
+ note: string,
31
+ ): DismissRequestBody | null {
32
+ if (reasonCode === '') return null
33
+ const normalizedNote = note.trim()
34
+ if (normalizedNote.length > 500) return null
35
+ if (reasonCode === 'other' && normalizedNote.length < 3) return null
36
+ return {
37
+ intentKey,
38
+ reasonCode,
39
+ ...(normalizedNote.length > 0 ? { note: normalizedNote } : {}),
40
+ }
41
+ }
42
+
43
+ /** A non-2xx response must leave the card and its editor intact for correction. */
44
+ export function shouldRemoveDismissedCard(responseOk: boolean): boolean {
45
+ return responseOk
46
+ }
47
+
22
48
  export function ContentInsightsScreen() {
23
49
  const [s, dispatch] = useReducer(insightsReducer, undefined, initInsightsState)
24
50
  const [expanded, setExpanded] = useState<string | null>(null)
25
51
  const [dismissing, setDismissing] = useState<string | null>(null)
52
+ const [dismissEditorFor, setDismissEditorFor] = useState<string | null>(null)
53
+ const [dismissReasonCode, setDismissReasonCode] = useState<DismissReasonSelection>('')
54
+ const [dismissNote, setDismissNote] = useState('')
55
+ const [dismissError, setDismissError] = useState<string | null>(null)
26
56
 
27
57
  const fetchBoard = useCallback(async () => {
28
58
  dispatch({ type: 'fetch-start' })
@@ -47,27 +77,52 @@ export function ContentInsightsScreen() {
47
77
  void fetchBoard()
48
78
  }, [fetchBoard])
49
79
 
80
+ const openDismissEditor = (card: InsightCard) => {
81
+ setDismissEditorFor(card.intent_key)
82
+ setDismissReasonCode('')
83
+ setDismissNote('')
84
+ setDismissError(null)
85
+ }
86
+
87
+ const closeDismissEditor = () => {
88
+ setDismissEditorFor(null)
89
+ setDismissReasonCode('')
90
+ setDismissNote('')
91
+ setDismissError(null)
92
+ }
93
+
50
94
  const dismiss = async (card: InsightCard) => {
95
+ const body = buildDismissRequest(card.intent_key, dismissReasonCode, dismissNote)
96
+ if (body == null) {
97
+ setDismissError(
98
+ dismissReasonCode === 'other'
99
+ ? 'Other requires an editorial note of at least 3 characters.'
100
+ : 'Choose a dismissal reason before saving.',
101
+ )
102
+ return
103
+ }
51
104
  setDismissing(card.intent_key)
105
+ setDismissError(null)
52
106
  try {
53
107
  const res = await fetch('/admin/api/content-insights/dismiss', {
54
108
  method: 'POST',
55
109
  headers: { 'content-type': 'application/json' },
56
- body: JSON.stringify({ intentKey: card.intent_key }),
110
+ body: JSON.stringify(body),
57
111
  })
58
- if (res.ok) dispatch({ type: 'dismiss-ok', intentKey: card.intent_key })
112
+ if (shouldRemoveDismissedCard(res.ok)) {
113
+ dispatch({ type: 'dismiss-ok', intentKey: card.intent_key })
114
+ closeDismissEditor()
115
+ } else {
116
+ setDismissError(`Could not save dismissal — ${res.status} ${res.statusText}`)
117
+ }
118
+ } catch (error) {
119
+ setDismissError(`Could not save dismissal — ${String(error)}`)
59
120
  } finally {
60
121
  setDismissing(null)
61
122
  }
62
123
  }
63
124
 
64
- // DEMAND board groups (GSC) — the two opportunity sub-kinds render in their own
65
- // labelled sections (Competitor Watch + What Readers Are Asking) below.
66
- const groups = demandDeskGroups(s.insights)
67
- const competitorGroups = competitorWatchGroups(s.insights)
68
- const audienceGroups = audienceQuestionGroups(s.insights)
69
- const bets = bestBets(s.insights)
70
- const trackerCards = trackers(s.insights)
125
+ const groups = laneGroups(s.insights)
71
126
 
72
127
  return (
73
128
  <div className="page page-wide content-insights-screen fade-in" style={{ maxWidth: 1340 }}>
@@ -106,181 +161,76 @@ export function ContentInsightsScreen() {
106
161
  />
107
162
  )}
108
163
 
109
- {s.loaded && !s.error && !s.schemaNotReady && s.insights.length === 0 && (
110
- <EmptyPanel
111
- icon="sparkle"
112
- message="No insights yet — the daily research run hasn't landed a board."
113
- />
114
- )}
115
-
116
- {bets.length > 0 && (
117
- <div className="panel" style={{ overflow: 'hidden', marginBottom: 'var(--gap)' }}>
118
- <div style={{ padding: '15px 18px 12px' }}>
119
- <div className="card-title">Best immediate bets</div>
120
- </div>
121
- <hr className="masthead-rule" style={{ margin: '0 18px' }} />
122
- <div style={{ display: 'flex', flexDirection: 'column', gap: 10, padding: 18 }}>
123
- {bets.map((c) => (
124
- <InsightCardRow
125
- key={c.intent_key}
126
- card={c}
127
- expanded={expanded === c.intent_key}
128
- onToggle={() => setExpanded(expanded === c.intent_key ? null : c.intent_key)}
129
- onDismiss={() => void dismiss(c)}
130
- dismissing={dismissing === c.intent_key}
131
- />
132
- ))}
133
- </div>
134
- </div>
135
- )}
136
-
137
- {groups.map((g) => (
138
- <div
139
- key={g.desk}
140
- className="panel"
141
- style={{ overflow: 'hidden', marginBottom: 'var(--gap)' }}
142
- >
143
- <div
144
- style={{
145
- padding: '15px 18px 12px',
146
- display: 'flex',
147
- alignItems: 'baseline',
148
- gap: 10,
149
- }}
150
- >
151
- <div className="card-title" style={{ textTransform: 'capitalize' }}>
152
- {g.desk}
153
- </div>
154
- <span className="kicker">{g.cards.length} ideas</span>
155
- </div>
156
- <hr className="masthead-rule" style={{ margin: '0 18px' }} />
157
- <div style={{ display: 'flex', flexDirection: 'column', gap: 10, padding: 18 }}>
158
- {g.cards.map((c) => (
159
- <InsightCardRow
160
- key={c.intent_key}
161
- card={c}
162
- expanded={expanded === c.intent_key}
163
- onToggle={() => setExpanded(expanded === c.intent_key ? null : c.intent_key)}
164
- onDismiss={() => void dismiss(c)}
165
- dismissing={dismissing === c.intent_key}
166
- />
167
- ))}
168
- </div>
169
- </div>
170
- ))}
171
-
172
- {competitorGroups.length > 0 && (
173
- <OpportunitySection
174
- title="Competitor Watch"
175
- subtitle="What rivals are publishing that we're missing"
176
- groups={competitorGroups}
177
- expanded={expanded}
178
- setExpanded={setExpanded}
179
- dismiss={dismiss}
180
- dismissing={dismissing}
181
- />
182
- )}
183
-
184
- {audienceGroups.length > 0 && (
185
- <OpportunitySection
186
- title="What Readers Are Asking"
187
- subtitle="Real reader questions (Google People-Also-Ask) we don't yet answer"
188
- groups={audienceGroups}
189
- expanded={expanded}
190
- setExpanded={setExpanded}
191
- dismiss={dismiss}
192
- dismissing={dismissing}
193
- />
194
- )}
195
-
196
- {trackerCards.length > 0 && (
197
- <div className="panel" style={{ overflow: 'hidden' }}>
198
- <div style={{ padding: '15px 18px 12px' }}>
199
- <div className="card-title">Trackers</div>
200
- </div>
201
- <hr className="masthead-rule" style={{ margin: '0 18px' }} />
202
- <div style={{ display: 'flex', flexDirection: 'column', gap: 10, padding: 18 }}>
203
- {trackerCards.map((c) => (
204
- <InsightCardRow
205
- key={c.intent_key}
206
- card={c}
207
- expanded={expanded === c.intent_key}
208
- onToggle={() => setExpanded(expanded === c.intent_key ? null : c.intent_key)}
209
- onDismiss={() => void dismiss(c)}
210
- dismissing={dismissing === c.intent_key}
211
- />
212
- ))}
213
- </div>
214
- </div>
164
+ {shouldRenderInsightLanes(s) && (
165
+ <section aria-label="Insight Engine decision lanes">
166
+ {groups.map((group) => (
167
+ <section
168
+ key={group.lane ?? 'legacy'}
169
+ className="panel"
170
+ aria-labelledby={`insight-lane-${group.lane ?? 'legacy'}`}
171
+ style={{
172
+ overflow: 'hidden',
173
+ marginBottom: 'var(--gap)',
174
+ borderLeft: `3px solid ${group.lane == null ? 'var(--hairline)' : 'var(--gold)'}`,
175
+ }}
176
+ >
177
+ <header
178
+ style={{
179
+ padding: '15px 18px 12px',
180
+ display: 'flex',
181
+ alignItems: 'baseline',
182
+ justifyContent: 'space-between',
183
+ gap: 12,
184
+ }}
185
+ >
186
+ <h2
187
+ id={`insight-lane-${group.lane ?? 'legacy'}`}
188
+ className="card-title serif"
189
+ style={{ margin: 0 }}
190
+ >
191
+ {group.label}
192
+ </h2>
193
+ <span className="kicker tnum">
194
+ {group.cards.length} {group.cards.length === 1 ? 'idea' : 'ideas'}
195
+ </span>
196
+ </header>
197
+ <hr className="masthead-rule" style={{ margin: '0 18px' }} />
198
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 10, padding: 18 }}>
199
+ {group.cards.length === 0 ? (
200
+ <div className="kicker" style={{ padding: '10px 0', color: 'var(--ink-faint)' }}>
201
+ No ideas in this lane.
202
+ </div>
203
+ ) : (
204
+ group.cards.map((card) => (
205
+ <InsightCardRow
206
+ key={card.intent_key}
207
+ card={card}
208
+ expanded={expanded === card.intent_key}
209
+ onToggle={() =>
210
+ setExpanded(expanded === card.intent_key ? null : card.intent_key)
211
+ }
212
+ dismissEditorOpen={dismissEditorFor === card.intent_key}
213
+ onOpenDismissEditor={() => openDismissEditor(card)}
214
+ onCancelDismissEditor={closeDismissEditor}
215
+ onSubmitDismiss={() => void dismiss(card)}
216
+ dismissReasonCode={dismissReasonCode}
217
+ dismissNote={dismissNote}
218
+ dismissError={dismissError}
219
+ onDismissReasonCodeChange={setDismissReasonCode}
220
+ onDismissNoteChange={setDismissNote}
221
+ dismissing={dismissing === card.intent_key}
222
+ />
223
+ ))
224
+ )}
225
+ </div>
226
+ </section>
227
+ ))}
228
+ </section>
215
229
  )}
216
230
  </div>
217
231
  )
218
232
  }
219
233
 
220
- /** One labelled opportunity SECTION (Competitor Watch / What Readers Are Asking),
221
- * grouped by desk inside. Reuses the desk-panel + InsightCardRow rendering so the
222
- * two new sub-kinds match the rest of the board. */
223
- function OpportunitySection({
224
- title,
225
- subtitle,
226
- groups,
227
- expanded,
228
- setExpanded,
229
- dismiss,
230
- dismissing,
231
- }: {
232
- title: string
233
- subtitle: string
234
- groups: Array<{ desk: string; cards: InsightCard[] }>
235
- expanded: string | null
236
- setExpanded: (v: string | null) => void
237
- dismiss: (c: InsightCard) => Promise<void>
238
- dismissing: string | null
239
- }) {
240
- return (
241
- <div className="panel" style={{ overflow: 'hidden', marginBottom: 'var(--gap)' }}>
242
- <div style={{ padding: '15px 18px 6px' }}>
243
- <div className="card-title">{title}</div>
244
- <div className="kicker" style={{ marginTop: 2 }}>
245
- {subtitle}
246
- </div>
247
- </div>
248
- <hr className="masthead-rule" style={{ margin: '0 18px' }} />
249
- {groups.map((g) => (
250
- <div key={g.desk}>
251
- <div
252
- style={{
253
- padding: '12px 18px 6px',
254
- display: 'flex',
255
- alignItems: 'baseline',
256
- gap: 10,
257
- }}
258
- >
259
- <div className="card-title" style={{ textTransform: 'capitalize', fontSize: '0.95em' }}>
260
- {g.desk}
261
- </div>
262
- <span className="kicker">{g.cards.length} ideas</span>
263
- </div>
264
- <div
265
- style={{ display: 'flex', flexDirection: 'column', gap: 10, padding: '0 18px 14px' }}
266
- >
267
- {g.cards.map((c) => (
268
- <InsightCardRow
269
- key={c.intent_key}
270
- card={c}
271
- expanded={expanded === c.intent_key}
272
- onToggle={() => setExpanded(expanded === c.intent_key ? null : c.intent_key)}
273
- onDismiss={() => void dismiss(c)}
274
- dismissing={dismissing === c.intent_key}
275
- />
276
- ))}
277
- </div>
278
- </div>
279
- ))}
280
- </div>
281
- )
282
- }
283
-
284
234
  function evidenceChip(card: InsightCard): string {
285
235
  if ('query' in card.evidence) return formatDemandEvidence(card.evidence)
286
236
  // Kind-aware opportunity chip: audience rows are grounded in reader questions,
@@ -292,19 +242,52 @@ function evidenceChip(card: InsightCard): string {
292
242
  return `${card.evidence.basis} · ${card.evidence.source_count} sources`
293
243
  }
294
244
 
295
- function InsightCardRow({
245
+ function formatPercent(value: number): string {
246
+ return `${(value * 100).toLocaleString('en-US', { maximumFractionDigits: 2 })}%`
247
+ }
248
+
249
+ function formatEvidenceNumber(value: number): string {
250
+ return value.toLocaleString('en-US', { maximumFractionDigits: 2 })
251
+ }
252
+
253
+ function formatSignedEvidenceNumber(value: number): string {
254
+ const formatted = formatEvidenceNumber(value)
255
+ return value > 0 ? `+${formatted}` : formatted
256
+ }
257
+
258
+ export function InsightCardRow({
296
259
  card,
297
260
  expanded,
298
261
  onToggle,
299
- onDismiss,
262
+ dismissEditorOpen,
263
+ onOpenDismissEditor,
264
+ onCancelDismissEditor,
265
+ onSubmitDismiss,
266
+ dismissReasonCode,
267
+ dismissNote,
268
+ dismissError,
269
+ onDismissReasonCodeChange,
270
+ onDismissNoteChange,
300
271
  dismissing,
301
272
  }: {
302
273
  card: InsightCard
303
274
  expanded: boolean
304
275
  onToggle: () => void
305
- onDismiss: () => void
276
+ dismissEditorOpen: boolean
277
+ onOpenDismissEditor: () => void
278
+ onCancelDismissEditor: () => void
279
+ onSubmitDismiss: () => void
280
+ dismissReasonCode: DismissReasonSelection
281
+ dismissNote: string
282
+ dismissError: string | null
283
+ onDismissReasonCodeChange: (reasonCode: DismissReasonSelection) => void
284
+ onDismissNoteChange: (note: string) => void
306
285
  dismissing: boolean
307
286
  }) {
287
+ const detailsId = `insight-details-${card.id}`
288
+ const dismissReasonId = `insight-dismiss-reason-${card.id}`
289
+ const dismissNoteId = `insight-dismiss-note-${card.id}`
290
+
308
291
  return (
309
292
  <div
310
293
  style={{
@@ -325,6 +308,8 @@ function InsightCardRow({
325
308
  <button
326
309
  type="button"
327
310
  onClick={onToggle}
311
+ aria-expanded={expanded}
312
+ aria-controls={detailsId}
328
313
  style={{
329
314
  textAlign: 'left',
330
315
  background: 'none',
@@ -341,16 +326,120 @@ function InsightCardRow({
341
326
  {evidenceChip(card)}
342
327
  </div>
343
328
  </button>
344
- <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
329
+ <div
330
+ style={{
331
+ display: 'flex',
332
+ alignItems: 'center',
333
+ justifyContent: 'flex-end',
334
+ flexWrap: 'wrap',
335
+ gap: 8,
336
+ }}
337
+ >
338
+ <span className="pill pdot">{card.desk}</span>
345
339
  <span className="pill pdot">{card.signal_type}</span>
340
+ {(card.signal_type === 'opportunity' || card.brief.discoveryKind != null) && (
341
+ <span className="pill pdot">{discoveryKindOf(card)}</span>
342
+ )}
343
+ {card.suggestion_type === 'tracker' && <span className="pill pdot">tracker</span>}
346
344
  {card.beat != null && <span className="pill pdot">{card.beat}</span>}
347
- <button type="button" className="btn danger sm" onClick={onDismiss} disabled={dismissing}>
348
- <Icon name="x" size={13} /> {dismissing ? 'Dismissing…' : 'Dismiss'}
345
+ {card.is_best_bet === 1 && (
346
+ <span className="pill pdot">
347
+ best bet{card.best_bet_rank != null ? ` #${card.best_bet_rank}` : ''}
348
+ </span>
349
+ )}
350
+ <button
351
+ type="button"
352
+ className="btn danger sm"
353
+ onClick={dismissEditorOpen ? onCancelDismissEditor : onOpenDismissEditor}
354
+ disabled={dismissing}
355
+ >
356
+ <Icon name="x" size={13} /> {dismissEditorOpen ? 'Cancel dismissal' : 'Dismiss'}
349
357
  </button>
350
358
  </div>
351
359
  </div>
360
+ {dismissEditorOpen && (
361
+ <form
362
+ onSubmit={(event) => {
363
+ event.preventDefault()
364
+ onSubmitDismiss()
365
+ }}
366
+ style={{
367
+ marginTop: 12,
368
+ borderTop: '1px solid var(--border)',
369
+ paddingTop: 12,
370
+ display: 'grid',
371
+ gap: 10,
372
+ }}
373
+ >
374
+ <div>
375
+ <label htmlFor={dismissReasonId} className="kicker">
376
+ Dismiss reason <span aria-hidden="true">*</span>
377
+ </label>
378
+ <select
379
+ id={dismissReasonId}
380
+ className="selectbox"
381
+ value={dismissReasonCode}
382
+ onChange={(event) =>
383
+ onDismissReasonCodeChange(event.currentTarget.value as DismissReasonSelection)
384
+ }
385
+ required
386
+ aria-describedby={`${dismissReasonId}-help`}
387
+ >
388
+ <option value="" disabled>
389
+ Choose a reason
390
+ </option>
391
+ <option value="off_topic">Off topic</option>
392
+ <option value="already_covered">Already covered</option>
393
+ <option value="cant_win">Can’t win</option>
394
+ <option value="wrong_format">Wrong format</option>
395
+ <option value="not_now">Not now</option>
396
+ <option value="other">Other</option>
397
+ </select>
398
+ <div id={`${dismissReasonId}-help`} className="kicker" style={{ marginTop: 4 }}>
399
+ Reason is required.
400
+ </div>
401
+ </div>
402
+ <div>
403
+ <label htmlFor={dismissNoteId} className="kicker">
404
+ Editorial note {dismissReasonCode === 'other' && '(required)'}
405
+ </label>
406
+ <textarea
407
+ id={dismissNoteId}
408
+ className="textarea"
409
+ value={dismissNote}
410
+ onChange={(event) => onDismissNoteChange(event.currentTarget.value)}
411
+ maxLength={500}
412
+ required={dismissReasonCode === 'other'}
413
+ aria-describedby={`${dismissNoteId}-help`}
414
+ />
415
+ <div id={`${dismissNoteId}-help`} className="kicker" style={{ marginTop: 4 }}>
416
+ 500 characters maximum
417
+ {dismissReasonCode === 'other' && ' · Required for Other (at least 3 characters)'}
418
+ </div>
419
+ </div>
420
+ {dismissError != null && (
421
+ <div role="alert" style={{ color: 'var(--red)', fontSize: 13 }}>
422
+ {dismissError}
423
+ </div>
424
+ )}
425
+ <div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
426
+ <button
427
+ type="button"
428
+ className="btn sm"
429
+ onClick={onCancelDismissEditor}
430
+ disabled={dismissing}
431
+ >
432
+ Cancel
433
+ </button>
434
+ <button type="submit" className="btn danger sm" disabled={dismissing}>
435
+ <Icon name="x" size={13} /> {dismissing ? 'Saving…' : 'Save dismissal'}
436
+ </button>
437
+ </div>
438
+ </form>
439
+ )}
352
440
  {expanded && (
353
441
  <div
442
+ id={detailsId}
354
443
  style={{
355
444
  marginTop: 12,
356
445
  borderTop: '1px solid var(--border)',
@@ -360,6 +449,114 @@ function InsightCardRow({
360
449
  lineHeight: 1.6,
361
450
  }}
362
451
  >
452
+ {card.evidence.scoreBreakdown != null && (
453
+ <div
454
+ className="mono tnum"
455
+ style={{
456
+ display: 'flex',
457
+ flexWrap: 'wrap',
458
+ gap: '6px 18px',
459
+ padding: '9px 10px',
460
+ marginBottom: 12,
461
+ background: 'var(--accent-tint)',
462
+ borderLeft: '2px solid var(--accent)',
463
+ fontSize: 12,
464
+ }}
465
+ >
466
+ <span>
467
+ <strong>Actual CTR:</strong> {formatPercent(card.evidence.scoreBreakdown.actualCtr)}
468
+ </span>
469
+ <span>
470
+ <strong>Expected CTR:</strong>{' '}
471
+ {formatPercent(card.evidence.scoreBreakdown.expectedCtr)}
472
+ </span>
473
+ <span>
474
+ <strong>Expected click gain:</strong>{' '}
475
+ {formatEvidenceNumber(card.evidence.scoreBreakdown.expectedClicksGain)}
476
+ </span>
477
+ <span>
478
+ <strong>Lane weight:</strong>{' '}
479
+ {formatPercent(card.evidence.scoreBreakdown.laneWeight)}
480
+ </span>
481
+ <span>
482
+ <strong>Score:</strong> {formatEvidenceNumber(card.evidence.scoreBreakdown.score)}
483
+ </span>
484
+ <span>
485
+ <strong>Target position:</strong>{' '}
486
+ {formatEvidenceNumber(card.evidence.scoreBreakdown.targetPosition)}
487
+ </span>
488
+ <span>
489
+ <strong>Breakdown topical fit:</strong>{' '}
490
+ {formatPercent(card.evidence.scoreBreakdown.topicalFit)}
491
+ </span>
492
+ <span>
493
+ <strong>Winnability prior:</strong>{' '}
494
+ {formatPercent(card.evidence.scoreBreakdown.winnabilityPrior)}
495
+ </span>
496
+ </div>
497
+ )}
498
+ {'query' in card.evidence &&
499
+ (card.evidence.coveredBy != null || card.evidence.coverageSource != null) && (
500
+ <div style={{ marginBottom: 12 }}>
501
+ {card.evidence.coveredBy != null && (
502
+ <div>
503
+ <strong>Covered by:</strong>{' '}
504
+ <a href={card.evidence.coveredBy} target="_blank" rel="noreferrer">
505
+ {card.evidence.coveredBy}
506
+ </a>
507
+ </div>
508
+ )}
509
+ {card.evidence.coverageSource != null && (
510
+ <div>
511
+ <strong>Coverage source:</strong> {card.evidence.coverageSource}
512
+ </div>
513
+ )}
514
+ </div>
515
+ )}
516
+ {card.evidence.semantic != null && (
517
+ <div style={{ marginBottom: 12 }}>
518
+ <div className="kicker" style={{ marginBottom: 4 }}>
519
+ Semantic evidence
520
+ </div>
521
+ <div>
522
+ <strong>Topical fit:</strong> {formatPercent(card.evidence.semantic.topicalFit)} ·{' '}
523
+ <strong>Disposition:</strong> {card.evidence.semantic.disposition} ·{' '}
524
+ <strong>Coverage similarity:</strong>{' '}
525
+ {formatPercent(card.evidence.semantic.coverageSimilarity)} ·{' '}
526
+ {card.evidence.semantic.coverageAligned ? 'aligned' : 'not aligned'} ·{' '}
527
+ {card.evidence.semantic.sharedConcepts} shared concepts
528
+ </div>
529
+ {card.evidence.semantic.nearest.length > 0 && (
530
+ <ul style={{ margin: '4px 0 0', paddingLeft: 18 }}>
531
+ {card.evidence.semantic.nearest.map((entry) => (
532
+ <li key={`${entry.slug}-${entry.title}`}>
533
+ {entry.title} ({formatPercent(entry.similarity)})
534
+ </li>
535
+ ))}
536
+ </ul>
537
+ )}
538
+ </div>
539
+ )}
540
+ {card.evidence.judge != null && (
541
+ <div style={{ marginBottom: 12 }}>
542
+ <div className="kicker" style={{ marginBottom: 4 }}>
543
+ Judge decision
544
+ </div>
545
+ <div>
546
+ <strong>Judge:</strong> {card.evidence.judge.verdict} ·{' '}
547
+ {card.evidence.judge.reason_code} · {formatPercent(card.evidence.judge.confidence)}{' '}
548
+ confidence
549
+ </div>
550
+ <div>{card.evidence.judge.intent_statement}</div>
551
+ {card.evidence.judge.winnability != null && (
552
+ <div style={{ marginTop: 4 }}>
553
+ <strong>Winnability:</strong> {card.evidence.judge.winnability.verdict} ·{' '}
554
+ {formatPercent(card.evidence.judge.winnability.confidence)} confidence —{' '}
555
+ {card.evidence.judge.winnability.serp_summary}
556
+ </div>
557
+ )}
558
+ </div>
559
+ )}
363
560
  <div>
364
561
  <strong>Angle:</strong> {card.brief.angle}
365
562
  </div>
@@ -367,6 +564,30 @@ function InsightCardRow({
367
564
  <strong>Cluster:</strong> {card.brief.cluster.canonical}
368
565
  {card.brief.cluster.spokes.length > 0 && ` — ${card.brief.cluster.spokes.join(', ')}`}
369
566
  </div>
567
+ {card.brief.format != null && (
568
+ <div style={{ marginTop: 8 }}>
569
+ <strong>Format:</strong> {card.brief.format}
570
+ </div>
571
+ )}
572
+ {card.brief.content_outline != null && card.brief.content_outline.length > 0 && (
573
+ <div style={{ marginTop: 8 }}>
574
+ <strong>Content outline:</strong>
575
+ <ol style={{ margin: '4px 0 0', paddingLeft: 20 }}>
576
+ {card.brief.content_outline.map((item) => (
577
+ <li key={item.question}>
578
+ {item.question}
579
+ {item.demand != null && (
580
+ <div className="mono" style={{ fontSize: 12, color: 'var(--ink-muted)' }}>
581
+ {item.demand.query} · pos {item.demand.position.toFixed(1)} ·{' '}
582
+ {item.demand.impressions.toLocaleString('en-US')} impr · trend{' '}
583
+ {formatSignedEvidenceNumber(item.demand.trend)}
584
+ </div>
585
+ )}
586
+ </li>
587
+ ))}
588
+ </ol>
589
+ </div>
590
+ )}
370
591
  {card.brief.sources.length > 0 && (
371
592
  <div style={{ marginTop: 8 }}>
372
593
  <strong>Sources:</strong>