@plannotator/ui 0.53.1 → 0.54.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 (135) hide show
  1. package/HANDOFF.md +217 -11
  2. package/README.md +65 -10
  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 +19 -5
  12. package/components/CodeFilePopout.tsx +139 -4
  13. package/components/CommentPopover.tsx +44 -8
  14. package/components/DecisionControl.tsx +31 -5
  15. package/components/DiagramBlock.tsx +17 -12
  16. package/components/EditorAnnotationCard.tsx +1 -1
  17. package/components/ExportModal.tsx +77 -7
  18. package/components/HostTitle.tsx +0 -4
  19. package/components/ImageAnnotator/Toolbar.tsx +25 -1
  20. package/components/ImageAnnotator/index.tsx +24 -4
  21. package/components/ImageLightbox.tsx +658 -120
  22. package/components/ImagePeek.tsx +38 -4
  23. package/components/ImageRegions.tsx +369 -0
  24. package/components/ImageThumbnail.tsx +1 -1
  25. package/components/ImageZoomStage.tsx +796 -0
  26. package/components/InboxAnnouncementDialog.tsx +202 -0
  27. package/components/InlineMarkdown.tsx +152 -24
  28. package/components/KeyboardShortcuts.tsx +23 -0
  29. package/components/LazyBlockBoundary.tsx +100 -0
  30. package/components/ListMarker.tsx +3 -3
  31. package/components/LookAndFeelAnnouncementDialog.tsx +12 -2
  32. package/components/MenuVersionSection.tsx +31 -9
  33. package/components/PlanDecisionMenuExtras.tsx +129 -0
  34. package/components/PlanHeaderMenu.tsx +15 -1
  35. package/components/ProviderIcons.tsx +8 -0
  36. package/components/RefHoverCard.tsx +913 -0
  37. package/components/SendFailureToast.tsx +126 -0
  38. package/components/Settings.tsx +617 -126
  39. package/components/TerminalToolsAnnouncementDialog.tsx +1 -1
  40. package/components/ThemeProvider.tsx +14 -1
  41. package/components/ThemeTab.tsx +16 -24
  42. package/components/Tooltip.tsx +49 -18
  43. package/components/Viewer.tsx +280 -41
  44. package/components/ai/DocumentAIChatPanel.tsx +29 -3
  45. package/components/ai/NewChatButton.tsx +74 -0
  46. package/components/blocks/CodeBlock.tsx +1 -1
  47. package/components/blocks/HtmlBlock.tsx +7 -1
  48. package/components/blocks/QuestionBlock.tsx +522 -62
  49. package/components/diagram/DiagramComposer.tsx +5 -1
  50. package/components/diagram/DiagramPending.tsx +12 -2
  51. package/components/diagram/DiagramViewer.tsx +19 -3
  52. package/components/html-embed/HtmlEmbedBlock.tsx +374 -0
  53. package/components/html-embed/SvgFenceBlock.tsx +46 -0
  54. package/components/html-embed/index.ts +3 -0
  55. package/components/html-embed/languages.ts +15 -0
  56. package/components/html-embed/loadGate.ts +107 -0
  57. package/components/html-viewer/HtmlViewer.tsx +47 -6
  58. package/components/html-viewer/bridge-script.asset.js +173 -1
  59. package/components/html-viewer/bridge-script.ts +173 -1
  60. package/components/html-viewer/useHtmlAnnotation.ts +125 -0
  61. package/components/imageZoom.ts +265 -0
  62. package/components/plan-diff/PlanCleanDiffView.tsx +1 -1
  63. package/components/plan-diff/PlanDiffViewer.tsx +13 -2
  64. package/components/plan-diff/PlanRawDiffView.tsx +1 -1
  65. package/components/sidebar/FileBrowser.tsx +16 -2
  66. package/components/sidebar/SidebarTabs.tsx +3 -0
  67. package/config/index.ts +3 -0
  68. package/config/reviewView.ts +41 -1
  69. package/config/settings.ts +155 -4
  70. package/configure.ts +28 -0
  71. package/hooks/pfm/useCodeFilePopout.ts +7 -1
  72. package/hooks/useAIChat.ts +72 -25
  73. package/hooks/useAnnotationDraft.ts +71 -6
  74. package/hooks/useAnnotationHighlighter.ts +57 -3
  75. package/hooks/useCodeAnnotationDraft.ts +113 -17
  76. package/hooks/useCookieHostSetting.ts +96 -0
  77. package/hooks/useDocumentFont.ts +17 -0
  78. package/hooks/useDocumentTextSize.ts +20 -0
  79. package/hooks/useFileBrowser.ts +10 -1
  80. package/hooks/useLinkedDoc.ts +226 -13
  81. package/hooks/usePackageChangesSetting.ts +90 -0
  82. package/hooks/usePlanLookDialog.ts +67 -0
  83. package/hooks/useSharing.ts +11 -7
  84. package/hooks/useThemePaletteHalves.ts +37 -0
  85. package/hooks/useUpdateCheck.ts +22 -4
  86. package/hooks/useUpdateToast.ts +1 -1
  87. package/package.json +23 -22
  88. package/shortcuts/code-review/annotationToolbar.shortcuts.ts +1 -0
  89. package/shortcuts/code-review/explorer.shortcuts.ts +82 -0
  90. package/shortcuts/core.ts +63 -4
  91. package/shortcuts/index.ts +1 -0
  92. package/shortcuts/plan-review/annotationPanel.shortcuts.ts +1 -1
  93. package/shortcuts/plan-review/commentPopover.shortcuts.ts +1 -0
  94. package/shortcuts/runtime.ts +4 -3
  95. package/styles.css +1 -1
  96. package/theme.css +179 -1
  97. package/themes/plannotator-graphite.css +66 -0
  98. package/types.ts +26 -9
  99. package/utils/agentSwitch.ts +41 -20
  100. package/utils/agentToolSetting.ts +1 -1
  101. package/utils/aiChatFormat.ts +23 -7
  102. package/utils/annotationOwnsHighlight.ts +4 -2
  103. package/utils/asciidocParser.ts +570 -0
  104. package/utils/autoUpdateNotice.ts +24 -0
  105. package/utils/belowTitleSlot.ts +174 -0
  106. package/utils/codeLineScroll.ts +100 -0
  107. package/utils/commentSubmitKey.ts +48 -0
  108. package/utils/cssFontFamily.ts +65 -0
  109. package/utils/decisionSpec.ts +190 -2
  110. package/utils/defaultNotesApp.ts +1 -1
  111. package/utils/diffFonts.ts +4 -1
  112. package/utils/documentFont.ts +68 -0
  113. package/utils/documentTextSize.ts +54 -0
  114. package/utils/fontDetect.ts +63 -0
  115. package/utils/inboxAnnouncement.ts +78 -0
  116. package/utils/lookAndFeelAnnouncement.ts +82 -5
  117. package/utils/markdownExtensions.ts +2 -2
  118. package/utils/mermaid.ts +20 -3
  119. package/utils/mermaidTheme.ts +3 -51
  120. package/utils/notion.ts +58 -0
  121. package/utils/parser.ts +28 -1
  122. package/utils/platform.ts +46 -0
  123. package/utils/refCard.ts +82 -0
  124. package/utils/richClipboard.ts +380 -0
  125. package/utils/richClipboardLists.ts +57 -0
  126. package/utils/richCopyPolicy.ts +22 -0
  127. package/utils/scrollAnchor.ts +82 -0
  128. package/utils/sectionRefs.ts +123 -0
  129. package/utils/selectionBoundary.ts +40 -0
  130. package/utils/sendFailure.ts +85 -0
  131. package/utils/sharing.ts +5 -2
  132. package/utils/themeRegistry.ts +10 -0
  133. package/utils/themeTokens.ts +74 -0
  134. package/utils/updateNotice.ts +30 -8
  135. 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,33 @@ 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;
