@growth-labs/cms 0.8.26 → 0.8.28

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 (61) hide show
  1. package/README.md +23 -0
  2. package/dist/engine/published-content.d.ts +4 -0
  3. package/dist/engine/published-content.d.ts.map +1 -1
  4. package/dist/engine/published-content.js +52 -16
  5. package/dist/engine/published-content.js.map +1 -1
  6. package/dist/index.d.ts +1 -0
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +1 -0
  9. package/dist/index.js.map +1 -1
  10. package/dist/integration/options.d.ts +226 -13
  11. package/dist/integration/options.d.ts.map +1 -1
  12. package/dist/integration/options.js +17 -2
  13. package/dist/integration/options.js.map +1 -1
  14. package/dist/providers/types.d.ts +10 -0
  15. package/dist/providers/types.d.ts.map +1 -1
  16. package/dist/providers/types.js.map +1 -1
  17. package/dist/routes/content-insights.d.ts +4 -0
  18. package/dist/routes/content-insights.d.ts.map +1 -1
  19. package/dist/routes/content-insights.js +104 -0
  20. package/dist/routes/content-insights.js.map +1 -1
  21. package/dist/routes/surveys.d.ts.map +1 -1
  22. package/dist/routes/surveys.js +80 -4
  23. package/dist/routes/surveys.js.map +1 -1
  24. package/dist/schema/insights-ingest.d.ts +64 -64
  25. package/dist/surveys/auto-label.d.ts +31 -4
  26. package/dist/surveys/auto-label.d.ts.map +1 -1
  27. package/dist/surveys/auto-label.js +160 -29
  28. package/dist/surveys/auto-label.js.map +1 -1
  29. package/dist/surveys/schema.d.ts +155 -0
  30. package/dist/surveys/schema.d.ts.map +1 -1
  31. package/dist/surveys/schema.js +49 -0
  32. package/dist/surveys/schema.js.map +1 -1
  33. package/dist/ui/api/content/insights-inputs.d.ts.map +1 -1
  34. package/dist/ui/api/content/insights-inputs.js +11 -1
  35. package/dist/ui/api/content/insights-inputs.js.map +1 -1
  36. package/dist/ui/client/mount.d.ts.map +1 -1
  37. package/dist/ui/client/mount.js +1 -0
  38. package/dist/ui/client/mount.js.map +1 -1
  39. package/dist/ui/screens/SurveyResponsesScreen.d.ts +18 -3
  40. package/dist/ui/screens/SurveyResponsesScreen.d.ts.map +1 -1
  41. package/dist/ui/screens/SurveyResponsesScreen.js +75 -18
  42. package/dist/ui/screens/SurveyResponsesScreen.js.map +1 -1
  43. package/dist/ui/screens/registry.d.ts +3 -1
  44. package/dist/ui/screens/registry.d.ts.map +1 -1
  45. package/dist/ui/screens/registry.js +4 -2
  46. package/dist/ui/screens/registry.js.map +1 -1
  47. package/dist/ui/styles/broadsheet.css +1 -0
  48. package/package.json +1 -1
  49. package/src/engine/published-content.ts +75 -16
  50. package/src/index.ts +1 -0
  51. package/src/integration/options.ts +21 -1
  52. package/src/providers/types.ts +9 -0
  53. package/src/routes/content-insights.ts +104 -0
  54. package/src/routes/surveys.ts +106 -4
  55. package/src/surveys/auto-label.ts +204 -33
  56. package/src/surveys/schema.ts +58 -0
  57. package/src/ui/api/content/insights-inputs.ts +8 -1
  58. package/src/ui/client/mount.tsx +1 -0
  59. package/src/ui/screens/SurveyResponsesScreen.tsx +198 -18
  60. package/src/ui/screens/registry.tsx +6 -2
  61. package/src/ui/styles/broadsheet.css +1 -0
@@ -196,6 +196,38 @@ export const SurveyQuestionSchema = z
196
196
  }
