@plannotator/ui 0.53.1 → 0.55.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 (149) hide show
  1. package/HANDOFF.md +274 -14
  2. package/README.md +114 -11
  3. package/components/AgentToolAnnouncementDialog.tsx +1 -1
  4. package/components/AgentsTab.tsx +1 -1
  5. package/components/AnnotationPanel.tsx +142 -24
  6. package/components/AnnotationSidebar.tsx +1 -1
  7. package/components/AnnotationToolbar.tsx +64 -9
  8. package/components/ApproveDropdown.tsx +7 -9
  9. package/components/AskSessionAnnouncementDialog.tsx +1 -1
  10. package/components/AttachmentsButton.tsx +1 -1
  11. package/components/BlockRenderer.tsx +22 -5
  12. package/components/CodeFilePopout.tsx +139 -4
  13. package/components/CodeSnippetPreview.tsx +174 -0
  14. package/components/CommentPopover.tsx +44 -8
  15. package/components/DecisionControl.tsx +31 -5
  16. package/components/DeferredMarkdownContentsRail.tsx +38 -0
  17. package/components/DiagramBlock.tsx +17 -12
  18. package/components/DiffFileTree.tsx +7 -6
  19. package/components/DocumentContents.tsx +74 -0
  20. package/components/EditorAnnotationCard.tsx +1 -1
  21. package/components/ExportModal.tsx +77 -7
  22. package/components/HostTitle.tsx +0 -4
  23. package/components/ImageAnnotator/Toolbar.tsx +25 -1
  24. package/components/ImageAnnotator/index.tsx +24 -4
  25. package/components/ImageLightbox.tsx +722 -126
  26. package/components/ImagePeek.tsx +38 -4
  27. package/components/ImageRegions.tsx +369 -0
  28. package/components/ImageThumbnail.tsx +1 -1
  29. package/components/ImageZoomStage.tsx +796 -0
  30. package/components/InboxAnnouncementDialog.tsx +202 -0
  31. package/components/InlineMarkdown.tsx +167 -174
  32. package/components/KeyboardShortcuts.tsx +23 -0
  33. package/components/LazyBlockBoundary.tsx +100 -0
  34. package/components/ListMarker.tsx +3 -3
  35. package/components/LookAndFeelAnnouncementDialog.tsx +12 -2
  36. package/components/MarkdownContentsRail.tsx +777 -0
  37. package/components/MenuVersionSection.tsx +31 -9
  38. package/components/PlanDecisionMenuExtras.tsx +129 -0
  39. package/components/PlanHeaderMenu.tsx +15 -1
  40. package/components/ProviderIcons.tsx +8 -0
  41. package/components/RefHoverCard.tsx +913 -0
  42. package/components/SendFailureToast.tsx +126 -0
  43. package/components/Settings.tsx +617 -126
  44. package/components/TableOfContents.tsx +20 -6
  45. package/components/TerminalToolsAnnouncementDialog.tsx +1 -1
  46. package/components/ThemeProvider.tsx +14 -1
  47. package/components/ThemeTab.tsx +16 -24
  48. package/components/Tooltip.tsx +49 -18
  49. package/components/Viewer.tsx +288 -43
  50. package/components/ai/DocumentAIChatPanel.tsx +29 -3
  51. package/components/ai/NewChatButton.tsx +74 -0
  52. package/components/blocks/CodeBlock.tsx +1 -1
  53. package/components/blocks/HtmlBlock.tsx +7 -1
  54. package/components/blocks/QuestionBlock.tsx +532 -62
  55. package/components/diagram/DiagramComposer.tsx +5 -1
  56. package/components/diagram/DiagramPending.tsx +12 -2
  57. package/components/diagram/DiagramViewer.tsx +19 -3
  58. package/components/html-embed/HtmlEmbedBlock.tsx +374 -0
  59. package/components/html-embed/SvgFenceBlock.tsx +46 -0
  60. package/components/html-embed/index.ts +3 -0
  61. package/components/html-embed/languages.ts +15 -0
  62. package/components/html-embed/loadGate.ts +107 -0
  63. package/components/html-viewer/HtmlViewer.tsx +82 -6
  64. package/components/html-viewer/bridge-script.asset.js +173 -1
  65. package/components/html-viewer/bridge-script.ts +173 -1
  66. package/components/html-viewer/useHtmlAnnotation.ts +125 -0
  67. package/components/imageZoom.ts +276 -0
  68. package/components/plan-diff/PlanCleanDiffView.tsx +1 -1
  69. package/components/plan-diff/PlanDiffViewer.tsx +13 -2
  70. package/components/plan-diff/PlanRawDiffView.tsx +1 -1
  71. package/components/sidebar/FileBrowser.tsx +16 -2
  72. package/components/sidebar/SidebarTabs.tsx +3 -0
  73. package/config/index.ts +3 -0
  74. package/config/reviewView.ts +41 -1
  75. package/config/settings.ts +155 -4
  76. package/configure.ts +28 -0
  77. package/contents-rail.css +337 -0
  78. package/hooks/pfm/useCodeFilePopout.ts +7 -1
  79. package/hooks/useAIChat.ts +72 -25
  80. package/hooks/useAnnotationDraft.ts +71 -6
  81. package/hooks/useAnnotationHighlighter.ts +57 -3
  82. package/hooks/useCodeAnnotationDraft.ts +113 -17
  83. package/hooks/useCookieHostSetting.ts +96 -0
  84. package/hooks/useDocumentFont.ts +17 -0
  85. package/hooks/useDocumentTextSize.ts +20 -0
  86. package/hooks/useFileBrowser.ts +10 -1
  87. package/hooks/useHeadingScrollSpy.ts +77 -0
  88. package/hooks/useHoverPreview.ts +94 -0
  89. package/hooks/useLinkedDoc.ts +226 -13
  90. package/hooks/usePackageChangesSetting.ts +90 -0
  91. package/hooks/usePlanLookDialog.ts +67 -0
  92. package/hooks/usePrefersReducedMotion.ts +23 -0
  93. package/hooks/useSharing.ts +11 -7
  94. package/hooks/useSwipeStep.ts +597 -0
  95. package/hooks/useThemePaletteHalves.ts +37 -0
  96. package/hooks/useUpdateCheck.ts +22 -4
  97. package/hooks/useUpdateToast.ts +1 -1
  98. package/package.json +26 -23
  99. package/shortcuts/code-review/annotationToolbar.shortcuts.ts +1 -0
  100. package/shortcuts/code-review/explorer.shortcuts.ts +82 -0
  101. package/shortcuts/core.ts +63 -4
  102. package/shortcuts/index.ts +1 -0
  103. package/shortcuts/plan-review/annotationPanel.shortcuts.ts +1 -1
  104. package/shortcuts/plan-review/commentPopover.shortcuts.ts +1 -0
  105. package/shortcuts/runtime.ts +4 -3
  106. package/styles.css +1 -1
  107. package/theme.css +179 -1
  108. package/themes/plannotator-graphite.css +66 -0
  109. package/types.ts +26 -9
  110. package/utils/agentSwitch.ts +41 -20
  111. package/utils/agentToolSetting.ts +1 -1
  112. package/utils/aiChatFormat.ts +23 -7
  113. package/utils/annotationHelpers.ts +32 -4
  114. package/utils/annotationOwnsHighlight.ts +4 -2
  115. package/utils/asciidocParser.ts +570 -0
  116. package/utils/autoUpdateNotice.ts +24 -0
  117. package/utils/belowTitleSlot.ts +174 -0
  118. package/utils/blockParse.ts +45 -0
  119. package/utils/codeLineScroll.ts +100 -0
  120. package/utils/commentSubmitKey.ts +48 -0
  121. package/utils/cssFontFamily.ts +65 -0
  122. package/utils/decisionSpec.ts +190 -2
  123. package/utils/defaultNotesApp.ts +1 -1
  124. package/utils/diffFonts.ts +4 -1
  125. package/utils/documentFont.ts +68 -0
  126. package/utils/documentTextSize.ts +54 -0
  127. package/utils/fontDetect.ts +63 -0
  128. package/utils/headingScrollSpy.ts +92 -0
  129. package/utils/inboxAnnouncement.ts +78 -0
  130. package/utils/lookAndFeelAnnouncement.ts +82 -5
  131. package/utils/markdownExtensions.ts +2 -2
  132. package/utils/mermaid.ts +20 -3
  133. package/utils/mermaidTheme.ts +3 -51
  134. package/utils/notion.ts +58 -0
  135. package/utils/parser.ts +28 -1
  136. package/utils/platform.ts +46 -0
  137. package/utils/refCard.ts +82 -0
  138. package/utils/richClipboard.ts +380 -0
  139. package/utils/richClipboardLists.ts +57 -0
  140. package/utils/richCopyPolicy.ts +22 -0
  141. package/utils/scrollAnchor.ts +82 -0
  142. package/utils/sectionRefs.ts +123 -0
  143. package/utils/selectionBoundary.ts +40 -0
  144. package/utils/sendFailure.ts +85 -0
  145. package/utils/sharing.ts +5 -2
  146. package/utils/themeRegistry.ts +10 -0
  147. package/utils/themeTokens.ts +74 -0
  148. package/utils/updateNotice.ts +30 -8
  149. package/utils/vscodePanel.ts +9 -0