200
274
  }
201
275
 
276
+ /** The picture tile's label (`pictureBadge`): a small dark chip over the picture's top-left corner. */
277
+ const PICTURE_BADGE_STYLE: React.CSSProperties = {
278
+ position: 'absolute',
279
+ left: 6,
280
+ top: 6,
281
+ zIndex: 1,
282
+ pointerEvents: 'none',
283
+ borderRadius: 4,
284
+ background: 'rgb(0 0 0 / 0.6)',
285
+ color: 'white',
286
+ padding: '0 6px',
287
+ fontSize: 10.5,
288
+ fontWeight: 600,
289
+ lineHeight: '18px',
290
+ letterSpacing: '0.02em',
291
+ };
292
+
202
293
  /** A host's status tag (`statusLabel`): its words, and the tone of one of the card's own tags (default `open`). */
203
294
  export interface QuestionStatusLabel {
204
295
  label: string;
@@ -228,8 +319,38 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
228
319
  githubRepo,
229
320
  repoHost,
230
321
  onNavigateAnchor,
322
+ pictureChoices = true,
323
+ pictureBadge,
231
324
  }) => {
232
325
  const { question, number } = indexed;
326
+ const resolveImage = useImageResolver();
327
+ // The URL a picture loads from, exactly as an inline markdown image of the
328
+ // same src would (the host's resolver first), or null when the host says
329
+ // it cannot be shown.
330
+ const pictureSrc = (src: string): string | null => {
331
+ const resolved = resolveImage ? resolveImage(src) : null;
332
+ if (resolved?.kind === 'unavailable') return null;
333
+ if (resolved?.kind === 'src') return resolved.src;
334
+ return /^(https?:\/\/|data:|blob:)/i.test(src) ? src : getImageSrc(src, imageBaseDir);
335
+ };
336
+ // The host's reason a picture is not shown (its resolver's `unavailable`
337
+ // title), drawn on the placeholder exactly as an inline image's, so a host
338
+ // that holds remote images counts and loads these too (code review's PR
339
+ // Markdown preview: "N remote images held · Load remote images").
340
+ const pictureHeldTitle = (src: string): string | null => {
341
+ const resolved = resolveImage ? resolveImage(src) : null;
342
+ return resolved?.kind === 'unavailable' ? resolved.title : null;
343
+ };
344
+ const heldPlaceholder = (src: string, alt: string) => {
345
+ const title = pictureHeldTitle(src);
346
+ return title === null ? null : (
347
+ <span className={UNAVAILABLE_IMAGE_CLASS} title={title} data-image-unavailable="">{alt || 'Image'}</span>
348
+ );
349
+ };
350
+ // Pictures that failed to load (by the URL tried): a tile shows its alt
351
+ // text instead, and with none left the choices are text rows.
352
+ const [failedPictures, setFailedPictures] = useState<ReadonlySet<string>>(() => new Set());
353
+ const markPictureFailed = (url: string) => setFailedPictures((prev) => (prev.has(url) ? prev : new Set([...prev, url])));
233
354
  // An answer saved under an older label of a choice (0.28.1 read a wrapped
234
355
  // plain bullet's first line as its label) shows that choice picked; the
235
356
  // next edit stores the current label.
@@ -275,6 +396,7 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
275
396
  }