197
197
  })
198
198
 
199
+ /** Reviewed delivery notes supplied by a site, never generated from private answers. */
200
+ export const SurveyFeedbackThemeSchema = z
201
+ .object({
202
+ id: SurveyIdSchema,
203
+ title: z.string().trim().min(1).max(160),
204
+ state: z.enum(['open', 'partly_shipped', 'shipped', 'unknown']),
205
+ summary: z.string().trim().min(1).max(1_000),
206
+ reviewedAt: IsoTimestampSchema,
207
+ evidence: z
208
+ .array(
209
+ z
210
+ .object({
211
+ label: z.string().trim().min(1).max(160),
212
+ href: z
213
+ .string()
214
+ .url()
215
+ .max(2_000)
216
+ .refine((value) => value.startsWith('https://')),
217
+ })
218
+ .strict(),
219
+ )
220
+ .max(6),
221
+ })
222
+ .strict()
223
+ export const SurveyFeedbackThemesSchema = z
224
+ .array(SurveyFeedbackThemeSchema)
225
+ .max(25)
226
+ .refine((themes) => new Set(themes.map(({ id }) => id)).size === themes.length, {
227
+ message: 'Feedback theme IDs must be unique',
228
+ })
229
+ export type SurveyFeedbackTheme = z.infer<typeof SurveyFeedbackThemeSchema>
230
+
199
231
  export const SurveyResultsSchema = z
200
232
  .object({
201
233
  survey: z
@@ -244,6 +276,28 @@ export const SurveyResultsSchema = z
244
276
  .object({
245
277
  questionId: SurveyQuestionIdSchema,
246
278
  value: AnswerValueSchema,
279
+ writtenValue: z
280
+ .string()
281
+ .max(
282
+ Math.max(
283
+ MAX_FREE_TEXT_LENGTH,
284
+ MAX_CHOICE_OPTIONS * (MAX_OPTION_LENGTH + 1),
285
+ ),
286
+ )
287
+ .optional(),
288
+ label: z.enum(['technical', 'general']).optional(),
289
+ labelSource: z.enum(['owner', 'auto', 'import']).optional(),
290
+ classificationStatus: z
291
+ .enum([
292
+ 'pending',
293
+ 'retry',
294
+ 'expired',
295
+ 'manual_review',
296
+ 'unavailable',
297
+ 'queued',
298
+ ])
299
+ .optional(),
300
+ nextAttemptAt: z.number().nonnegative().optional(),
247
301
  })
248
302
  .strict(),
249
303
  )
@@ -269,6 +323,10 @@ export const SurveyResultsSchema = z
269
323
  // Owner payloads carry it; non-owner entries pass through
270
324
  // undecorated, exactly as they do for `label`.
271
325
  labelSource: z.enum(['owner', 'auto', 'import']).optional(),
326
+ classificationStatus: z
327
+ .enum(['pending', 'retry', 'expired', 'manual_review', 'unavailable', 'queued'])
328
+ .optional(),
329
+ nextAttemptAt: z.number().nonnegative().optional(),
272
330
  })
273
331
  .strict(),
274
332
  )
@@ -4,10 +4,13 @@
4
4
  // X-Foundry-Timestamp + X-Foundry-Signature family as fronts/schedule) for
5
5
  // foundryd's sitebrain.insights.run schedule (W1-07 PR 6). NOT behind
6
6
  // requirePublisher. Sibling of insights-ingest under /content/.
7
+
8
+ import { config } from 'virtual:growth-labs/cms/config'
7
9
  import type { APIContext } from 'astro'
8
10
  import { createContentInsightsRoutes } from '../../../routes/content-insights.js'
9
11
  import type { RouteContext } from '../../../routes/context.js'
10
12
  import { buildSiteAuthz } from '../_authz.js'
13
+ import { toContentConfig } from '../_content-config.js'
11
14
 
