@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.
- package/HANDOFF.md +217 -11
- package/README.md +65 -10
- package/components/AgentToolAnnouncementDialog.tsx +1 -1
- package/components/AgentsTab.tsx +1 -1
- package/components/AnnotationPanel.tsx +142 -24
- package/components/AnnotationSidebar.tsx +1 -1
- package/components/AnnotationToolbar.tsx +64 -9
- package/components/ApproveDropdown.tsx +7 -9
- package/components/AskSessionAnnouncementDialog.tsx +1 -1
- package/components/AttachmentsButton.tsx +1 -1
- package/components/BlockRenderer.tsx +19 -5
- package/components/CodeFilePopout.tsx +139 -4
- package/components/CommentPopover.tsx +44 -8
- package/components/DecisionControl.tsx +31 -5
- package/components/DiagramBlock.tsx +17 -12
- package/components/EditorAnnotationCard.tsx +1 -1
- package/components/ExportModal.tsx +77 -7
- package/components/HostTitle.tsx +0 -4
- package/components/ImageAnnotator/Toolbar.tsx +25 -1
- package/components/ImageAnnotator/index.tsx +24 -4
- package/components/ImageLightbox.tsx +658 -120
- package/components/ImagePeek.tsx +38 -4
- package/components/ImageRegions.tsx +369 -0
- package/components/ImageThumbnail.tsx +1 -1
- package/components/ImageZoomStage.tsx +796 -0
- package/components/InboxAnnouncementDialog.tsx +202 -0
- package/components/InlineMarkdown.tsx +152 -24
- package/components/KeyboardShortcuts.tsx +23 -0
- package/components/LazyBlockBoundary.tsx +100 -0
- package/components/ListMarker.tsx +3 -3
- package/components/LookAndFeelAnnouncementDialog.tsx +12 -2
- package/components/MenuVersionSection.tsx +31 -9
- package/components/PlanDecisionMenuExtras.tsx +129 -0
- package/components/PlanHeaderMenu.tsx +15 -1
- package/components/ProviderIcons.tsx +8 -0
- package/components/RefHoverCard.tsx +913 -0
- package/components/SendFailureToast.tsx +126 -0
- package/components/Settings.tsx +617 -126
- package/components/TerminalToolsAnnouncementDialog.tsx +1 -1
- package/components/ThemeProvider.tsx +14 -1
- package/components/ThemeTab.tsx +16 -24
- package/components/Tooltip.tsx +49 -18
- package/components/Viewer.tsx +280 -41
- package/components/ai/DocumentAIChatPanel.tsx +29 -3
- package/components/ai/NewChatButton.tsx +74 -0
- package/components/blocks/CodeBlock.tsx +1 -1
- package/components/blocks/HtmlBlock.tsx +7 -1
- package/components/blocks/QuestionBlock.tsx +522 -62
- package/components/diagram/DiagramComposer.tsx +5 -1
- package/components/diagram/DiagramPending.tsx +12 -2
- package/components/diagram/DiagramViewer.tsx +19 -3
- package/components/html-embed/HtmlEmbedBlock.tsx +374 -0
- package/components/html-embed/SvgFenceBlock.tsx +46 -0
- package/components/html-embed/index.ts +3 -0
- package/components/html-embed/languages.ts +15 -0
- package/components/html-embed/loadGate.ts +107 -0
- package/components/html-viewer/HtmlViewer.tsx +47 -6
- package/components/html-viewer/bridge-script.asset.js +173 -1
- package/components/html-viewer/bridge-script.ts +173 -1
- package/components/html-viewer/useHtmlAnnotation.ts +125 -0
- package/components/imageZoom.ts +265 -0
- package/components/plan-diff/PlanCleanDiffView.tsx +1 -1
- package/components/plan-diff/PlanDiffViewer.tsx +13 -2
- package/components/plan-diff/PlanRawDiffView.tsx +1 -1
- package/components/sidebar/FileBrowser.tsx +16 -2
- package/components/sidebar/SidebarTabs.tsx +3 -0
- package/config/index.ts +3 -0
- package/config/reviewView.ts +41 -1
- package/config/settings.ts +155 -4
- package/configure.ts +28 -0
- package/hooks/pfm/useCodeFilePopout.ts +7 -1
- package/hooks/useAIChat.ts +72 -25
- package/hooks/useAnnotationDraft.ts +71 -6
- package/hooks/useAnnotationHighlighter.ts +57 -3
- package/hooks/useCodeAnnotationDraft.ts +113 -17
- package/hooks/useCookieHostSetting.ts +96 -0
- package/hooks/useDocumentFont.ts +17 -0
- package/hooks/useDocumentTextSize.ts +20 -0
- package/hooks/useFileBrowser.ts +10 -1
- package/hooks/useLinkedDoc.ts +226 -13
- package/hooks/usePackageChangesSetting.ts +90 -0
- package/hooks/usePlanLookDialog.ts +67 -0
- package/hooks/useSharing.ts +11 -7
- package/hooks/useThemePaletteHalves.ts +37 -0
- package/hooks/useUpdateCheck.ts +22 -4
- package/hooks/useUpdateToast.ts +1 -1
- package/package.json +23 -22
- package/shortcuts/code-review/annotationToolbar.shortcuts.ts +1 -0
- package/shortcuts/code-review/explorer.shortcuts.ts +82 -0
- package/shortcuts/core.ts +63 -4
- package/shortcuts/index.ts +1 -0
- package/shortcuts/plan-review/annotationPanel.shortcuts.ts +1 -1
- package/shortcuts/plan-review/commentPopover.shortcuts.ts +1 -0
- package/shortcuts/runtime.ts +4 -3
- package/styles.css +1 -1
- package/theme.css +179 -1
- package/themes/plannotator-graphite.css +66 -0
- package/types.ts +26 -9
- package/utils/agentSwitch.ts +41 -20
- package/utils/agentToolSetting.ts +1 -1
- package/utils/aiChatFormat.ts +23 -7
- package/utils/annotationOwnsHighlight.ts +4 -2
- package/utils/asciidocParser.ts +570 -0
- package/utils/autoUpdateNotice.ts +24 -0
- package/utils/belowTitleSlot.ts +174 -0
- package/utils/codeLineScroll.ts +100 -0
- package/utils/commentSubmitKey.ts +48 -0
- package/utils/cssFontFamily.ts +65 -0
- package/utils/decisionSpec.ts +190 -2
- package/utils/defaultNotesApp.ts +1 -1
- package/utils/diffFonts.ts +4 -1
- package/utils/documentFont.ts +68 -0
- package/utils/documentTextSize.ts +54 -0
- package/utils/fontDetect.ts +63 -0
- package/utils/inboxAnnouncement.ts +78 -0
- package/utils/lookAndFeelAnnouncement.ts +82 -5
- package/utils/markdownExtensions.ts +2 -2
- package/utils/mermaid.ts +20 -3
- package/utils/mermaidTheme.ts +3 -51
- package/utils/notion.ts +58 -0
- package/utils/parser.ts +28 -1
- package/utils/platform.ts +46 -0
- package/utils/refCard.ts +82 -0
- package/utils/richClipboard.ts +380 -0
- package/utils/richClipboardLists.ts +57 -0
- package/utils/richCopyPolicy.ts +22 -0
- package/utils/scrollAnchor.ts +82 -0
- package/utils/sectionRefs.ts +123 -0
- package/utils/selectionBoundary.ts +40 -0
- package/utils/sendFailure.ts +85 -0
- package/utils/sharing.ts +5 -2
- package/utils/themeRegistry.ts +10 -0
- package/utils/themeTokens.ts +74 -0
- package/utils/updateNotice.ts +30 -8
- 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
|
|
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
|
|
326
|
-
//
|
|
327
|
-
//
|
|
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
|
|
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
|
-
//
|
|
476
|
-
//
|
|
477
|
-
//
|
|
478
|
-
const suggestedLine = question.suggestedText && !answered && (
|
|
479
|
-
<div id={suggestId} className="mt-2
|
|
480
|
-
<
|
|
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’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
|
-
{
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
{choice.
|
|
677
|
-
|
|
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
|
-
|
|
680
|
-
|
|
681
|
-
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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
|
+
);
|