276
397
  };
277
398
  const cancel = () => {
399
+ replacedOtherRef.current = null;
278
400
  setDraft(null);
279
401
  setNotePinned(null);
280
402
  };
@@ -283,6 +405,7 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
283
405
  const uid = useId().replace(/:/g, '');
284
406
  const promptId = `q-prompt-${uid}`;
285
407
  const contextId = `q-context-${uid}`;
408
+ const stakesId = `q-stakes-${uid}`;
286
409
  const suggestId = `q-suggest-${uid}`;
287
410
  const noteLabelId = `q-note-${uid}`;
288
411
  // null = follow the answer (open while a note exists); a click pins it.
@@ -291,10 +414,21 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
291
414
  const pressRef = useRef<{ x: number; y: number; touch: boolean } | null>(null);
292
415
  const noteRef = useRef<HTMLTextAreaElement>(null);
293
416
  const cardRef = useRef<HTMLFieldSetElement>(null);
294
- // Accept recommended (and Use) go away once clicked: focus moves to what
417
+ // Accept recommended goes away once clicked: focus moves to what
295
418
  // the click filled in, never to the page.
296
419
  const focusAfterAcceptRef = useRef(false);
297
420
  const lastPickRef = useRef<{ label: string; before: QuestionAnswer | undefined; at: number } | null>(null);
421
+ // Who set the "Other…" answer while this card was open: the recommended
422
+ // row (its pick, or Accept recommended) or the Other field. null: neither
423
+ // (an answer the host stored): an Other answer that is exactly the agent's
424
+ // text then reads as the row, unless the Other field has focus. Typing
425
+ // never turns into the row, so "Yes, but slower" stays in the field when
426
+ // the recommendation is "Yes".
427
+ const [otherBy, setOtherBy] = useState<'row' | 'field' | null>(null);
428
+ const [otherFocused, setOtherFocused] = useState(false);
429
+ // Pick any: the reviewer's own Other text the recommended row replaced when
430
+ // ticked, put back when it is unticked (the answer has one Other field).
431
+ const replacedOtherRef = useRef<string | null>(null);
298
432
 