12
15
  function toCtx(c: APIContext): RouteContext {
13
16
  const env = (c.locals as { cmsEnv?: Record<string, unknown> }).cmsEnv ?? {}
@@ -16,7 +19,11 @@ function toCtx(c: APIContext): RouteContext {
16
19
 
17
20
  function siteId(c: APIContext): string {
18
21
  const env = (c.locals as { cmsEnv?: Record<string, unknown> }).cmsEnv ?? {}
19
- return (env.ACTIVE_WORKSPACE_ID as string | undefined) ?? 'default'
22
+ return (
23
+ toContentConfig(c).activeWorkspaceId ??
24
+ (env.ACTIVE_WORKSPACE_ID as string | undefined) ??
25
+ (config.workspaces?.length === 1 ? config.workspaces[0]!.id : 'default')
26
+ )
20
27
  }
21
28
 
22
29
  export const GET = (c: APIContext) =>
@@ -42,6 +42,7 @@ export async function mount(elementId = 'masthead-root') {
42
42
  primaryTopic: config.primaryTopic,
43
43
  contentMetadataSelects: config.contentMetadataSelects,
44
44
  surveyId: config.surveyResults?.surveyId,
45
+ surveys: config.surveyResults?.surveys,
45
46
  })
46
47
  createRoot(el).render(<CmsApp initial={initial} screens={screens} boot={boot} />)
47
48
  }
@@ -1,4 +1,4 @@
1
- import { useCallback, useEffect, useMemo, useState } from 'react'
1
+ import { type ReactNode, useCallback, useEffect, useMemo, useState } from 'react'
2
2
  import type { CmsRole } from '../../schema/types.js'
3
3
  import { canLabelSurveyAnswers, canViewSurveyVerbatims } from '../../surveys/access.js'