@@ -14,6 +14,8 @@ import {
14
14
  import { InlineMarkdown } from '../InlineMarkdown';
15
15
  import { QuestionContext } from './QuestionContext';
16
16
  import { HostTitle } from '../HostTitle';
17
+ import { UNAVAILABLE_IMAGE_CLASS, useImageResolver } from '../ImageResolverContext';
18
+ import { getImageSrc } from '../ImageThumbnail';
17
19
 
18
20
  /**
19
21
  * A `:::question` block rendered as an answer card (the approved "Card"
@@ -46,6 +48,20 @@ import { HostTitle } from '../HostTitle';
46
48
  * host supplies a state), open its own decision
47
49
  * card from the tag's words (`onOpenDecision`), and hide the card's own
48
50
  * status tag to draw its own (`statusTag: 'none'`).
51
+ *
52
+ * The optional contract lines (approval "inbox-decision-mode"): `Stakes:` is
53
+ * a short emphasised line under the prompt, `Shows:` a chip that opens the
54
+ * picture (through `onImageClick`) or the file (an ordinary link), `Why:` a
55
+ * line under the choices ("Why <recommended>: …"), and `Reversible: no` a
56
+ * "Can't be undone" tag in the eyebrow. The card adds no confirm step.
57
+ *
58
+ * Picture choices (`choice.image`) draw as a grid of tiles: the picture and
59
+ * the label pick (a real radio or checkbox in each tile), a separate zoom
60
+ * button opens the picture (`onImageClick`), the label and description sit
61
+ * under it. When no picture can be shown (the host's image resolver says
62
+ * `unavailable`, every picture failed to load, or `pictureChoices={false}`)
63
+ * the choices are the ordinary text rows. The recommended answer that is
64
+ * none of the choices, and Other, stay text rows.
49
65
  */
50
66
 
51
67
  /** Pointer travel (px) above which a press on a choice row is a drag. */
@@ -57,6 +73,10 @@ const DOUBLE_CLICK_REVERT_MS = 800;
57
73
 
58
74
  const now = (): number => (typeof performance !== 'undefined' ? performance.now() : Date.now());
59
75
 
76
+ /** The double-click guard's name for the recommended row (never a label: it
77
+ * holds a character no choice label can). */
78
+ const SUGGESTION_ROW = '\u0000suggestion';
79
+
60
80
  const cx = (...parts: Array<string | false | null | undefined>) => parts.filter(Boolean).join(' ');
61
81
 
62
82
  /** The box every pill in the eyebrow shares (the status tags, the decision
@@ -70,7 +90,7 @@ const cx = (...parts: Array<string | false | null | undefined>) => parts.filter(
70
90
  * the guides.show viewer pins (a deploy for no visible change). */
71
91
  const PILL = 'annotation-exclude select-none inline-flex h-[18px] items-center gap-1 whitespace-nowrap rounded border px-1.5 align-[1px] text-[10.5px] font-semibold leading-4 tracking-[0.03em]';
72
92
 
73
- const Tag: React.FC<{ tone: 'rec' | 'ok' | 'skip' | 'open'; children: React.ReactNode; className?: string; style?: React.CSSProperties }> = ({ tone, children, className, style }) => (
93
+ const Tag: React.FC<{ tone: 'rec' | 'ok' | 'skip' | 'open' | 'warn'; children: React.ReactNode; className?: string; style?: React.CSSProperties }> = ({ tone, children, className, style }) => (
74
94
  <span
75
95
  className={cx(
76
96
  PILL,
@@ -78,10 +98,12 @@ const Tag: React.FC<{ tone: 'rec' | 'ok' | 'skip' | 'open'; children: React.Reac
78
98
  tone === 'ok' && 'border-transparent bg-success/15 text-success',
79
99
  tone === 'skip' && 'border-transparent bg-muted text-muted-foreground',
80
100
  tone === 'open' && 'border-dashed border-border text-muted-foreground',
101
+ tone === 'warn' && 'border-transparent bg-warning/15 text-warning',
81
102
  className,
82
103
  )}
83
104
  style={style}
84
105
  data-pinpoint-ignore=""
106
+ data-question-tag={tone}
85
107
  >
86
108
  {children}
87
109
  </span>
@@ -99,6 +121,20 @@ const DiamondGlyph = () => (
99
121
  </svg>
100
122
  );
101
123
 
124
+ const ZoomGlyph = () => (
125
+ <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
126
+ <circle cx="11" cy="11" r="7" />
127
+ <path d="m20 20-3.5-3.5M11 8v6M8 11h6" strokeLinecap="round" />
128
+ </svg>
129
+ );
130
+
131
+ const PictureGlyph = () => (
132
+ <svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true" className="shrink-0">
133
+ <rect x="3" y="4" width="18" height="16" rx="2" />
134
+ <path d="m3 16 5-5 4 4 3-3 6 6" strokeLinejoin="round" />
135
+ </svg>
136
+ );
137
+
102
138
  /** Draws a decision tag in its "off" state: the same words with a dotted
103
139
  * outline instead of the tinted fill, faded. The theme's muted-foreground
104
140
  * alone is exactly the "Open" tag's color beside it, so the off tag read as
@@ -115,6 +151,36 @@ const DECISION_OFF_STYLE: React.CSSProperties = {
115
151
  outlineOffset: '-1px',
116
152
  };
117
153
 
154
+ /**
155
+ * The footer in a narrow column. The card is a size container
156
+ * (`@container/qcard`: the card's own width, never the viewport's, because
157
+ * the card sits in columns of every width), and the footer row wraps whole
158
+ * buttons:
159
+ * - No button's words ever wrap inside it (`whitespace-nowrap`): the row
160
+ * wraps between buttons instead, and only when they do not fit on one
161
+ * row, so Add note and Skip stay together and Accept recommended moves
162
+ * down whole, still at the row's end (`ml-auto`).
163
+ * - Under 10rem inside the card (a card under about 190 px), where a button
164
+ * may be wider than the card, Accept recommended and the save bar take a
165
+ * row of their own and their words end in an ellipsis rather than leave
166
+ * the card (`FOOT_FIT`; the name stays whole). The words stay one text
167
+ * node: a split one moved the glyphs of a wide card by a fraction of a
168
+ * pixel.
169
+ * - On a touch screen each button's hit area is at least 44 x 44 px: an
170
+ * invisible `before:` box centred on it, as the header's decision tag
171
+ * does, so the drawn button does not change. (An ellipsised button clips
172
+ * that box to itself.) A box reaches up to 11 px past its button, so a
173
+ * wrapped footer's rows sit 20 px apart on a touch screen
174
+ * (`pointer-coarse:gap-y-5`, the footer row): with the 4 px of `gap-1`,
175
+ * Accept recommended's box, later in the page, covered the bottom of Skip
176
+ * and Add note above it, and a tap on Skip answered the question. One row
177
+ * has no row gap, so a footer that fits is unchanged.
178
+ * A footer that fits on one row draws exactly as before, on any pointer.
179
+ */
180
+ const FOOT_BUTTON = "whitespace-nowrap pointer-coarse:relative pointer-coarse:before:absolute pointer-coarse:before:left-1/2 pointer-coarse:before:top-1/2 pointer-coarse:before:h-11 pointer-coarse:before:w-full pointer-coarse:before:min-w-11 pointer-coarse:before:-translate-x-1/2 pointer-coarse:before:-translate-y-1/2 pointer-coarse:before:content-['']";
181
+ const FOOT_FIT = '@max-[10rem]/qcard:max-w-full @max-[10rem]/qcard:overflow-hidden @max-[10rem]/qcard:text-ellipsis';
182
+ const FOOT_OWN_ROW = '@max-[10rem]/qcard:basis-full';
183
+
118
184
  /** Two answers say the same thing (identity and line ignored). */
119
185
  const sameAnswer = (a: QuestionAnswer | null | undefined, b: QuestionAnswer | null | undefined): boolean => {
120
186
  const norm = (x: QuestionAnswer | null | undefined) =>
@@ -197,8 +263,38 @@ export interface QuestionBlockProps {
197
263
  githubRepo?: string;
198
264
  repoHost?: string;
199
265
  onNavigateAnchor?: (hash: string) => void;
266
+ /** Draw picture choices (`choice.image`) as picture tiles (default true).
267
+ * False: every choice is a text row, as on a host that shows no images. */
268
+ pictureChoices?: boolean;
269
+ /** A small label over a picture tile's picture, top left ("Mockup · VS
270
+ * Code"), from the host; null or absent: none. Display only: never the
271
+ * answer, never read by the parser. Default absent, so every card draws as
272
+ * before (the Plannotator Inbox's onboarding tour labels its pictures). */
273
+ pictureBadge?: (choice: { label: string; image: { alt: string; src: string } }) => string | null;
274
+ /** The zoom button on a picture tile, told which question and which choice
275
+ * (its index in the question's choices) as well as the picture: a host can
276
+ * open a gallery of that question's choices (the Plannotator Inbox's option
277
+ * sets). Absent: the zoom calls `onImageClick(url, alt)` as before. */
278
+ onZoomChoice?: (key: string, index: number, url: string, alt: string) => void;
200
279
  }
201
280
 
281
+ /** The picture tile's label (`pictureBadge`): a small dark chip over the picture's top-left corner. */
282
+ const PICTURE_BADGE_STYLE: React.CSSProperties = {
283
+ position: 'absolute',
284
+ left: 6,
285
+ top: 6,
286
+ zIndex: 1,
287
+ pointerEvents: 'none',
288
+ borderRadius: 4,
289
+ background: 'rgb(0 0 0 / 0.6)',
290
+ color: 'white',
291
+ padding: '0 6px',
292
+ fontSize: 10.5,
293
+ fontWeight: 600,
294
+ lineHeight: '18px',
295
+ letterSpacing: '0.02em',
296
+ };
297
+
202
298
  /** A host's status tag (`statusLabel`): its words, and the tone of one of the card's own tags (default `open`). */
203
299
  export interface QuestionStatusLabel {
204
300
  label: string;
@@ -228,8 +324,39 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
228
324
  githubRepo,
229
325
  repoHost,
230
326
  onNavigateAnchor,
327
+ pictureChoices = true,
328
+ pictureBadge,
329
+ onZoomChoice,
231
330
  }) => {
232
331
  const { question, number } = indexed;
332
+ const resolveImage = useImageResolver();
333
+ // The URL a picture loads from, exactly as an inline markdown image of the
334
+ // same src would (the host's resolver first), or null when the host says
335
+ // it cannot be shown.
336
+ const pictureSrc = (src: string): string | null => {
337
+ const resolved = resolveImage ? resolveImage(src) : null;
338
+ if (resolved?.kind === 'unavailable') return null;
339
+ if (resolved?.kind === 'src') return resolved.src;
340
+ return /^(https?:\/\/|data:|blob:)/i.test(src) ? src : getImageSrc(src, imageBaseDir);
341
+ };
342
+ // The host's reason a picture is not shown (its resolver's `unavailable`
343
+ // title), drawn on the placeholder exactly as an inline image's, so a host
344
+ // that holds remote images counts and loads these too (code review's PR
345
+ // Markdown preview: "N remote images held · Load remote images").
346
+ const pictureHeldTitle = (src: string): string | null => {
347
+ const resolved = resolveImage ? resolveImage(src) : null;
348
+ return resolved?.kind === 'unavailable' ? resolved.title : null;
349
+ };
350
+ const heldPlaceholder = (src: string, alt: string) => {
351
+ const title = pictureHeldTitle(src);
352
+ return title === null ? null : (
353
+ <span className={UNAVAILABLE_IMAGE_CLASS} title={title} data-image-unavailable="">{alt || 'Image'}</span>
354
+ );
355
+ };
356
+ // Pictures that failed to load (by the URL tried): a tile shows its alt
357
+ // text instead, and with none left the choices are text rows.
358
+ const [failedPictures, setFailedPictures] = useState<ReadonlySet<string>>(() => new Set());
359
+ const markPictureFailed = (url: string) => setFailedPictures((prev) => (prev.has(url) ? prev : new Set([...prev, url])));
233
360
  // An answer saved under an older label of a choice (0.28.1 read a wrapped
234
361
  // plain bullet's first line as its label) shows that choice picked; the
235
362
  // next edit stores the current label.
@@ -275,6 +402,7 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
275
402
  }
276
403
  };
277
404
  const cancel = () => {
405
+ replacedOtherRef.current = null;
278
406
  setDraft(null);
279
407
  setNotePinned(null);
280
408
  };
@@ -283,6 +411,7 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
283
411
  const uid = useId().replace(/:/g, '');
284
412
  const promptId = `q-prompt-${uid}`;
285
413
  const contextId = `q-context-${uid}`;
414
+ const stakesId = `q-stakes-${uid}`;
286
415
  const suggestId = `q-suggest-${uid}`;
287
416
  const noteLabelId = `q-note-${uid}`;
288
417
  // null = follow the answer (open while a note exists); a click pins it.
@@ -291,10 +420,21 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
291
420
  const pressRef = useRef<{ x: number; y: number; touch: boolean } | null>(null);
292
421
  const noteRef = useRef<HTMLTextAreaElement>(null);
293
422
  const cardRef = useRef<HTMLFieldSetElement>(null);
294
- // Accept recommended (and Use) go away once clicked: focus moves to what
423
+ // Accept recommended goes away once clicked: focus moves to what
295
424
  // the click filled in, never to the page.
296
425
  const focusAfterAcceptRef = useRef(false);
297
426
  const lastPickRef = useRef<{ label: string; before: QuestionAnswer | undefined; at: number } | null>(null);
427
+ // Who set the "Other…" answer while this card was open: the recommended
428
+ // row (its pick, or Accept recommended) or the Other field. null: neither
429
+ // (an answer the host stored): an Other answer that is exactly the agent's
430
+ // text then reads as the row, unless the Other field has focus. Typing
431
+ // never turns into the row, so "Yes, but slower" stays in the field when
432
+ // the recommendation is "Yes".
433
+ const [otherBy, setOtherBy] = useState<'row' | 'field' | null>(null);
434
+ const [otherFocused, setOtherFocused] = useState(false);
435
+ // Pick any: the reviewer's own Other text the recommended row replaced when
436
+ // ticked, put back when it is unticked (the answer has one Other field).
437
+ const replacedOtherRef = useRef<string | null>(null);
298
438
 
299
439
  const inline = (text: string) => (
300
440
  <InlineMarkdown
@@ -322,9 +462,9 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
322
462
  // recommendation names choices until the answer is exactly those choices:
323
463
  // ticking one box must not take away the one click that ticks the rest. A
324
464
  // pick-one or text question answered any way, a question with a settled
325
- // choice, and a pick-any question recommending only a suggestion (whose
326
- // Suggested line goes once the card is answered, so Accept would clear the
327
- // ticks for something no longer shown) offer it no more.
465
+ // choice, and a pick-any question recommending only its own answer (the
466
+ // recommended row after the choices: Accept would clear the ticks for it,
467
+ // and the row is one click anyway) offer it no more.
328
468
  const offerAccept = !readOnly && hasRecommendation && !settled && (
329
469
  !answered
330
470
  || (question.kind === 'multi'
@@ -361,7 +501,37 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
361
501
  }
362
502
  };
363
503
 
504
+ // The agent's recommended answer that is none of its choices
505
+ // (`suggestedText` on a pick question): one more answer row, after the
506
+ // choices and before "Other…". Picking it stores exactly what Accept
507
+ // recommended stores, the text as the "Other…" answer, so no label,
508
+ // index, key or host check of picked labels changes.
509
+ const pickSuggestion = (checked: boolean) => {
510
+ const text = question.suggestedText;
511
+ if (!text) return;
512
+ lastPickRef.current = { label: SUGGESTION_ROW, before: answer, at: now() };
513
+ const { skipped: _s, other: _o, ...rest } = base;
514
+ if (question.kind === 'multi') {
515
+ if (checked) {
516
+ if (!suggestionPicked) replacedOtherRef.current = storedOther.trim() ? storedOther : null;
517
+ setOtherBy('row');
518
+ commit({ ...rest, selected, other: text });
519
+ } else {
520
+ const back = replacedOtherRef.current;
521
+ replacedOtherRef.current = null;
522
+ setOtherBy(back ? 'field' : null);
523
+ commit({ ...rest, selected, ...(back ? { other: back } : {}) });
524
+ }
525
+ } else if (checked) {
526
+ replacedOtherRef.current = null;
527
+ setOtherBy('row');
528
+ commit({ ...rest, selected: [], other: text });
529
+ }
530
+ };
531
+
364
532
  const setOther = (value: string) => {
533
+ setOtherBy('field');
534
+ replacedOtherRef.current = null;
365
535
  const { skipped: _s, other: _o, ...rest } = base;
366
536
  const keepSelected = question.kind === 'multi' ? selected : value.trim() ? [] : rest.selected;
367
537
  commit({ ...rest, selected: keepSelected, ...(value ? { other: value } : {}) });
@@ -378,6 +548,7 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
378
548
  };
379
549
 
380
550
  const toggleSkip = () => {
551
+ replacedOtherRef.current = null;
381
552
  if (base.skipped) {
382
553
  const { skipped: _s, ...rest } = base;
383
554
  commit(rest);
@@ -390,6 +561,8 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
390
561
  const acceptRecommended = () => {
391
562
  const next = recommendedQuestionAnswer(indexed, base);
392
563
  if (!next) return;
564
+ replacedOtherRef.current = null;
565
+ if (next.other) setOtherBy('row');
393
566
  focusAfterAcceptRef.current = true;
394
567
  commit(next);
395
568
  };
@@ -450,8 +623,25 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
450
623
  : `Question${question.kind === 'multi' ? ' · pick any' : ''}`;
451
624
  const groupName = `question-${uid}`;
452
625
  const inputType = question.kind === 'multi' ? 'checkbox' : 'radio';
453
- const other = answer?.other ?? '';
626
+ const storedOther = answer?.other ?? '';
627
+ // The recommended row holds the "Other…" answer when it is the agent's
628
+ // text; the Other field is then empty (typing there replaces it).
629
+ const suggestionRow = question.kind !== 'text' && !!question.suggestedText;
630
+ const suggestionPicked = suggestionRow
631
+ && storedOther.trim() !== ''
632
+ && storedOther.trim() === question.suggestedText!.trim()
633
+ && (otherBy === 'row' || (otherBy === null && !otherFocused));
634
+ const other = suggestionPicked ? '' : storedOther;
454
635
  const otherOn = other.trim() !== '';
636
+ // Picture choices: each choice's picture URL (null: none, or not shown).
637
+ const pictures = question.choices.map((c) => (c.image ? pictureSrc(c.image.src) : null));
638
+ const showTiles = pictureChoices
639
+ && question.kind !== 'text'
640
+ && pictures.some((url) => url !== null && !failedPictures.has(url));
641
+ const recommendedLabels = question.choices.filter((c) => c.recommended).map((c) => c.label);
642
+ const showsUrl = question.shows?.kind === 'image' ? pictureSrc(question.shows.src) : null;
643
+ // A `Shows:` picture that failed to load is its label, as a picture tile's is.
644
+ const showsSrc = showsUrl !== null && !failedPictures.has(showsUrl) ? showsUrl : null;
455
645
 
456
646
  // A `Decision: when answered` question the host may switch off, or (with
457
647
  // `decisionScope: 'any'`) any other question once the host supplies a
@@ -468,27 +658,16 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
468
658
  ? (recordingOn ? 'on' : 'off')
469
659
  : undefined;
470
660
 
471
- const describedBy = [question.context ? contextId : null, question.suggestedText && !answered ? suggestId : null]
661
+ const describedBy = [question.stakes ? stakesId : null, question.context ? contextId : null, question.kind === 'text' && question.suggestedText && !answered ? suggestId : null]
472
662
  .filter(Boolean)
473
663
  .join(' ') || undefined;
474
664
 
475
- // The suggestion the agent wrote that is not one of the choices (a choice
476
- // question's `Recommended:` naming none of them; a text question's whole
477
- // suggestion): "Accept recommended" fills it in, so the card shows it.
478
- const suggestedLine = question.suggestedText && !answered && (
479
- <div id={suggestId} className="mt-2 flex items-start gap-2 text-[12.5px] text-muted-foreground" data-question-suggested="">
480
- <span className="min-w-0 flex-1">
481
- <Tag tone="rec">Suggested</Tag> {inline(question.suggestedText)}
482
- </span>
483
- {!readOnly && (
484
- <button
485
- type="button"
486
- onClick={acceptRecommended}
487
- className="shrink-0 rounded-md bg-primary/15 px-2.5 py-[3px] text-xs font-medium text-primary hover:bg-primary/25 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
488
- >
489
- Use
490
- </button>
491
- )}
665
+ // A text question's recommended answer: shown above the field with the
666
+ // same Recommended tag a choice carries; the footer's Accept recommended
667
+ // fills it in. (A pick question's is a row of its own, below.)
668
+ const suggestedLine = question.kind === 'text' && question.suggestedText && !answered && (
669
+ <div id={suggestId} className="mt-2 text-[12.5px] text-muted-foreground" data-question-suggested="">
670
+ <Tag tone="rec">Recommended</Tag> {inline(question.suggestedText)}
492
671
  </div>
493
672
  );
494
673
 
@@ -497,6 +676,8 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
497
676
  ref={cardRef}
498
677
  className={cx(
499
678
  'question-block my-5 min-w-0 rounded-[10px] border px-3.5 pb-2.5 pt-3',
679
+ // A size container: the card's own width lays out its footer (FOOT_BUTTON).
680
+ '@container/qcard',
500
681
  // Native radios/checkboxes follow the theme's mode. ThemeProvider marks
501
682
  // light mode with `.light` on the root; a host without theme classes
502
683
  // keeps the page's own color-scheme.
@@ -569,6 +750,14 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
569
750
  ) : (
570
751
  <><Tag tone="rec" style={DECISION_OFF_STYLE}><DiamondGlyph />Records a decision</Tag>{' '}</>
571
752
  ))}
753
+ {question.reversible && (
754
+ <><HostTitle title={question.reversible.why ? `Can't be undone: ${question.reversible.why}` : "Can't be undone"}>
755
+ <span className="inline-flex" data-question-irreversible="">
756
+ <Tag tone="warn">Can&rsquo;t be undone</Tag>
757
+ {question.reversible.why && <span className="sr-only">: {question.reversible.why}</span>}
758
+ </span>
759
+ </HostTitle>{' '}</>
760
+ )}
572
761
  {statusTag !== 'none' && hostStatus && <Tag tone={hostStatus.tone ?? 'open'}>{hostStatus.label}</Tag>}
573
762
  {statusTag !== 'none' && !hostStatus && (
574
763
  <>
@@ -584,6 +773,43 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
584
773
  <p id={promptId} className="m-0 text-[15px] font-semibold leading-[1.45] text-foreground" data-question-part="prompt">
585
774
  {inline(question.prompt)}
586
775
  </p>
776
+ {question.stakes && (
777
+ <p id={stakesId} className="m-0 mt-1 text-[13px] font-medium leading-[1.45] text-foreground/90" data-question-part="stakes">
778
+ {inline(question.stakes)}
779
+ </p>
780
+ )}
781
+ {question.shows && (
782
+ <div className="mt-1.5 flex flex-wrap items-center gap-1.5 text-[12.5px]" data-question-shows="">
783
+ <span className="annotation-exclude select-none text-muted-foreground" data-pinpoint-ignore="">Shows</span>
784
+ {question.shows.kind === 'image' ? (
785
+ showsSrc && onImageClick ? (
786
+ <button
787
+ type="button"
788
+ onClick={() => onImageClick(showsSrc, question.shows!.label || 'Picture')}
789
+ className="inline-flex min-w-0 max-w-full items-center gap-1.5 rounded-md border border-border/80 bg-background/60 py-0.5 pl-0.5 pr-2 text-foreground hover:bg-muted/50 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-primary"
790
+ data-question-shows-open=""
791
+ >
792
+ <img
793
+ src={showsSrc}
794
+ alt=""
795
+ draggable={false}
796
+ className="h-6 w-8 shrink-0 rounded-[4px] object-cover"
797
+ data-question-picture=""
798
+ data-zoomable=""
799
+ data-image-src={question.shows.src}
800
+ onError={() => markPictureFailed(showsSrc)}
801
+ />
802
+ <span className="truncate">{question.shows.label || 'Picture'}</span>
803
+ </button>
804
+ ) : (
805
+ heldPlaceholder(question.shows.src, question.shows.label)
806
+ ?? <span className="inline-flex items-center gap-1 text-foreground"><PictureGlyph />{question.shows.label || question.shows.src}</span>
807
+ )
808
+ ) : (
809
+ <span className="min-w-0 text-foreground">{inline(`[${question.shows.label}](${question.shows.src})`)}</span>
810
+ )}
811
+ </div>
812
+ )}
587
813
  {renderBelowPrompt?.(indexed)}
588
814
  {(question.decision || (decisionCapable && recordingOn)) && (
589
815
  <div
@@ -643,49 +869,106 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
643
869
  </>
644
870
  ) : (
645
871
  <>
646
- {suggestedLine}
647
872
  <div
648
873
  className={cx('mt-2.5 grid gap-0.5', skipped && 'opacity-55')}
649
874
  role={inputType === 'radio' ? 'radiogroup' : 'group'}
650
875
  aria-labelledby={promptId}
651
876
  >
652
- {question.choices.map((choice) => {
653
- const checked = selected.includes(choice.label);
654
- return (
655
- <label
656
- key={choice.label}
657
- className={cx(
658
- 'flex items-start gap-2.5 rounded-[7px] px-[9px] py-1.5 transition-colors',
659
- readOnly ? 'cursor-default' : 'cursor-pointer hover:bg-muted/45',
660
- checked && 'bg-primary/12 hover:bg-primary/12',
661
- )}
662
- data-question-option=""
663
- onPointerDown={onRowPointerDown}
664
- onClickCapture={(event) => onRowClick(event, choice.label)}
665
- >
666
- <input
667
- type={inputType}
668
- name={groupName}
669
- checked={checked}
670
- onChange={(e) => pick(choice.label, e.target.checked)}
671
- className="mt-[3px] h-[15px] w-[15px] shrink-0 cursor-pointer accent-primary disabled:cursor-default"
672
- disabled={readOnly}
673
- />
674
- <span className="min-w-0 flex-1 select-text text-[13.5px] leading-[1.45] text-foreground" data-question-part="choice">
675
- <span className="font-[550]">{inline(choice.label)}</span>
676
- {choice.description && (
677
- <span className="text-muted-foreground"> — {inline(choice.description)}</span>
877
+ {showTiles ? (
878
+ <QuestionPictureTiles
879
+ choices={question.choices}
880
+ urls={question.choices.map((_c, index) => {
881
+ const url = pictures[index];
882
+ return url !== null && url !== undefined && !failedPictures.has(url) ? url : null;
883
+ })}
884
+ selected={selected}
885
+ inputType={inputType}
886
+ name={groupName}
887
+ readOnly={readOnly}
888
+ onPick={pick}
889
+ onZoom={
890
+ onZoomChoice
891
+ ? (url, alt, index) => onZoomChoice(question.key, index, url, alt)
892
+ : onImageClick
893
+ }
894
+ onPictureError={markPictureFailed}
895
+ heldTitle={pictureHeldTitle}
896
+ renderText={inline}
897
+ labelProps={(label) => ({ onPointerDown: onRowPointerDown, onClickCapture: (event) => onRowClick(event, label) })}
898
+ pictureBadge={pictureBadge}
899
+ />
900
+ ) : (
901
+ question.choices.map((choice) => {
902
+ const checked = selected.includes(choice.label);
903
+ return (
904
+ <label
905
+ key={choice.label}
906
+ className={cx(
907
+ 'flex items-start gap-2.5 rounded-[7px] px-[9px] py-1.5 transition-colors',
908
+ readOnly ? 'cursor-default' : 'cursor-pointer hover:bg-muted/45',
909
+ checked && 'bg-primary/12 hover:bg-primary/12',
678
910
  )}
679
- {choice.recommended && <> <Tag tone="rec">Recommended</Tag></>}
680
- </span>
681
- </label>
682
- );
683
- })}
911
+ data-question-option=""
912
+ onPointerDown={onRowPointerDown}
913
+ onClickCapture={(event) => onRowClick(event, choice.label)}
914
+ >
915
+ <input
916
+ type={inputType}
917
+ name={groupName}
918
+ checked={checked}
919
+ onChange={(e) => pick(choice.label, e.target.checked)}
920
+ className="mt-[3px] h-[15px] w-[15px] shrink-0 cursor-pointer accent-primary disabled:cursor-default"
921
+ data-question-control=""
922
+ disabled={readOnly}
923
+ />
924
+ <span className="min-w-0 flex-1 select-text text-[13.5px] leading-[1.45] text-foreground" data-question-part="choice">
925
+ <span className="font-[550]">{inline(choice.label)}</span>
926
+ {choice.image && pictureChoices && pictureHeldTitle(choice.image.src) !== null && <> {heldPlaceholder(choice.image.src, choice.image.alt || choice.label)}</>}
927
+ {choice.description && (
928
+ <span className="text-muted-foreground"> — {inline(choice.description)}</span>
929
+ )}
930
+ {choice.recommended && <> <Tag tone="rec">Recommended</Tag></>}
931
+ </span>
932
+ </label>
933
+ );
934
+ })
935
+ )}
936
+ {suggestionRow && (
937
+ // The agent's recommended answer that is none of its choices:
938
+ // drawn exactly as a choice (the same control, row and tag), so
939
+ // the card reads as one list of answers.
940
+ <label
941
+ className={cx(
942
+ 'flex items-start gap-2.5 rounded-[7px] px-[9px] py-1.5 transition-colors',
943
+ readOnly ? 'cursor-default' : 'cursor-pointer hover:bg-muted/45',
944
+ suggestionPicked && 'bg-primary/12 hover:bg-primary/12',
945
+ )}
946
+ data-question-option=""
947
+ data-question-suggested=""
948
+ onPointerDown={onRowPointerDown}
949
+ onClickCapture={(event) => onRowClick(event, SUGGESTION_ROW)}
950
+ >
951
+ <input
952
+ type={inputType}
953
+ name={groupName}
954
+ checked={suggestionPicked}
955
+ onChange={(e) => pickSuggestion(e.target.checked)}
956
+ className="mt-[3px] h-[15px] w-[15px] shrink-0 cursor-pointer accent-primary disabled:cursor-default"
957
+ data-question-control=""
958
+ disabled={readOnly}
959
+ />
960
+ <span className="min-w-0 flex-1 select-text text-[13.5px] leading-[1.45] text-foreground" data-question-part="choice">
961
+ <span className="font-[550]">{inline(question.suggestedText!)}</span>
962
+ {' '}<Tag tone="rec">Recommended</Tag>
963
+ </span>
964
+ </label>
965
+ )}
684
966
  {/* A read-only card with no Other answer has nothing to show there (it drew a bare "+"). */}
685
967
  {(!readOnly || otherOn) && (
686
968
  <div className="flex items-center gap-2.5 rounded-[7px] px-[9px] py-[5px]">
687
969
  <span
688
970
  aria-hidden="true"
971
+ data-question-control=""
689
972
  className={cx(
690
973
  'annotation-exclude select-none grid h-[15px] w-[15px] shrink-0 place-items-center border-[1.5px] text-[11px] leading-none',
691
974
  question.kind === 'multi' ? 'rounded-[3px]' : 'rounded-full',
@@ -698,6 +981,13 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
698
981
  type="text"
699
982
  value={other}
700
983
  onChange={(e) => setOther(e.target.value)}
984
+ onFocus={() => {
985
+ // A stored answer that reads as the row stays the row
986
+ // while the reviewer types here (typing replaces it).
987
+ if (otherBy === null && suggestionPicked) setOtherBy('row');
988
+ setOtherFocused(true);
989
+ }}
990
+ onBlur={() => setOtherFocused(false)}
701
991
  disabled={readOnly}
702
992
  placeholder={readOnly ? '' : 'Other…'}
703
993
  aria-label={number > 0 ? `Other answer to question ${number}` : 'Other answer'}
@@ -709,6 +999,15 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
709
999
  </>
710
1000
  )}
711
1001
 
1002
+ {question.why && (
1003
+ <p className="m-0 mt-2 text-[12.5px] leading-[1.45] text-muted-foreground" data-question-part="why">
1004
+ <span className="font-semibold text-foreground/85">
1005
+ {recommendedLabels.length > 0 ? <>Why {recommendedLabels.map((label, i) => <React.Fragment key={label}>{i > 0 ? ', ' : ''}{inline(label)}</React.Fragment>)}:</> : 'Why:'}
1006
+ </span>{' '}
1007
+ {inline(question.why)}
1008
+ </p>
1009
+ )}
1010
+
712
1011
  {noteOpen && (!readOnly || !!answer?.note?.trim()) && (
713
1012
  <div className="mt-2">
714
1013
  <p id={noteLabelId} className="annotation-exclude select-none mb-1 ml-0.5 text-[10.5px] font-semibold uppercase tracking-[0.07em] text-muted-foreground">
@@ -728,7 +1027,7 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
728
1027
 
729
1028
  {(!readOnly || hasHostFooter) && (
730
1029
  <div
731
- className="annotation-exclude select-none mt-2 flex items-center gap-1 border-t border-border/55 pt-1.5"
1030
+ className="annotation-exclude select-none mt-2 flex flex-wrap items-center gap-1 pointer-coarse:gap-y-5 border-t border-border/55 pt-1.5"
732
1031
  data-pinpoint-ignore=""
733
1032
  >
734
1033
  {!readOnly && (
@@ -737,6 +1036,7 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
737
1036
  onClick={toggleNote}
738
1037
  aria-expanded={noteOpen}
739
1038
  className={cx(
1039
+ FOOT_BUTTON,
740
1040
  'rounded-[5px] px-[7px] py-[3px] text-xs hover:bg-muted hover:text-foreground',
741
1041
  noteOpen ? 'text-foreground' : 'text-muted-foreground',
742
1042
  )}
@@ -751,6 +1051,7 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
751
1051
  onClick={toggleSkip}
752
1052
  aria-pressed={skipped}
753
1053
  className={cx(
1054
+ FOOT_BUTTON,
754
1055
  'rounded-[5px] px-[7px] py-[3px] text-xs hover:bg-muted hover:text-foreground',
755
1056
  skipped ? 'text-foreground' : 'text-muted-foreground',
756
1057
  )}
@@ -765,18 +1066,18 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
765
1066
  <button
766
1067
  type="button"
767
1068
  onClick={acceptRecommended}
768
- className="ml-auto rounded-md border border-primary/45 bg-primary/10 px-2.5 py-[3px] text-[12.5px] font-medium text-primary hover:bg-primary/20 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
1069
+ className={cx(FOOT_BUTTON, FOOT_FIT, FOOT_OWN_ROW, 'ml-auto rtl:ml-0 rtl:mr-auto rounded-md border border-primary/45 bg-primary/10 px-2.5 py-[3px] text-[12.5px] font-medium text-primary hover:bg-primary/20 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary')}
769
1070
  >
770
1071
  Accept recommended
771
1072
  </button>
772
1073
  )
773
1074
  ) : (
774
- <div className="ml-auto flex items-center gap-1">
1075
+ <div className={cx(FOOT_OWN_ROW, 'ml-auto rtl:ml-0 rtl:mr-auto flex min-w-0 flex-wrap items-center justify-end gap-1')}>
775
1076
  {offerAccept && (
776
1077
  <button
777
1078
  type="button"
778
1079
  onClick={acceptRecommended}
779
- className="rounded-md border border-primary/45 bg-primary/10 px-2.5 py-[3px] text-[12.5px] font-medium text-primary hover:bg-primary/20 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
1080
+ className={cx(FOOT_BUTTON, FOOT_FIT, 'rounded-md border border-primary/45 bg-primary/10 px-2.5 py-[3px] text-[12.5px] font-medium text-primary hover:bg-primary/20 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary')}
780
1081
  >
781
1082
  Accept recommended
782
1083
  </button>
@@ -788,7 +1089,7 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
788
1089
  type="button"
789
1090
  onClick={cancel}
790
1091
  disabled={saving}
791
- className="rounded-[5px] px-[7px] py-[3px] text-xs text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-60"
1092
+ className={cx(FOOT_BUTTON, FOOT_FIT, 'rounded-[5px] px-[7px] py-[3px] text-xs text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-60')}
792
1093
  >
793
1094
  Cancel
794
1095
  </button>
@@ -797,7 +1098,7 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
797
1098
  onClick={save}
798
1099
  disabled={saving}
799
1100
  aria-busy={saving || undefined}
800
- className="rounded-md bg-primary px-2.5 py-[3px] text-[12.5px] font-medium text-primary-foreground hover:bg-primary/90 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:opacity-60"
1101
+ className={cx(FOOT_BUTTON, FOOT_FIT, 'rounded-md bg-primary px-2.5 py-[3px] text-[12.5px] font-medium text-primary-foreground hover:bg-primary/90 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:opacity-60')}
801
1102
  >
802
1103
  {(typeof saveLabel === 'function' ? saveLabel(question) : saveLabel) || 'Save answer'}
803
1104
  </button>
@@ -810,3 +1111,172 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
810
1111
  </fieldset>
811
1112
  );
812
1113
  };
1114
+
1115
+ /** One choice a picture tile draws (a parsed question's choice, or the Inbox wire's). */
1116
+ export interface QuestionPictureTileChoice {
1117
+ label: string;
1118
+ description?: string | null;
1119
+ recommended?: boolean;
1120
+ image?: { alt: string; src: string } | null;
1121
+ }
1122
+
1123
+ export interface QuestionPictureTilesProps {
1124
+ choices: readonly QuestionPictureTileChoice[];
1125
+ /** The URL each choice's picture loads from, in choice order; null (no picture shown) draws its alt text. */
1126
+ urls: readonly (string | null)[];
1127
+ selected: readonly string[];
1128
+ inputType: 'radio' | 'checkbox';
1129
+ /** The radio group's name. */
1130
+ name: string;
1131
+ readOnly?: boolean;
1132
+ /** The tile's radio or checkbox changed (the picture and the label pick through it). */
1133
+ onPick: (label: string, checked: boolean) => void;
1134
+ /** The separate zoom button (absent: no zoom button). Never picks. `index`: the choice's place in `choices`. */
1135
+ onZoom?: (url: string, alt: string, index: number) => void;
1136
+ onPictureError?: (url: string) => void;
1137
+ /** The host's reason a picture is held (its image resolver's `unavailable` title). */
1138
+ heldTitle?: (src: string) => string | null;
1139
+ /** How a label or description is drawn (the card's inline markdown). */
1140
+ renderText: (text: string) => React.ReactNode;
1141
+ /** A host's own props on each tile's label (the card's drag guard). */
1142
+ labelProps?: (label: string) => React.LabelHTMLAttributes<HTMLLabelElement>;
1143
+ /** A host's own props on each tile's input (decision mode answers on a click, or Enter). */
1144
+ inputProps?: (label: string) => React.InputHTMLAttributes<HTMLInputElement>;
1145
+ /** What sits under a choice's words (default: its Recommended tag). */
1146
+ renderBadges?: (choice: QuestionPictureTileChoice, index: number) => React.ReactNode;
1147
+ /** A small label over a tile's picture, top left (the card's `pictureBadge`); null or absent draws none. */
1148
+ pictureBadge?: (choice: { label: string; image: { alt: string; src: string } }) => string | null;
1149
+ }
1150
+
1151
+ /**
1152
+ * Picture choices as tiles (approval "inbox-decision-mode", agent contract
1153
+ * lines): each tile is a native `<label>` holding the picture and a real radio
1154
+ * or checkbox, so the picture, the label and the keyboard pick; a separate
1155
+ * zoom button, outside the label, opens the picture and never picks. The
1156
+ * question card draws these, and so does the Inbox's decision mode (its own
1157
+ * size through `[data-question-tile*]` hooks), so the two never drift.
1158
+ */
1159
+ export const QuestionPictureTiles: React.FC<QuestionPictureTilesProps> = ({
1160
+ choices,
1161
+ urls,
1162
+ selected,
1163
+ inputType,
1164
+ name,
1165
+ readOnly = false,
1166
+ onPick,
1167
+ onZoom,
1168
+ onPictureError,
1169
+ heldTitle,
1170
+ renderText,
1171
+ labelProps,
1172
+ inputProps,
1173
+ renderBadges,
1174
+ pictureBadge,
1175
+ }) => (
1176
+ <div
1177
+ className="mb-1 grid grid-cols-[repeat(auto-fill,minmax(150px,1fr))] gap-2"
1178
+ data-question-tiles=""
1179
+ >
1180
+ {choices.map((choice, index) => {
1181
+ const checked = selected.includes(choice.label);
1182
+ const shown = urls[index] ?? null;
1183
+ // The alt text names the picture; one that only repeats the label is decorative.
1184
+ const alt = choice.image ? (choice.image.alt || choice.label) : '';
1185
+ const decorative = !!choice.image && alt.trim() === choice.label.trim();
1186
+ const host = labelProps?.(choice.label);
1187
+ const input = inputProps?.(choice.label);
1188
+ return (
1189
+ <div
1190
+ key={choice.label}
1191
+ className={cx(
1192
+ 'relative flex min-w-0 flex-col overflow-hidden rounded-[9px] border transition-colors has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-primary',
1193
+ checked ? 'border-primary/70 bg-primary/10' : 'border-border/80 bg-background/40',
1194
+ !readOnly && !checked && 'hover:border-foreground/30',
1195
+ )}
1196
+ data-question-tile=""
1197
+ data-question-tile-checked={checked ? '' : undefined}
1198
+ data-question-tile-recommended={choice.recommended ? '' : undefined}
1199
+ >
1200
+ <label
1201
+ {...host}
1202
+ className={cx('flex min-w-0 flex-1 flex-col', readOnly ? 'cursor-default' : 'cursor-pointer')}
1203
+ data-question-option=""
1204
+ >
1205
+ {choice.image && (
1206
+ <span
1207
+ className="annotation-exclude select-none block aspect-[4/3] w-full overflow-hidden border-b border-border/60 bg-muted/35"
1208
+ // Inline, not a utility class: the badge's position needs it, and no new class reaches the guides.show viewer's CSS.
1209
+ style={{ position: 'relative' }}
1210
+ data-pinpoint-ignore=""
1211
+ data-question-tile-picture=""
1212
+ >
1213
+ {(() => {
1214
+ const badge = pictureBadge?.({ label: choice.label, image: choice.image });
1215
+ return badge ? (
1216
+ <span style={PICTURE_BADGE_STYLE} data-question-picture-badge="">
1217
+ {badge}
1218
+ </span>
1219
+ ) : null;
1220
+ })()}
1221
+ {shown ? (
1222
+ <img
1223
+ src={shown}
1224
+ alt={decorative ? '' : alt}
1225
+ draggable={false}
1226
+ loading="lazy"
1227
+ className="h-full w-full object-contain"
1228
+ data-question-picture=""
1229
+ data-zoomable=""
1230
+ data-image-src={choice.image.src}
1231
+ onError={() => onPictureError?.(shown)}
1232
+ />
1233
+ ) : (
1234
+ <span
1235
+ className="grid h-full place-items-center px-2 text-center text-xs text-muted-foreground"
1236
+ title={heldTitle?.(choice.image.src) ?? undefined}
1237
+ data-image-unavailable=""
1238
+ >
1239
+ {alt}
1240
+ </span>
1241
+ )}
1242
+ </span>
1243
+ )}
1244
+ <span className="flex w-full min-w-0 items-start gap-2 px-2.5 py-2" data-question-tile-caption="">
1245
+ <input
1246
+ {...input}
1247
+ type={inputType}
1248
+ name={name}
1249
+ checked={checked}
1250
+ onChange={(e) => onPick(choice.label, e.target.checked)}
1251
+ className="mt-[3px] h-[15px] w-[15px] shrink-0 cursor-pointer accent-primary disabled:cursor-default"
1252
+ data-question-control=""
1253
+ disabled={readOnly}
1254
+ />
1255
+ <span className="min-w-0 flex-1 select-text text-[13px] leading-[1.4] text-foreground" data-question-part="choice">
1256
+ <span className="font-[550]" data-question-tile-label="">{renderText(choice.label)}</span>
1257
+ {choice.description && (
1258
+ <span className="block text-muted-foreground" data-question-tile-description="">{renderText(choice.description)}</span>
1259
+ )}
1260
+ {renderBadges
1261
+ ? renderBadges(choice, index)
1262
+ : choice.recommended && <span className="mt-1 block"><Tag tone="rec">Recommended</Tag></span>}
1263
+ </span>
1264
+ </span>
1265
+ </label>
1266
+ {shown && onZoom && (
1267
+ <button
1268
+ type="button"
1269
+ onClick={() => onZoom(shown, alt, index)}
1270
+ aria-label={`Zoom: ${alt}`}
1271
+ className="annotation-exclude absolute right-1.5 top-1.5 grid h-7 w-7 place-items-center rounded-md bg-background/85 text-foreground shadow-sm ring-1 ring-border/70 before:absolute before:-inset-2 before:content-[''] hover:bg-background focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-primary"
1272
+ data-pinpoint-ignore=""
1273
+ data-question-zoom=""
1274
+ >
1275
+ <ZoomGlyph />
1276
+ </button>
1277
+ )}
1278
+ </div>
1279
+ );
1280
+ })}
1281
+ </div>
1282
+ );