299
433
  const inline = (text: string) => (
300
434
  <InlineMarkdown
@@ -322,9 +456,9 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
322
456
  // recommendation names choices until the answer is exactly those choices:
323
457
  // ticking one box must not take away the one click that ticks the rest. A
324
458
  // 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.
459
+ // choice, and a pick-any question recommending only its own answer (the
460
+ // recommended row after the choices: Accept would clear the ticks for it,
461
+ // and the row is one click anyway) offer it no more.
328
462
  const offerAccept = !readOnly && hasRecommendation && !settled && (
329
463
  !answered
330
464
  || (question.kind === 'multi'
@@ -361,7 +495,37 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
361
495
  }
362
496
  };
363
497
 
498
+ // The agent's recommended answer that is none of its choices
499
+ // (`suggestedText` on a pick question): one more answer row, after the
500
+ // choices and before "Other…". Picking it stores exactly what Accept
501
+ // recommended stores, the text as the "Other…" answer, so no label,
502
+ // index, key or host check of picked labels changes.
503
+ const pickSuggestion = (checked: boolean) => {
504
+ const text = question.suggestedText;
505
+ if (!text) return;
506
+ lastPickRef.current = { label: SUGGESTION_ROW, before: answer, at: now() };
507
+ const { skipped: _s, other: _o, ...rest } = base;
508
+ if (question.kind === 'multi') {
509
+ if (checked) {
510
+ if (!suggestionPicked) replacedOtherRef.current = storedOther.trim() ? storedOther : null;
511
+ setOtherBy('row');
512
+ commit({ ...rest, selected, other: text });
513
+ } else {
514
+ const back = replacedOtherRef.current;
515
+ replacedOtherRef.current = null;
516
+ setOtherBy(back ? 'field' : null);
517
+ commit({ ...rest, selected, ...(back ? { other: back } : {}) });
518
+ }
519
+ } else if (checked) {
520
+ replacedOtherRef.current = null;
521
+ setOtherBy('row');
522
+ commit({ ...rest, selected: [], other: text });
523
+ }
524
+ };
525
+
364
526
  const setOther = (value: string) => {
527
+ setOtherBy('field');
528
+ replacedOtherRef.current = null;
365
529
  const { skipped: _s, other: _o, ...rest } = base;
366
530
  const keepSelected = question.kind === 'multi' ? selected : value.trim() ? [] : rest.selected;
367
531
  commit({ ...rest, selected: keepSelected, ...(value ? { other: value } : {}) });
@@ -378,6 +542,7 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
378
542
  };
379
543
 
380
544
  const toggleSkip = () => {
545
+ replacedOtherRef.current = null;
381
546
  if (base.skipped) {
382
547
  const { skipped: _s, ...rest } = base;
383
548
  commit(rest);
@@ -390,6 +555,8 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
390
555
  const acceptRecommended = () => {
391
556
  const next = recommendedQuestionAnswer(indexed, base);
392
557
  if (!next) return;
558
+ replacedOtherRef.current = null;
559
+ if (next.other) setOtherBy('row');
393
560
  focusAfterAcceptRef.current = true;
394
561
  commit(next);
395
562
  };
@@ -450,8 +617,25 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
450
617
  : `Question${question.kind === 'multi' ? ' · pick any' : ''}`;
451
618
  const groupName = `question-${uid}`;
452
619
  const inputType = question.kind === 'multi' ? 'checkbox' : 'radio';
453
- const other = answer?.other ?? '';
620
+ const storedOther = answer?.other ?? '';
621
+ // The recommended row holds the "Other…" answer when it is the agent's
622
+ // text; the Other field is then empty (typing there replaces it).
623
+ const suggestionRow = question.kind !== 'text' && !!question.suggestedText;
624
+ const suggestionPicked = suggestionRow
625
+ && storedOther.trim() !== ''
626
+ && storedOther.trim() === question.suggestedText!.trim()
627
+ && (otherBy === 'row' || (otherBy === null && !otherFocused));
628
+ const other = suggestionPicked ? '' : storedOther;
454
629
  const otherOn = other.trim() !== '';
630
+ // Picture choices: each choice's picture URL (null: none, or not shown).
631
+ const pictures = question.choices.map((c) => (c.image ? pictureSrc(c.image.src) : null));
632
+ const showTiles = pictureChoices
633
+ && question.kind !== 'text'
634
+ && pictures.some((url) => url !== null && !failedPictures.has(url));
635
+ const recommendedLabels = question.choices.filter((c) => c.recommended).map((c) => c.label);
636
+ const showsUrl = question.shows?.kind === 'image' ? pictureSrc(question.shows.src) : null;
637
+ // A `Shows:` picture that failed to load is its label, as a picture tile's is.
638
+ const showsSrc = showsUrl !== null && !failedPictures.has(showsUrl) ? showsUrl : null;
455
639
 
456
640
  // A `Decision: when answered` question the host may switch off, or (with
457
641
  // `decisionScope: 'any'`) any other question once the host supplies a
@@ -468,27 +652,16 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
468
652
  ? (recordingOn ? 'on' : 'off')
469
653
  : undefined;
470
654
 
471
- const describedBy = [question.context ? contextId : null, question.suggestedText && !answered ? suggestId : null]
655
+ const describedBy = [question.stakes ? stakesId : null, question.context ? contextId : null, question.kind === 'text' && question.suggestedText && !answered ? suggestId : null]
472
656
  .filter(Boolean)
473
657
  .join(' ') || undefined;
474
658
 
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
- )}
659
+ // A text question's recommended answer: shown above the field with the
660
+ // same Recommended tag a choice carries; the footer's Accept recommended
661
+ // fills it in. (A pick question's is a row of its own, below.)
662
+ const suggestedLine = question.kind === 'text' && question.suggestedText && !answered && (
663
+ <div id={suggestId} className="mt-2 text-[12.5px] text-muted-foreground" data-question-suggested="">
664
+ <Tag tone="rec">Recommended</Tag> {inline(question.suggestedText)}
492
665
  </div>
493
666
  );
494
667
 
@@ -497,6 +670,8 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
497
670
  ref={cardRef}
498
671
  className={cx(
499
672
  'question-block my-5 min-w-0 rounded-[10px] border px-3.5 pb-2.5 pt-3',
673
+ // A size container: the card's own width lays out its footer (FOOT_BUTTON).
674
+ '@container/qcard',
500
675
  // Native radios/checkboxes follow the theme's mode. ThemeProvider marks
501
676
  // light mode with `.light` on the root; a host without theme classes
502
677
  // keeps the page's own color-scheme.
@@ -569,6 +744,14 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
569
744
  ) : (
570
745
  <><Tag tone="rec" style={DECISION_OFF_STYLE}><DiamondGlyph />Records a decision</Tag>{' '}</>
571
746
  ))}