4
4
  import {
@@ -31,6 +31,7 @@ import {
31
31
  import {
32
32
  MAX_FREE_TEXT_ENTRIES,
33
33
  type SurveyAnswerLabel,
34
+ type SurveyFeedbackTheme,
34
35
  type SurveyQuestion,
35
36
  type SurveyResults,
36
37
  SurveyResultsSchema,
@@ -89,7 +90,72 @@ const PAGE_SIZE = 100
89
90
  // Five groups is as many as a reader can tell apart by color; more fold into a note.
90
91
  const MAX_GROUPS = 5
91
92
 
92
- export function SurveyResponsesScreen({ surveyId }: { surveyId: string }) {
93
+ export interface SurveyChoice {
94
+ surveyId: string
95
+ label: string
96
+ feedbackThemes?: SurveyFeedbackTheme[]
97
+ }
98
+
99
+ export function SurveyResponsesScreen({
100
+ surveyId,
101
+ surveys,
102
+ }: {
103
+ surveyId: string
104
+ surveys?: SurveyChoice[]
105
+ }) {
106
+ const [selected, setSelected] = useState(surveyId)
107
+ const picker =
108
+ surveys && surveys.length > 1 ? (
109
+ <SurveyPicker surveys={surveys} selected={selected} onSelect={setSelected} />
110
+ ) : null
111
+ // Keyed by survey, so switching starts clean: no page, comparison, filter or
112
+ // loaded responses carry over from one survey to another.
113
+ return (
114
+ <SurveyResultsView
115
+ key={selected}
116
+ surveyId={selected}
117
+ picker={picker}
118
+ feedbackThemes={surveys?.find((survey) => survey.surveyId === selected)?.feedbackThemes ?? []}
119
+ />
120
+ )
121
+ }
122
+
123
+ export function SurveyPicker({
124
+ surveys,
125
+ selected,
126
+ onSelect,
127
+ }: {
128
+ surveys: SurveyChoice[]
129
+ selected: string
130
+ onSelect: (surveyId: string) => void
131
+ }) {
132
+ return (
133
+ <label className="svy-control">
134
+ <span className="kicker">Survey</span>
135
+ <select
136
+ className="selectbox"
137
+ value={selected}
138
+ onChange={(event) => onSelect(event.target.value)}
139
+ >
140
+ {surveys.map((survey) => (
141
+ <option key={survey.surveyId} value={survey.surveyId}>
142
+ {survey.label}
143
+ </option>
144
+ ))}
145
+ </select>
146
+ </label>
147
+ )
148
+ }
149
+
150
+ function SurveyResultsView({
151
+ surveyId,
152
+ picker,
153
+ feedbackThemes,
154
+ }: {
155
+ surveyId: string
156
+ picker: ReactNode
157
+ feedbackThemes: SurveyFeedbackTheme[]
158
+ }) {
93
159
  const [state, setState] = useState<SurveyResponsesState>({ kind: 'loading' })
94
160
  const [offset, setOffset] = useState(0)
95
161
  const [labelSaveFailed, setLabelSaveFailed] = useState(false)
@@ -189,6 +255,8 @@ export function SurveyResponsesScreen({ surveyId }: { surveyId: string }) {
189
255
  labelSaveFailed={labelSaveFailed}
190
256
  everyone={everyone}
191
257
  onLoadEveryone={(total) => void loadEveryone(total)}
258
+ picker={picker}
259
+ feedbackThemes={feedbackThemes}
192
260
  />
193
261
  )
194
262
  }
@@ -201,6 +269,18 @@ function withAnswerLabel(
201
269
  ): SurveyResults {
202
270
  return {
203
271
  ...data,
272
+ responses: data.responses.map((response) =>
273
+ response.id !== responseId
274
+ ? response
275
+ : {
276
+ ...response,
277
+ answers: response.answers?.map((answer) =>
278
+ answer.questionId !== questionId
279
+ ? answer
280
+ : { ...answer, label, labelSource: 'owner' },
281
+ ),
282
+ },
283
+ ),
204
284
  freeText: data.freeText.map((entry) =>
205
285
  entry.responseId === responseId && entry.questionId === questionId
206
286
  ? // A successful owner save stores source 'owner' server-side; the
@@ -224,6 +304,8 @@ export function SurveyResponsesBody({
224
304
  onLoadEveryone,
225
305
  compareBy = '',
226
306
  commentMood = '',
307
+ picker = null,
308
+ feedbackThemes = [],
227
309
  }: {
228
310
  surveyId: string
229
311
  state: SurveyResponsesState
@@ -239,30 +321,40 @@ export function SurveyResponsesBody({
239
321
  compareBy?: string
240
322
  /** The comment mood filter to open with; the screen starts with all readers. */
241
323
  commentMood?: Mood | ''
324
+ /** The survey switcher, shown in the header when there is more than one survey. */
325
+ picker?: ReactNode
326
+ feedbackThemes?: SurveyFeedbackTheme[]
242
327
  }) {
243
328
  const canSeeVerbatims = canViewSurveyVerbatims(role)
244
329
  const canLabel = canLabelSurveyAnswers(role)
245
330
 
246
- if (state.kind === 'loading') return <StatusPage title="Loading survey responses…" />
331
+ if (state.kind === 'loading')
332
+ return <StatusPage picker={picker} title="Loading survey responses…" />
247
333
  if (state.kind === 'forbidden')
248
- return <StatusPage title="You do not have permission to view survey responses." />
334
+ return (
335
+ <StatusPage picker={picker} title="You do not have permission to view survey responses." />
336
+ )
249
337
  if (state.kind === 'unavailable')
250
- return <StatusPage title="Survey reporting is not configured for this site." />
338
+ return <StatusPage picker={picker} title="Survey reporting is not configured for this site." />
251
339
  if (state.kind === 'auth_failed')
252
340
  return (
253
341
  <StatusPage
342
+ picker={picker}
254
343
  title="Survey reporting rejected this site’s credentials."
255
344
  detail="The signing secret shared with the reporting upstream is missing or does not match."
256
345
  retry={onRetry}
257
346
  />
258
347
  )
259
348
  if (state.kind === 'malformed')
260
- return <StatusPage title="Masthead received invalid survey data." retry={onRetry} />
349
+ return (
350
+ <StatusPage picker={picker} title="Masthead received invalid survey data." retry={onRetry} />
351
+ )
261
352
  if (state.kind === 'not_found')
262
- return <StatusPage title="The configured survey could not be found." />
353
+ return <StatusPage picker={picker} title="The configured survey could not be found." />
263
354
  if (state.kind === 'retryable')
264
355
  return (
265
356
  <StatusPage
357
+ picker={picker}
266
358
  title="Survey responses could not be loaded."
267
359
  detail="The request did not reach the reporting upstream, or it failed before replying."
268
360
  retry={onRetry}
@@ -285,12 +377,15 @@ export function SurveyResponsesBody({
285
377
  : `${total} ${total === 1 ? 'response' : 'responses'} · ${surveyStatusLabel(data.survey.status)}`}
286
378
  </div>
287
379
  </div>
288
- <a
289
- className="btn"
290
- href={`/admin/api/surveys/${encodeURIComponent(surveyId)}/responses.csv`}
291
- >
292
- Export CSV
293
- </a>
380
+ <div className="svy-head-actions">
381
+ {picker}
382
+ <a
383
+ className="btn"
384
+ href={`/admin/api/surveys/${encodeURIComponent(surveyId)}/responses.csv`}
385
+ >
386
+ Export CSV
387
+ </a>
388
+ </div>
294
389
  </div>
295
390
 
296
391
  {total === 0 ? (
@@ -309,6 +404,8 @@ export function SurveyResponsesBody({
309
404
  initialCompareBy={compareBy}
310
405
  />
311
406
 
407
+ {feedbackThemes.length > 0 && <FeedbackThemes themes={feedbackThemes} />}
408
+
312
409
  {canSeeVerbatims ? (
313
410
  <Comments
314
411
  data={data}
@@ -336,6 +433,51 @@ export function SurveyResponsesBody({
336
433
  )
337
434
  }
338
435
 
436
+ // Existing site configuration supplies reviewed statuses; the survey payload stays unchanged.
437
+ function FeedbackThemes({ themes }: { themes: SurveyFeedbackTheme[] }) {
438
+ const states: Record<SurveyFeedbackTheme['state'], string> = {
439
+ open: 'Open',
440
+ partly_shipped: 'Partly shipped',
441
+ shipped: 'Shipped',
442
+ unknown: 'Not verified',
443
+ }
444
+ return (
445
+ <section className="svy-section" aria-labelledby="survey-feedback-themes">
446
+ <div className="svy-section-head">
447
+ <div className="kicker">Recurring feedback</div>
448
+ <h2 className="card-title" id="survey-feedback-themes">
449
+ You said → we shipped
450
+ </h2>
451
+ <p className="page-sub">
452
+ Reviewed delivery notes for recurring reader themes. These are dated updates, not live
453
+ issue statuses or counts of survey answers. Open work stays visible.
454
+ </p>
455
+ </div>
456
+ <div className="svy-grid">
457
+ {themes.map((theme) => (
458
+ <article className="card" key={theme.id} data-feedback-theme={theme.id}>
459
+ <div className="kicker">{states[theme.state]}</div>
460
+ <h3 className="svy-q-prompt">{theme.title}</h3>
461
+ <p className="svy-takeaway">{theme.summary}</p>
462
+ <p className="svy-note">
463
+ Reviewed <time dateTime={theme.reviewedAt}>{theme.reviewedAt.slice(0, 10)}</time>
464
+ </p>
465
+ {theme.evidence.length > 0 && (
466
+ <ul className="svy-note">
467
+ {theme.evidence.map((link) => (
468
+ <li key={link.href}>
469
+ <a href={link.href}>{link.label}</a>
470
+ </li>
471
+ ))}
472
+ </ul>
473
+ )}
474
+ </article>
475
+ ))}
476
+ </div>
477
+ </section>
478
+ )
479
+ }
480
+
339
481
  // ── Headline ────────────────────────────────────────────────────────────
340
482
 
341
483
  // The first scale question is the survey's headline number. Leading with the
@@ -816,15 +958,31 @@ function Comments({
816
958
 
817
959
  // Non-owners never receive technical or unlabeled entries (the route filters
818
960
  // server-side), so for them this feed is already the general thread.
961
+ const writtenAnswers: WrittenAnswer[] = [...data.freeText]
962
+ for (const response of everyone.kind === 'ready' ? everyone.responses : data.responses) {
963
+ for (const answer of response.answers ?? []) {
964
+ if (answer.writtenValue === undefined) continue
965
+ writtenAnswers.push({
966
+ questionId: answer.questionId,
967
+ responseId: response.id,
968
+ submittedAt: response.submittedAt,
969
+ value: answer.writtenValue,
970
+ label: answer.label,
971
+ labelSource: answer.labelSource,
972
+ classificationStatus: answer.classificationStatus,
973
+ nextAttemptAt: answer.nextAttemptAt,
974
+ })
975
+ }
976
+ }
819
977
  const visibleFreeText =
820
- labelFilter === 'all' ? data.freeText : data.freeText.filter((e) => e.label === labelFilter)
978
+ labelFilter === 'all' ? writtenAnswers : writtenAnswers.filter((e) => e.label === labelFilter)
821
979
  const writtenByResponse = new Map<string, WrittenAnswer[]>()
822
980
  for (const entry of visibleFreeText) {
823
981
  const entries = writtenByResponse.get(entry.responseId) ?? []
824
982
  entries.push(entry)
825
983
  writtenByResponse.set(entry.responseId, entries)
826
984
  }
827
- const wroteSomething = new Set(data.freeText.map((entry) => entry.responseId))
985
+ const wroteSomething = new Set(writtenAnswers.map((entry) => entry.responseId))
828
986
 
829
987
  // A filter answers "what do unhappy readers say", so it searches every
830
988
  // response, never just the page on screen. The written answers already
@@ -949,13 +1107,15 @@ function Comments({
949
1107
  const mood = moodOf(response)
950
1108
  const others = (response.answers ?? []).filter(
951
1109
  (answer) =>
952
- !textQuestionIds.has(answer.questionId) && answer.questionId !== headline?.id,
1110
+ !textQuestionIds.has(answer.questionId) &&
1111
+ answer.writtenValue === undefined &&
1112
+ answer.questionId !== headline?.id,
953
1113
  )
954
1114
  return (
955
1115
  <article className="card svy-comment" key={response.id}>
956
1116
  {(writtenByResponse.get(response.id) ?? []).map((entry) => (
957
1117
  <div className="svy-comment-entry" key={`${response.id}:${entry.questionId}`}>
958
- {textQuestions.length > 1 ? (
1118
+ {textQuestions.length > 1 || !textQuestionIds.has(entry.questionId) ? (
959
1119
  <div className="kicker">{promptById.get(entry.questionId)}</div>
960
1120
  ) : null}
961
1121
  <blockquote className="svy-quote">{entry.value}</blockquote>
@@ -984,6 +1144,21 @@ function Comments({
984
1144
  >
985
1145
  General
986
1146
  </button>
1147
+ {!entry.label && entry.classificationStatus ? (
1148
+ <span className="svy-meta" role="status">
1149
+ {entry.classificationStatus === 'pending'
1150
+ ? 'Classification in progress. Reload to check the result.'
1151
+ : entry.classificationStatus === 'manual_review'
1152
+ ? 'Choose a label manually.'
1153
+ : entry.classificationStatus === 'retry'
1154
+ ? `Classification failed. ${entry.nextAttemptAt ? `Retry eligible after ${formatTime(new Date(entry.nextAttemptAt).toISOString())}. ` : ''}Reload to retry, or choose a label.`
1155
+ : entry.classificationStatus === 'expired'
1156
+ ? 'Classification timed out. Reload to schedule a retry, or choose a label.'
1157
+ : entry.classificationStatus === 'unavailable'
1158
+ ? 'Classification status unavailable. Reload or choose a label.'
1159
+ : 'Awaiting classification. Reload to check, or choose a label.'}
1160
+ </span>
1161
+ ) : null}
987
1162
  {entry.labelSource === 'auto' ? (
988
1163
  <span
989
1164
  className="chip is-quiet"
@@ -1114,14 +1289,19 @@ function StatusPage({
1114
1289
  title,
1115
1290
  detail,
1116
1291
  retry,
1292
+ picker = null,
1117
1293
  }: {
1118
1294
  title: string
1119
1295
  detail?: string
1120
1296
  retry?: () => void
1297
+ picker?: ReactNode
1121
1298
  }) {
1122
1299
  return (
1123
1300
  <div className="page" role="status">
1124
- <h1 className="page-title">Survey responses</h1>
1301
+ <div className="page-head">
1302
+ <h1 className="page-title">Survey responses</h1>
1303
+ {picker}
1304
+ </div>
1125
1305
  <p className="page-sub">{title}</p>
1126
1306
  {detail ? <p className="page-sub">{detail}</p> : null}
1127
1307
  {retry ? (
@@ -25,7 +25,7 @@ import { PodcastListeningScreen } from './PodcastListeningScreen.js'
25
25
  import { SettingsScreen } from './SettingsScreen.js'
26
26
  import { SocialShareScreen } from './SocialShareScreen.js'
27
27
  import { SubscriptionsScreen } from './SubscriptionsScreen.js'
28
- import { SurveyResponsesScreen } from './SurveyResponsesScreen.js'
28
+ import { type SurveyChoice, SurveyResponsesScreen } from './SurveyResponsesScreen.js'
29
29
  import { TopicsScreen } from './TopicsScreen.js'
30
30
 
31
31
  // Thin wrappers that supply the openDoc seam for screens that need it.
@@ -47,12 +47,14 @@ export function createCmsScreens({
47
47
  primaryTopic,
48
48
  contentMetadataSelects = [],
49
49
  surveyId,
50
+ surveys,
50
51
  }: {
51
52
  allowInlineAuthorCreation?: boolean
52
53
  primaryCategories?: Array<{ slug: string; label: string }>
53
54
  primaryTopic?: PrimaryTopicControl
54
55
  contentMetadataSelects?: ContentMetadataSelectField[]
55
56
  surveyId?: string
57
+ surveys?: SurveyChoice[]
56
58
  } = {}): ScreenRegistry {
57
59
  return {
58
60
  content: () => (
@@ -73,7 +75,9 @@ export function createCmsScreens({
73
75
  topics: () => <TopicsScreen />,
74
76
  subscriptions: () => <SubscriptionsScreen />,
75
77
  ...(surveyId
76
- ? { 'survey-responses': () => <SurveyResponsesScreen surveyId={surveyId} /> }
78
+ ? {
79
+ 'survey-responses': () => <SurveyResponsesScreen surveyId={surveyId} surveys={surveys} />,
80
+ }
77
81
  : {}),
78
82
  'social-share': () => (
79
83
  <SocialShareScreen allowInlineAuthorCreation={allowInlineAuthorCreation} />
@@ -533,3 +533,4 @@ button.chip:hover { background: var(--hover); color: var(--ink); }
533
533
  .svy-big { font-size: 44px; }
534
534
  .svy-control { min-width: 0; width: 100%; max-width: none; }
535
535
  }
536
+ .svy-head-actions { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 12px; }