747
+ {question.reversible && (
748
+ <><HostTitle title={question.reversible.why ? `Can't be undone: ${question.reversible.why}` : "Can't be undone"}>
749
+ <span className="inline-flex" data-question-irreversible="">
750
+ <Tag tone="warn">Can&rsquo;t be undone</Tag>
751
+ {question.reversible.why && <span className="sr-only">: {question.reversible.why}</span>}
752
+ </span>
753
+ </HostTitle>{' '}</>
754
+ )}
572
755
  {statusTag !== 'none' && hostStatus && <Tag tone={hostStatus.tone ?? 'open'}>{hostStatus.label}</Tag>}
573
756
  {statusTag !== 'none' && !hostStatus && (
574
757
  <>
@@ -584,6 +767,43 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
584
767
  <p id={promptId} className="m-0 text-[15px] font-semibold leading-[1.45] text-foreground" data-question-part="prompt">
585
768
  {inline(question.prompt)}
586
769
  </p>
770
+ {question.stakes && (
771
+ <p id={stakesId} className="m-0 mt-1 text-[13px] font-medium leading-[1.45] text-foreground/90" data-question-part="stakes">
772
+ {inline(question.stakes)}
773
+ </p>
774
+ )}
775
+ {question.shows && (
776
+ <div className="mt-1.5 flex flex-wrap items-center gap-1.5 text-[12.5px]" data-question-shows="">
777
+ <span className="annotation-exclude select-none text-muted-foreground" data-pinpoint-ignore="">Shows</span>
778
+ {question.shows.kind === 'image' ? (
779
+ showsSrc && onImageClick ? (
780
+ <button
781
+ type="button"
782
+ onClick={() => onImageClick(showsSrc, question.shows!.label || 'Picture')}
783
+ 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"
784
+ data-question-shows-open=""
785
+ >
786
+ <img
787
+ src={showsSrc}
788
+ alt=""
789
+ draggable={false}
790
+ className="h-6 w-8 shrink-0 rounded-[4px] object-cover"
791
+ data-question-picture=""
792
+ data-zoomable=""
793
+ data-image-src={question.shows.src}
794
+ onError={() => markPictureFailed(showsSrc)}
795
+ />
796
+ <span className="truncate">{question.shows.label || 'Picture'}</span>
797
+ </button>
798
+ ) : (
799
+ heldPlaceholder(question.shows.src, question.shows.label)
800
+ ?? <span className="inline-flex items-center gap-1 text-foreground"><PictureGlyph />{question.shows.label || question.shows.src}</span>
801
+ )
802
+ ) : (
803
+ <span className="min-w-0 text-foreground">{inline(`[${question.shows.label}](${question.shows.src})`)}</span>
804
+ )}
805
+ </div>
806
+ )}
587
807
  {renderBelowPrompt?.(indexed)}
588
808
  {(question.decision || (decisionCapable && recordingOn)) && (
589
809
  <div
@@ -643,49 +863,102 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
643
863
  </>
644
864
  ) : (
645
865
  <>
646
- {suggestedLine}
647
866
  <div
648
867
  className={cx('mt-2.5 grid gap-0.5', skipped && 'opacity-55')}
649
868
  role={inputType === 'radio' ? 'radiogroup' : 'group'}
650
869
  aria-labelledby={promptId}
651
870
  >
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>
871
+ {showTiles ? (
872
+ <QuestionPictureTiles
873
+ choices={question.choices}
874
+ urls={question.choices.map((_c, index) => {
875
+ const url = pictures[index];
876
+ return url !== null && url !== undefined && !failedPictures.has(url) ? url : null;
877
+ })}
878
+ selected={selected}
879
+ inputType={inputType}
880
+ name={groupName}
881
+ readOnly={readOnly}
882
+ onPick={pick}
883
+ onZoom={onImageClick}
884
+ onPictureError={markPictureFailed}
885
+ heldTitle={pictureHeldTitle}
886
+ renderText={inline}
887
+ labelProps={(label) => ({ onPointerDown: onRowPointerDown, onClickCapture: (event) => onRowClick(event, label) })}
888
+ pictureBadge={pictureBadge}
889
+ />
890
+ ) : (
891
+ question.choices.map((choice) => {
892
+ const checked = selected.includes(choice.label);
893
+ return (
894
+ <label
895
+ key={choice.label}
896
+ className={cx(
897
+ 'flex items-start gap-2.5 rounded-[7px] px-[9px] py-1.5 transition-colors',
898
+ readOnly ? 'cursor-default' : 'cursor-pointer hover:bg-muted/45',
899
+ checked && 'bg-primary/12 hover:bg-primary/12',
678
900
  )}
679
- {choice.recommended && <> <Tag tone="rec">Recommended</Tag></>}
680
- </span>
681
- </label>
682
- );
683
- })}
901
+ data-question-option=""
902
+ onPointerDown={onRowPointerDown}
903
+ onClickCapture={(event) => onRowClick(event, choice.label)}
904
+ >
905
+ <input
906
+ type={inputType}
907
+ name={groupName}
908
+ checked={checked}
909
+ onChange={(e) => pick(choice.label, e.target.checked)}
910
+ className="mt-[3px] h-[15px] w-[15px] shrink-0 cursor-pointer accent-primary disabled:cursor-default"
911
+ data-question-control=""
912
+ disabled={readOnly}
913
+ />
914
+ <span className="min-w-0 flex-1 select-text text-[13.5px] leading-[1.45] text-foreground" data-question-part="choice">
915
+ <span className="font-[550]">{inline(choice.label)}</span>
916
+ {choice.image && pictureChoices && pictureHeldTitle(choice.image.src) !== null && <> {heldPlaceholder(choice.image.src, choice.image.alt || choice.label)}</>}
917
+ {choice.description && (
918
+ <span className="text-muted-foreground"> — {inline(choice.description)}</span>
919
+ )}
920
+ {choice.recommended && <> <Tag tone="rec">Recommended</Tag></>}
921
+ </span>
922
+ </label>
923
+ );
924
+ })
925
+ )}
926
+ {suggestionRow && (
927
+ // The agent's recommended answer that is none of its choices:
928
+ // drawn exactly as a choice (the same control, row and tag), so
929
+ // the card reads as one list of answers.
930
+ <label
931
+ className={cx(
932
+ 'flex items-start gap-2.5 rounded-[7px] px-[9px] py-1.5 transition-colors',
933
+ readOnly ? 'cursor-default' : 'cursor-pointer hover:bg-muted/45',
934
+ suggestionPicked && 'bg-primary/12 hover:bg-primary/12',
935
+ )}
936
+ data-question-option=""
937
+ data-question-suggested=""
938
+ onPointerDown={onRowPointerDown}
939
+ onClickCapture={(event) => onRowClick(event, SUGGESTION_ROW)}
940
+ >
941
+ <input
942
+ type={inputType}
943
+ name={groupName}
944
+ checked={suggestionPicked}
945
+ onChange={(e) => pickSuggestion(e.target.checked)}
946
+ className="mt-[3px] h-[15px] w-[15px] shrink-0 cursor-pointer accent-primary disabled:cursor-default"
947
+ data-question-control=""
948
+ disabled={readOnly}
949
+ />
950
+ <span className="min-w-0 flex-1 select-text text-[13.5px] leading-[1.45] text-foreground" data-question-part="choice">
951
+ <span className="font-[550]">{inline(question.suggestedText!)}</span>
952
+ {' '}<Tag tone="rec">Recommended</Tag>
953
+ </span>
954
+ </label>
955
+ )}
684
956
  {/* A read-only card with no Other answer has nothing to show there (it drew a bare "+"). */}
685
957
  {(!readOnly || otherOn) && (
686
958
  <div className="flex items-center gap-2.5 rounded-[7px] px-[9px] py-[5px]">
687
959
  <span
688
960
  aria-hidden="true"
961
+ data-question-control=""
689
962
  className={cx(
690
963
  'annotation-exclude select-none grid h-[15px] w-[15px] shrink-0 place-items-center border-[1.5px] text-[11px] leading-none',
691
964
  question.kind === 'multi' ? 'rounded-[3px]' : 'rounded-full',
@@ -698,6 +971,13 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
698
971
  type="text"
699
972
  value={other}
700
973
  onChange={(e) => setOther(e.target.value)}
974
+ onFocus={() => {
975
+ // A stored answer that reads as the row stays the row
976
+ // while the reviewer types here (typing replaces it).
977
+ if (otherBy === null && suggestionPicked) setOtherBy('row');
978
+ setOtherFocused(true);
979
+ }}
980
+ onBlur={() => setOtherFocused(false)}
701
981
  disabled={readOnly}
702
982
  placeholder={readOnly ? '' : 'Other…'}
703
983
  aria-label={number > 0 ? `Other answer to question ${number}` : 'Other answer'}
@@ -709,6 +989,15 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
709
989
  </>
710
990
  )}
711
991
 
992
+ {question.why && (
993
+ <p className="m-0 mt-2 text-[12.5px] leading-[1.45] text-muted-foreground" data-question-part="why">
994
+ <span className="font-semibold text-foreground/85">
995
+ {recommendedLabels.length > 0 ? <>Why {recommendedLabels.map((label, i) => <React.Fragment key={label}>{i > 0 ? ', ' : ''}{inline(label)}</React.Fragment>)}:</> : 'Why:'}
996
+ </span>{' '}
997
+ {inline(question.why)}
998
+ </p>
999
+ )}
1000
+
712
1001
  {noteOpen && (!readOnly || !!answer?.note?.trim()) && (
713
1002
  <div className="mt-2">
714
1003
  <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 +1017,7 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
728
1017
 
729
1018
  {(!readOnly || hasHostFooter) && (
730
1019
  <div
731
- className="annotation-exclude select-none mt-2 flex items-center gap-1 border-t border-border/55 pt-1.5"
1020
+ 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
1021
  data-pinpoint-ignore=""
733
1022
  >
734
1023
  {!readOnly && (
@@ -737,6 +1026,7 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
737
1026
  onClick={toggleNote}
738
1027
  aria-expanded={noteOpen}
739
1028
  className={cx(
1029
+ FOOT_BUTTON,
740
1030
  'rounded-[5px] px-[7px] py-[3px] text-xs hover:bg-muted hover:text-foreground',
741
1031
  noteOpen ? 'text-foreground' : 'text-muted-foreground',
742
1032
  )}
@@ -751,6 +1041,7 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
751
1041
  onClick={toggleSkip}
752
1042
  aria-pressed={skipped}
753
1043
  className={cx(
1044
+ FOOT_BUTTON,
754
1045
  'rounded-[5px] px-[7px] py-[3px] text-xs hover:bg-muted hover:text-foreground',
755
1046
  skipped ? 'text-foreground' : 'text-muted-foreground',
756
1047
  )}
@@ -765,18 +1056,18 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
765
1056
  <button
766
1057
  type="button"
767
1058
  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"
1059
+ 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
1060
  >
770
1061
  Accept recommended
771
1062
  </button>
772
1063
  )
773
1064
  ) : (
774
- <div className="ml-auto flex items-center gap-1">
1065
+ <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
1066
  {offerAccept && (
776
1067
  <button
777
1068
  type="button"
778
1069
  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"
1070
+ 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
1071
  >
781
1072
  Accept recommended
782
1073
  </button>
@@ -788,7 +1079,7 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
788
1079
  type="button"
789
1080
  onClick={cancel}
790
1081
  disabled={saving}
791
- className="rounded-[5px] px-[7px] py-[3px] text-xs text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-60"
1082
+ 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
1083
  >
793
1084
  Cancel
794
1085
  </button>
@@ -797,7 +1088,7 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
797
1088
  onClick={save}
798
1089
  disabled={saving}
799
1090
  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"
1091
+ 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
1092
  >
802
1093
  {(typeof saveLabel === 'function' ? saveLabel(question) : saveLabel) || 'Save answer'}
803
1094
  </button>
@@ -810,3 +1101,172 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
810
1101
  </fieldset>
811
1102
  );
812
1103
  };
1104
+
1105
+ /** One choice a picture tile draws (a parsed question's choice, or the Inbox wire's). */
1106
+ export interface QuestionPictureTileChoice {
1107
+ label: string;
1108
+ description?: string | null;
1109
+ recommended?: boolean;
1110
+ image?: { alt: string; src: string } | null;
1111
+ }
1112
+
1113
+ export interface QuestionPictureTilesProps {
1114
+ choices: readonly QuestionPictureTileChoice[];
1115
+ /** The URL each choice's picture loads from, in choice order; null (no picture shown) draws its alt text. */
1116
+ urls: readonly (string | null)[];
1117
+ selected: readonly string[];
1118
+ inputType: 'radio' | 'checkbox';
1119
+ /** The radio group's name. */
1120
+ name: string;
1121
+ readOnly?: boolean;
1122
+ /** The tile's radio or checkbox changed (the picture and the label pick through it). */
1123
+ onPick: (label: string, checked: boolean) => void;
1124
+ /** The separate zoom button (absent: no zoom button). Never picks. */
1125
+ onZoom?: (url: string, alt: string) => void;
1126
+ onPictureError?: (url: string) => void;
1127
+ /** The host's reason a picture is held (its image resolver's `unavailable` title). */
1128
+ heldTitle?: (src: string) => string | null;
1129
+ /** How a label or description is drawn (the card's inline markdown). */
1130
+ renderText: (text: string) => React.ReactNode;
1131
+ /** A host's own props on each tile's label (the card's drag guard). */
1132
+ labelProps?: (label: string) => React.LabelHTMLAttributes<HTMLLabelElement>;
1133
+ /** A host's own props on each tile's input (decision mode answers on a click, or Enter). */
1134
+ inputProps?: (label: string) => React.InputHTMLAttributes<HTMLInputElement>;
1135
+ /** What sits under a choice's words (default: its Recommended tag). */
1136
+ renderBadges?: (choice: QuestionPictureTileChoice, index: number) => React.ReactNode;
1137
+ /** A small label over a tile's picture, top left (the card's `pictureBadge`); null or absent draws none. */
1138
+ pictureBadge?: (choice: { label: string; image: { alt: string; src: string } }) => string | null;
1139
+ }
1140
+
1141
+ /**
1142
+ * Picture choices as tiles (approval "inbox-decision-mode", agent contract
1143
+ * lines): each tile is a native `<label>` holding the picture and a real radio
1144
+ * or checkbox, so the picture, the label and the keyboard pick; a separate
1145
+ * zoom button, outside the label, opens the picture and never picks. The
1146
+ * question card draws these, and so does the Inbox's decision mode (its own
1147
+ * size through `[data-question-tile*]` hooks), so the two never drift.
1148
+ */
1149
+ export const QuestionPictureTiles: React.FC<QuestionPictureTilesProps> = ({
1150
+ choices,
1151
+ urls,
1152
+ selected,
1153
+ inputType,
1154
+ name,
1155
+ readOnly = false,
1156
+ onPick,
1157
+ onZoom,
1158
+ onPictureError,
1159
+ heldTitle,
1160
+ renderText,
1161
+ labelProps,
1162
+ inputProps,
1163
+ renderBadges,
1164
+ pictureBadge,
1165
+ }) => (
1166
+ <div
1167
+ className="mb-1 grid grid-cols-[repeat(auto-fill,minmax(150px,1fr))] gap-2"
1168
+ data-question-tiles=""
1169
+ >
1170
+ {choices.map((choice, index) => {
1171
+ const checked = selected.includes(choice.label);
1172
+ const shown = urls[index] ?? null;
1173
+ // The alt text names the picture; one that only repeats the label is decorative.
1174
+ const alt = choice.image ? (choice.image.alt || choice.label) : '';
1175
+ const decorative = !!choice.image && alt.trim() === choice.label.trim();
1176
+ const host = labelProps?.(choice.label);
1177
+ const input = inputProps?.(choice.label);
1178
+ return (
1179
+ <div
1180
+ key={choice.label}
1181
+ className={cx(
1182
+ 'relative flex min-w-0 flex-col overflow-hidden rounded-[9px] border transition-colors has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-primary',
1183
+ checked ? 'border-primary/70 bg-primary/10' : 'border-border/80 bg-background/40',
1184
+ !readOnly && !checked && 'hover:border-foreground/30',
1185
+ )}
1186
+ data-question-tile=""
1187
+ data-question-tile-checked={checked ? '' : undefined}
1188
+ data-question-tile-recommended={choice.recommended ? '' : undefined}
1189
+ >
1190
+ <label
1191
+ {...host}
1192
+ className={cx('flex min-w-0 flex-1 flex-col', readOnly ? 'cursor-default' : 'cursor-pointer')}
1193
+ data-question-option=""
1194
+ >
1195
+ {choice.image && (
1196
+ <span
1197
+ className="annotation-exclude select-none block aspect-[4/3] w-full overflow-hidden border-b border-border/60 bg-muted/35"
1198
+ // Inline, not a utility class: the badge's position needs it, and no new class reaches the guides.show viewer's CSS.
1199
+ style={{ position: 'relative' }}
1200
+ data-pinpoint-ignore=""
1201
+ data-question-tile-picture=""
1202
+ >
1203
+ {(() => {
1204
+ const badge = pictureBadge?.({ label: choice.label, image: choice.image });
1205
+ return badge ? (
1206
+ <span style={PICTURE_BADGE_STYLE} data-question-picture-badge="">
1207
+ {badge}
1208
+ </span>
1209
+ ) : null;
1210
+ })()}
1211
+ {shown ? (
1212
+ <img
1213
+ src={shown}
1214
+ alt={decorative ? '' : alt}
1215
+ draggable={false}
1216
+ loading="lazy"
1217
+ className="h-full w-full object-contain"
1218
+ data-question-picture=""
1219
+ data-zoomable=""
1220
+ data-image-src={choice.image.src}
1221
+ onError={() => onPictureError?.(shown)}
1222
+ />
1223
+ ) : (
1224
+ <span
1225
+ className="grid h-full place-items-center px-2 text-center text-xs text-muted-foreground"
1226
+ title={heldTitle?.(choice.image.src) ?? undefined}
1227
+ data-image-unavailable=""
1228
+ >
1229
+ {alt}
1230
+ </span>
1231
+ )}
1232
+ </span>
1233
+ )}
1234
+ <span className="flex w-full min-w-0 items-start gap-2 px-2.5 py-2" data-question-tile-caption="">
1235
+ <input
1236
+ {...input}
1237
+ type={inputType}
1238
+ name={name}
1239
+ checked={checked}
1240
+ onChange={(e) => onPick(choice.label, e.target.checked)}
1241
+ className="mt-[3px] h-[15px] w-[15px] shrink-0 cursor-pointer accent-primary disabled:cursor-default"
1242
+ data-question-control=""
1243
+ disabled={readOnly}
1244
+ />
1245
+ <span className="min-w-0 flex-1 select-text text-[13px] leading-[1.4] text-foreground" data-question-part="choice">
1246
+ <span className="font-[550]" data-question-tile-label="">{renderText(choice.label)}</span>
1247
+ {choice.description && (
1248
+ <span className="block text-muted-foreground" data-question-tile-description="">{renderText(choice.description)}</span>
1249
+ )}
1250
+ {renderBadges
1251
+ ? renderBadges(choice, index)
1252
+ : choice.recommended && <span className="mt-1 block"><Tag tone="rec">Recommended</Tag></span>}
1253
+ </span>
1254
+ </span>
1255
+ </label>
1256
+ {shown && onZoom && (
1257
+ <button
1258
+ type="button"
1259
+ onClick={() => onZoom(shown, alt)}
1260
+ aria-label={`Zoom: ${alt}`}
1261
+ 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"
1262
+ data-pinpoint-ignore=""
1263
+ data-question-zoom=""
1264
+ >
1265
+ <ZoomGlyph />
1266
+ </button>
1267
+ )}
1268
+ </div>
1269
+ );
1270
+ })}
1271
+ </div>
1272
+ );