@plannotator/ui 0.53.1 → 0.55.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/HANDOFF.md +274 -14
- package/README.md +114 -11
- 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 +22 -5
- package/components/CodeFilePopout.tsx +139 -4
- package/components/CodeSnippetPreview.tsx +174 -0
- package/components/CommentPopover.tsx +44 -8
- package/components/DecisionControl.tsx +31 -5
- package/components/DeferredMarkdownContentsRail.tsx +38 -0
- package/components/DiagramBlock.tsx +17 -12
- package/components/DiffFileTree.tsx +7 -6
- package/components/DocumentContents.tsx +74 -0
- 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 +722 -126
- 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 +167 -174
- 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/MarkdownContentsRail.tsx +777 -0
- 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/TableOfContents.tsx +20 -6
- 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 +288 -43
- 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 +532 -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 +82 -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 +276 -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/contents-rail.css +337 -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/useHeadingScrollSpy.ts +77 -0
- package/hooks/useHoverPreview.ts +94 -0
- package/hooks/useLinkedDoc.ts +226 -13
- package/hooks/usePackageChangesSetting.ts +90 -0
- package/hooks/usePlanLookDialog.ts +67 -0
- package/hooks/usePrefersReducedMotion.ts +23 -0
- package/hooks/useSharing.ts +11 -7
- package/hooks/useSwipeStep.ts +597 -0
- package/hooks/useThemePaletteHalves.ts +37 -0
- package/hooks/useUpdateCheck.ts +22 -4
- package/hooks/useUpdateToast.ts +1 -1
- package/package.json +26 -23
- 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/annotationHelpers.ts +32 -4
- 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/blockParse.ts +45 -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/headingScrollSpy.ts +92 -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,38 @@ export interface QuestionBlockProps {
|
|
|
197
263
|
githubRepo?: string;
|
|
198
264
|
repoHost?: string;
|
|
199
265
|
onNavigateAnchor?: (hash: string) => void;
|
|
266
|
+
/** Draw picture choices (`choice.image`) as picture tiles (default true).
|
|
267
|
+
* False: every choice is a text row, as on a host that shows no images. */
|
|
268
|
+
pictureChoices?: boolean;
|
|
269
|
+
/** A small label over a picture tile's picture, top left ("Mockup · VS
|
|
270
|
+
* Code"), from the host; null or absent: none. Display only: never the
|
|
271
|
+
* answer, never read by the parser. Default absent, so every card draws as
|
|
272
|
+
* before (the Plannotator Inbox's onboarding tour labels its pictures). */
|
|
273
|
+
pictureBadge?: (choice: { label: string; image: { alt: string; src: string } }) => string | null;
|
|
274
|
+
/** The zoom button on a picture tile, told which question and which choice
|
|
275
|
+
* (its index in the question's choices) as well as the picture: a host can
|
|
276
|
+
* open a gallery of that question's choices (the Plannotator Inbox's option
|
|
277
|
+
* sets). Absent: the zoom calls `onImageClick(url, alt)` as before. */
|
|
278
|
+
onZoomChoice?: (key: string, index: number, url: string, alt: string) => void;
|
|
200
279
|
}
|
|
201
280
|
|
|
281
|
+
/** The picture tile's label (`pictureBadge`): a small dark chip over the picture's top-left corner. */
|
|
282
|
+
const PICTURE_BADGE_STYLE: React.CSSProperties = {
|
|
283
|
+
position: 'absolute',
|
|
284
|
+
left: 6,
|
|
285
|
+
top: 6,
|
|
286
|
+
zIndex: 1,
|
|
287
|
+
pointerEvents: 'none',
|
|
288
|
+
borderRadius: 4,
|
|
289
|
+
background: 'rgb(0 0 0 / 0.6)',
|
|
290
|
+
color: 'white',
|
|
291
|
+
padding: '0 6px',
|
|
292
|
+
fontSize: 10.5,
|
|
293
|
+
fontWeight: 600,
|
|
294
|
+
lineHeight: '18px',
|
|
295
|
+
letterSpacing: '0.02em',
|
|
296
|
+
};
|
|
297
|
+
|
|
202
298
|
/** A host's status tag (`statusLabel`): its words, and the tone of one of the card's own tags (default `open`). */
|
|
203
299
|
export interface QuestionStatusLabel {
|
|
204
300
|
label: string;
|
|
@@ -228,8 +324,39 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
|
|
|
228
324
|
githubRepo,
|
|
229
325
|
repoHost,
|
|
230
326
|
onNavigateAnchor,
|
|
327
|
+
pictureChoices = true,
|
|
328
|
+
pictureBadge,
|
|
329
|
+
onZoomChoice,
|
|
231
330
|
}) => {
|
|
232
331
|
const { question, number } = indexed;
|
|
332
|
+
const resolveImage = useImageResolver();
|
|
333
|
+
// The URL a picture loads from, exactly as an inline markdown image of the
|
|
334
|
+
// same src would (the host's resolver first), or null when the host says
|
|
335
|
+
// it cannot be shown.
|
|
336
|
+
const pictureSrc = (src: string): string | null => {
|
|
337
|
+
const resolved = resolveImage ? resolveImage(src) : null;
|
|
338
|
+
if (resolved?.kind === 'unavailable') return null;
|
|
339
|
+
if (resolved?.kind === 'src') return resolved.src;
|
|
340
|
+
return /^(https?:\/\/|data:|blob:)/i.test(src) ? src : getImageSrc(src, imageBaseDir);
|
|
341
|
+
};
|
|
342
|
+
// The host's reason a picture is not shown (its resolver's `unavailable`
|
|
343
|
+
// title), drawn on the placeholder exactly as an inline image's, so a host
|
|
344
|
+
// that holds remote images counts and loads these too (code review's PR
|
|
345
|
+
// Markdown preview: "N remote images held · Load remote images").
|
|
346
|
+
const pictureHeldTitle = (src: string): string | null => {
|
|
347
|
+
const resolved = resolveImage ? resolveImage(src) : null;
|
|
348
|
+
return resolved?.kind === 'unavailable' ? resolved.title : null;
|
|
349
|
+
};
|
|
350
|
+
const heldPlaceholder = (src: string, alt: string) => {
|
|
351
|
+
const title = pictureHeldTitle(src);
|
|
352
|
+
return title === null ? null : (
|
|
353
|
+
<span className={UNAVAILABLE_IMAGE_CLASS} title={title} data-image-unavailable="">{alt || 'Image'}</span>
|
|
354
|
+
);
|
|
355
|
+
};
|
|
356
|
+
// Pictures that failed to load (by the URL tried): a tile shows its alt
|
|
357
|
+
// text instead, and with none left the choices are text rows.
|
|
358
|
+
const [failedPictures, setFailedPictures] = useState<ReadonlySet<string>>(() => new Set());
|
|
359
|
+
const markPictureFailed = (url: string) => setFailedPictures((prev) => (prev.has(url) ? prev : new Set([...prev, url])));
|
|
233
360
|
// An answer saved under an older label of a choice (0.28.1 read a wrapped
|
|
234
361
|
// plain bullet's first line as its label) shows that choice picked; the
|
|
235
362
|
// next edit stores the current label.
|
|
@@ -275,6 +402,7 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
|
|
|
275
402
|
}
|
|
276
403
|
};
|
|
277
404
|
const cancel = () => {
|
|
405
|
+
replacedOtherRef.current = null;
|
|
278
406
|
setDraft(null);
|
|
279
407
|
setNotePinned(null);
|
|
280
408
|
};
|
|
@@ -283,6 +411,7 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
|
|
|
283
411
|
const uid = useId().replace(/:/g, '');
|
|
284
412
|
const promptId = `q-prompt-${uid}`;
|
|
285
413
|
const contextId = `q-context-${uid}`;
|
|
414
|
+
const stakesId = `q-stakes-${uid}`;
|
|
286
415
|
const suggestId = `q-suggest-${uid}`;
|
|
287
416
|
const noteLabelId = `q-note-${uid}`;
|
|
288
417
|
// null = follow the answer (open while a note exists); a click pins it.
|
|
@@ -291,10 +420,21 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
|
|
|
291
420
|
const pressRef = useRef<{ x: number; y: number; touch: boolean } | null>(null);
|
|
292
421
|
const noteRef = useRef<HTMLTextAreaElement>(null);
|
|
293
422
|
const cardRef = useRef<HTMLFieldSetElement>(null);
|
|
294
|
-
// Accept recommended
|
|
423
|
+
// Accept recommended goes away once clicked: focus moves to what
|
|
295
424
|
// the click filled in, never to the page.
|
|
296
425
|
const focusAfterAcceptRef = useRef(false);
|
|
297
426
|
const lastPickRef = useRef<{ label: string; before: QuestionAnswer | undefined; at: number } | null>(null);
|
|
427
|
+
// Who set the "Other…" answer while this card was open: the recommended
|
|
428
|
+
// row (its pick, or Accept recommended) or the Other field. null: neither
|
|
429
|
+
// (an answer the host stored): an Other answer that is exactly the agent's
|
|
430
|
+
// text then reads as the row, unless the Other field has focus. Typing
|
|
431
|
+
// never turns into the row, so "Yes, but slower" stays in the field when
|
|
432
|
+
// the recommendation is "Yes".
|
|
433
|
+
const [otherBy, setOtherBy] = useState<'row' | 'field' | null>(null);
|
|
434
|
+
const [otherFocused, setOtherFocused] = useState(false);
|
|
435
|
+
// Pick any: the reviewer's own Other text the recommended row replaced when
|
|
436
|
+
// ticked, put back when it is unticked (the answer has one Other field).
|
|
437
|
+
const replacedOtherRef = useRef<string | null>(null);
|
|
298
438
|
|
|
299
439
|
const inline = (text: string) => (
|
|
300
440
|
<InlineMarkdown
|
|
@@ -322,9 +462,9 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
|
|
|
322
462
|
// recommendation names choices until the answer is exactly those choices:
|
|
323
463
|
// ticking one box must not take away the one click that ticks the rest. A
|
|
324
464
|
// pick-one or text question answered any way, a question with a settled
|
|
325
|
-
// choice, and a pick-any question recommending only
|
|
326
|
-
//
|
|
327
|
-
//
|
|
465
|
+
// choice, and a pick-any question recommending only its own answer (the
|
|
466
|
+
// recommended row after the choices: Accept would clear the ticks for it,
|
|
467
|
+
// and the row is one click anyway) offer it no more.
|
|
328
468
|
const offerAccept = !readOnly && hasRecommendation && !settled && (
|
|
329
469
|
!answered
|
|
330
470
|
|| (question.kind === 'multi'
|
|
@@ -361,7 +501,37 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
|
|
|
361
501
|
}
|
|
362
502
|
};
|
|
363
503
|
|
|
504
|
+
// The agent's recommended answer that is none of its choices
|
|
505
|
+
// (`suggestedText` on a pick question): one more answer row, after the
|
|
506
|
+
// choices and before "Other…". Picking it stores exactly what Accept
|
|
507
|
+
// recommended stores, the text as the "Other…" answer, so no label,
|
|
508
|
+
// index, key or host check of picked labels changes.
|
|
509
|
+
const pickSuggestion = (checked: boolean) => {
|
|
510
|
+
const text = question.suggestedText;
|
|
511
|
+
if (!text) return;
|
|
512
|
+
lastPickRef.current = { label: SUGGESTION_ROW, before: answer, at: now() };
|
|
513
|
+
const { skipped: _s, other: _o, ...rest } = base;
|
|
514
|
+
if (question.kind === 'multi') {
|
|
515
|
+
if (checked) {
|
|
516
|
+
if (!suggestionPicked) replacedOtherRef.current = storedOther.trim() ? storedOther : null;
|
|
517
|
+
setOtherBy('row');
|
|
518
|
+
commit({ ...rest, selected, other: text });
|
|
519
|
+
} else {
|
|
520
|
+
const back = replacedOtherRef.current;
|
|
521
|
+
replacedOtherRef.current = null;
|
|
522
|
+
setOtherBy(back ? 'field' : null);
|
|
523
|
+
commit({ ...rest, selected, ...(back ? { other: back } : {}) });
|
|
524
|
+
}
|
|
525
|
+
} else if (checked) {
|
|
526
|
+
replacedOtherRef.current = null;
|
|
527
|
+
setOtherBy('row');
|
|
528
|
+
commit({ ...rest, selected: [], other: text });
|
|
529
|
+
}
|
|
530
|
+
};
|
|
531
|
+
|
|
364
532
|
const setOther = (value: string) => {
|
|
533
|
+
setOtherBy('field');
|
|
534
|
+
replacedOtherRef.current = null;
|
|
365
535
|
const { skipped: _s, other: _o, ...rest } = base;
|
|
366
536
|
const keepSelected = question.kind === 'multi' ? selected : value.trim() ? [] : rest.selected;
|
|
367
537
|
commit({ ...rest, selected: keepSelected, ...(value ? { other: value } : {}) });
|
|
@@ -378,6 +548,7 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
|
|
|
378
548
|
};
|
|
379
549
|
|
|
380
550
|
const toggleSkip = () => {
|
|
551
|
+
replacedOtherRef.current = null;
|
|
381
552
|
if (base.skipped) {
|
|
382
553
|
const { skipped: _s, ...rest } = base;
|
|
383
554
|
commit(rest);
|
|
@@ -390,6 +561,8 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
|
|
|
390
561
|
const acceptRecommended = () => {
|
|
391
562
|
const next = recommendedQuestionAnswer(indexed, base);
|
|
392
563
|
if (!next) return;
|
|
564
|
+
replacedOtherRef.current = null;
|
|
565
|
+
if (next.other) setOtherBy('row');
|
|
393
566
|
focusAfterAcceptRef.current = true;
|
|
394
567
|
commit(next);
|
|
395
568
|
};
|
|
@@ -450,8 +623,25 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
|
|
|
450
623
|
: `Question${question.kind === 'multi' ? ' · pick any' : ''}`;
|
|
451
624
|
const groupName = `question-${uid}`;
|
|
452
625
|
const inputType = question.kind === 'multi' ? 'checkbox' : 'radio';
|
|
453
|
-
const
|
|
626
|
+
const storedOther = answer?.other ?? '';
|
|
627
|
+
// The recommended row holds the "Other…" answer when it is the agent's
|
|
628
|
+
// text; the Other field is then empty (typing there replaces it).
|
|
629
|
+
const suggestionRow = question.kind !== 'text' && !!question.suggestedText;
|
|
630
|
+
const suggestionPicked = suggestionRow
|
|
631
|
+
&& storedOther.trim() !== ''
|
|
632
|
+
&& storedOther.trim() === question.suggestedText!.trim()
|
|
633
|
+
&& (otherBy === 'row' || (otherBy === null && !otherFocused));
|
|
634
|
+
const other = suggestionPicked ? '' : storedOther;
|
|
454
635
|
const otherOn = other.trim() !== '';
|
|
636
|
+
// Picture choices: each choice's picture URL (null: none, or not shown).
|
|
637
|
+
const pictures = question.choices.map((c) => (c.image ? pictureSrc(c.image.src) : null));
|
|
638
|
+
const showTiles = pictureChoices
|
|
639
|
+
&& question.kind !== 'text'
|
|
640
|
+
&& pictures.some((url) => url !== null && !failedPictures.has(url));
|
|
641
|
+
const recommendedLabels = question.choices.filter((c) => c.recommended).map((c) => c.label);
|
|
642
|
+
const showsUrl = question.shows?.kind === 'image' ? pictureSrc(question.shows.src) : null;
|
|
643
|
+
// A `Shows:` picture that failed to load is its label, as a picture tile's is.
|
|
644
|
+
const showsSrc = showsUrl !== null && !failedPictures.has(showsUrl) ? showsUrl : null;
|
|
455
645
|
|
|
456
646
|
// A `Decision: when answered` question the host may switch off, or (with
|
|
457
647
|
// `decisionScope: 'any'`) any other question once the host supplies a
|
|
@@ -468,27 +658,16 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
|
|
|
468
658
|
? (recordingOn ? 'on' : 'off')
|
|
469
659
|
: undefined;
|
|
470
660
|
|
|
471
|
-
const describedBy = [question.context ? contextId : null, question.suggestedText && !answered ? suggestId : null]
|
|
661
|
+
const describedBy = [question.stakes ? stakesId : null, question.context ? contextId : null, question.kind === 'text' && question.suggestedText && !answered ? suggestId : null]
|
|
472
662
|
.filter(Boolean)
|
|
473
663
|
.join(' ') || undefined;
|
|
474
664
|
|
|
475
|
-
//
|
|
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
|
-
)}
|
|
665
|
+
// A text question's recommended answer: shown above the field with the
|
|
666
|
+
// same Recommended tag a choice carries; the footer's Accept recommended
|
|
667
|
+
// fills it in. (A pick question's is a row of its own, below.)
|
|
668
|
+
const suggestedLine = question.kind === 'text' && question.suggestedText && !answered && (
|
|
669
|
+
<div id={suggestId} className="mt-2 text-[12.5px] text-muted-foreground" data-question-suggested="">
|
|
670
|
+
<Tag tone="rec">Recommended</Tag> {inline(question.suggestedText)}
|
|
492
671
|
</div>
|
|
493
672
|
);
|
|
494
673
|
|
|
@@ -497,6 +676,8 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
|
|
|
497
676
|
ref={cardRef}
|
|
498
677
|
className={cx(
|
|
499
678
|
'question-block my-5 min-w-0 rounded-[10px] border px-3.5 pb-2.5 pt-3',
|
|
679
|
+
// A size container: the card's own width lays out its footer (FOOT_BUTTON).
|
|
680
|
+
'@container/qcard',
|
|
500
681
|
// Native radios/checkboxes follow the theme's mode. ThemeProvider marks
|
|
501
682
|
// light mode with `.light` on the root; a host without theme classes
|
|
502
683
|
// keeps the page's own color-scheme.
|
|
@@ -569,6 +750,14 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
|
|
|
569
750
|
) : (
|
|
570
751
|
<><Tag tone="rec" style={DECISION_OFF_STYLE}><DiamondGlyph />Records a decision</Tag>{' '}</>
|
|
571
752
|
))}
|
|
753
|
+
{question.reversible && (
|
|
754
|
+
<><HostTitle title={question.reversible.why ? `Can't be undone: ${question.reversible.why}` : "Can't be undone"}>
|
|
755
|
+
<span className="inline-flex" data-question-irreversible="">
|
|
756
|
+
<Tag tone="warn">Can’t be undone</Tag>
|
|
757
|
+
{question.reversible.why && <span className="sr-only">: {question.reversible.why}</span>}
|
|
758
|
+
</span>
|
|
759
|
+
</HostTitle>{' '}</>
|
|
760
|
+
)}
|
|
572
761
|
{statusTag !== 'none' && hostStatus && <Tag tone={hostStatus.tone ?? 'open'}>{hostStatus.label}</Tag>}
|
|
573
762
|
{statusTag !== 'none' && !hostStatus && (
|
|
574
763
|
<>
|
|
@@ -584,6 +773,43 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
|
|
|
584
773
|
<p id={promptId} className="m-0 text-[15px] font-semibold leading-[1.45] text-foreground" data-question-part="prompt">
|
|
585
774
|
{inline(question.prompt)}
|
|
586
775
|
</p>
|
|
776
|
+
{question.stakes && (
|
|
777
|
+
<p id={stakesId} className="m-0 mt-1 text-[13px] font-medium leading-[1.45] text-foreground/90" data-question-part="stakes">
|
|
778
|
+
{inline(question.stakes)}
|
|
779
|
+
</p>
|
|
780
|
+
)}
|
|
781
|
+
{question.shows && (
|
|
782
|
+
<div className="mt-1.5 flex flex-wrap items-center gap-1.5 text-[12.5px]" data-question-shows="">
|
|
783
|
+
<span className="annotation-exclude select-none text-muted-foreground" data-pinpoint-ignore="">Shows</span>
|
|
784
|
+
{question.shows.kind === 'image' ? (
|
|
785
|
+
showsSrc && onImageClick ? (
|
|
786
|
+
<button
|
|
787
|
+
type="button"
|
|
788
|
+
onClick={() => onImageClick(showsSrc, question.shows!.label || 'Picture')}
|
|
789
|
+
className="inline-flex min-w-0 max-w-full items-center gap-1.5 rounded-md border border-border/80 bg-background/60 py-0.5 pl-0.5 pr-2 text-foreground hover:bg-muted/50 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-primary"
|
|
790
|
+
data-question-shows-open=""
|
|
791
|
+
>
|
|
792
|
+
<img
|
|
793
|
+
src={showsSrc}
|
|
794
|
+
alt=""
|
|
795
|
+
draggable={false}
|
|
796
|
+
className="h-6 w-8 shrink-0 rounded-[4px] object-cover"
|
|
797
|
+
data-question-picture=""
|
|
798
|
+
data-zoomable=""
|
|
799
|
+
data-image-src={question.shows.src}
|
|
800
|
+
onError={() => markPictureFailed(showsSrc)}
|
|
801
|
+
/>
|
|
802
|
+
<span className="truncate">{question.shows.label || 'Picture'}</span>
|
|
803
|
+
</button>
|
|
804
|
+
) : (
|
|
805
|
+
heldPlaceholder(question.shows.src, question.shows.label)
|
|
806
|
+
?? <span className="inline-flex items-center gap-1 text-foreground"><PictureGlyph />{question.shows.label || question.shows.src}</span>
|
|
807
|
+
)
|
|
808
|
+
) : (
|
|
809
|
+
<span className="min-w-0 text-foreground">{inline(`[${question.shows.label}](${question.shows.src})`)}</span>
|
|
810
|
+
)}
|
|
811
|
+
</div>
|
|
812
|
+
)}
|
|
587
813
|
{renderBelowPrompt?.(indexed)}
|
|
588
814
|
{(question.decision || (decisionCapable && recordingOn)) && (
|
|
589
815
|
<div
|
|
@@ -643,49 +869,106 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
|
|
|
643
869
|
</>
|
|
644
870
|
) : (
|
|
645
871
|
<>
|
|
646
|
-
{suggestedLine}
|
|
647
872
|
<div
|
|
648
873
|
className={cx('mt-2.5 grid gap-0.5', skipped && 'opacity-55')}
|
|
649
874
|
role={inputType === 'radio' ? 'radiogroup' : 'group'}
|
|
650
875
|
aria-labelledby={promptId}
|
|
651
876
|
>
|
|
652
|
-
{
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
877
|
+
{showTiles ? (
|
|
878
|
+
<QuestionPictureTiles
|
|
879
|
+
choices={question.choices}
|
|
880
|
+
urls={question.choices.map((_c, index) => {
|
|
881
|
+
const url = pictures[index];
|
|
882
|
+
return url !== null && url !== undefined && !failedPictures.has(url) ? url : null;
|
|
883
|
+
})}
|
|
884
|
+
selected={selected}
|
|
885
|
+
inputType={inputType}
|
|
886
|
+
name={groupName}
|
|
887
|
+
readOnly={readOnly}
|
|
888
|
+
onPick={pick}
|
|
889
|
+
onZoom={
|
|
890
|
+
onZoomChoice
|
|
891
|
+
? (url, alt, index) => onZoomChoice(question.key, index, url, alt)
|
|
892
|
+
: onImageClick
|
|
893
|
+
}
|
|
894
|
+
onPictureError={markPictureFailed}
|
|
895
|
+
heldTitle={pictureHeldTitle}
|
|
896
|
+
renderText={inline}
|
|
897
|
+
labelProps={(label) => ({ onPointerDown: onRowPointerDown, onClickCapture: (event) => onRowClick(event, label) })}
|
|
898
|
+
pictureBadge={pictureBadge}
|
|
899
|
+
/>
|
|
900
|
+
) : (
|
|
901
|
+
question.choices.map((choice) => {
|
|
902
|
+
const checked = selected.includes(choice.label);
|
|
903
|
+
return (
|
|
904
|
+
<label
|
|
905
|
+
key={choice.label}
|
|
906
|
+
className={cx(
|
|
907
|
+
'flex items-start gap-2.5 rounded-[7px] px-[9px] py-1.5 transition-colors',
|
|
908
|
+
readOnly ? 'cursor-default' : 'cursor-pointer hover:bg-muted/45',
|
|
909
|
+
checked && 'bg-primary/12 hover:bg-primary/12',
|
|
678
910
|
)}
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
911
|
+
data-question-option=""
|
|
912
|
+
onPointerDown={onRowPointerDown}
|
|
913
|
+
onClickCapture={(event) => onRowClick(event, choice.label)}
|
|
914
|
+
>
|
|
915
|
+
<input
|
|
916
|
+
type={inputType}
|
|
917
|
+
name={groupName}
|
|
918
|
+
checked={checked}
|
|
919
|
+
onChange={(e) => pick(choice.label, e.target.checked)}
|
|
920
|
+
className="mt-[3px] h-[15px] w-[15px] shrink-0 cursor-pointer accent-primary disabled:cursor-default"
|
|
921
|
+
data-question-control=""
|
|
922
|
+
disabled={readOnly}
|
|
923
|
+
/>
|
|
924
|
+
<span className="min-w-0 flex-1 select-text text-[13.5px] leading-[1.45] text-foreground" data-question-part="choice">
|
|
925
|
+
<span className="font-[550]">{inline(choice.label)}</span>
|
|
926
|
+
{choice.image && pictureChoices && pictureHeldTitle(choice.image.src) !== null && <> {heldPlaceholder(choice.image.src, choice.image.alt || choice.label)}</>}
|
|
927
|
+
{choice.description && (
|
|
928
|
+
<span className="text-muted-foreground"> — {inline(choice.description)}</span>
|
|
929
|
+
)}
|
|
930
|
+
{choice.recommended && <> <Tag tone="rec">Recommended</Tag></>}
|
|
931
|
+
</span>
|
|
932
|
+
</label>
|
|
933
|
+
);
|
|
934
|
+
})
|
|
935
|
+
)}
|
|
936
|
+
{suggestionRow && (
|
|
937
|
+
// The agent's recommended answer that is none of its choices:
|
|
938
|
+
// drawn exactly as a choice (the same control, row and tag), so
|
|
939
|
+
// the card reads as one list of answers.
|
|
940
|
+
<label
|
|
941
|
+
className={cx(
|
|
942
|
+
'flex items-start gap-2.5 rounded-[7px] px-[9px] py-1.5 transition-colors',
|
|
943
|
+
readOnly ? 'cursor-default' : 'cursor-pointer hover:bg-muted/45',
|
|
944
|
+
suggestionPicked && 'bg-primary/12 hover:bg-primary/12',
|
|
945
|
+
)}
|
|
946
|
+
data-question-option=""
|
|
947
|
+
data-question-suggested=""
|
|
948
|
+
onPointerDown={onRowPointerDown}
|
|
949
|
+
onClickCapture={(event) => onRowClick(event, SUGGESTION_ROW)}
|
|
950
|
+
>
|
|
951
|
+
<input
|
|
952
|
+
type={inputType}
|
|
953
|
+
name={groupName}
|
|
954
|
+
checked={suggestionPicked}
|
|
955
|
+
onChange={(e) => pickSuggestion(e.target.checked)}
|
|
956
|
+
className="mt-[3px] h-[15px] w-[15px] shrink-0 cursor-pointer accent-primary disabled:cursor-default"
|
|
957
|
+
data-question-control=""
|
|
958
|
+
disabled={readOnly}
|
|
959
|
+
/>
|
|
960
|
+
<span className="min-w-0 flex-1 select-text text-[13.5px] leading-[1.45] text-foreground" data-question-part="choice">
|
|
961
|
+
<span className="font-[550]">{inline(question.suggestedText!)}</span>
|
|
962
|
+
{' '}<Tag tone="rec">Recommended</Tag>
|
|
963
|
+
</span>
|
|
964
|
+
</label>
|
|
965
|
+
)}
|
|
684
966
|
{/* A read-only card with no Other answer has nothing to show there (it drew a bare "+"). */}
|
|
685
967
|
{(!readOnly || otherOn) && (
|
|
686
968
|
<div className="flex items-center gap-2.5 rounded-[7px] px-[9px] py-[5px]">
|
|
687
969
|
<span
|
|
688
970
|
aria-hidden="true"
|
|
971
|
+
data-question-control=""
|
|
689
972
|
className={cx(
|
|
690
973
|
'annotation-exclude select-none grid h-[15px] w-[15px] shrink-0 place-items-center border-[1.5px] text-[11px] leading-none',
|
|
691
974
|
question.kind === 'multi' ? 'rounded-[3px]' : 'rounded-full',
|
|
@@ -698,6 +981,13 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
|
|
|
698
981
|
type="text"
|
|
699
982
|
value={other}
|
|
700
983
|
onChange={(e) => setOther(e.target.value)}
|
|
984
|
+
onFocus={() => {
|
|
985
|
+
// A stored answer that reads as the row stays the row
|
|
986
|
+
// while the reviewer types here (typing replaces it).
|
|
987
|
+
if (otherBy === null && suggestionPicked) setOtherBy('row');
|
|
988
|
+
setOtherFocused(true);
|
|
989
|
+
}}
|
|
990
|
+
onBlur={() => setOtherFocused(false)}
|
|
701
991
|
disabled={readOnly}
|
|
702
992
|
placeholder={readOnly ? '' : 'Other…'}
|
|
703
993
|
aria-label={number > 0 ? `Other answer to question ${number}` : 'Other answer'}
|
|
@@ -709,6 +999,15 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
|
|
|
709
999
|
</>
|
|
710
1000
|
)}
|
|
711
1001
|
|
|
1002
|
+
{question.why && (
|
|
1003
|
+
<p className="m-0 mt-2 text-[12.5px] leading-[1.45] text-muted-foreground" data-question-part="why">
|
|
1004
|
+
<span className="font-semibold text-foreground/85">
|
|
1005
|
+
{recommendedLabels.length > 0 ? <>Why {recommendedLabels.map((label, i) => <React.Fragment key={label}>{i > 0 ? ', ' : ''}{inline(label)}</React.Fragment>)}:</> : 'Why:'}
|
|
1006
|
+
</span>{' '}
|
|
1007
|
+
{inline(question.why)}
|
|
1008
|
+
</p>
|
|
1009
|
+
)}
|
|
1010
|
+
|
|
712
1011
|
{noteOpen && (!readOnly || !!answer?.note?.trim()) && (
|
|
713
1012
|
<div className="mt-2">
|
|
714
1013
|
<p id={noteLabelId} className="annotation-exclude select-none mb-1 ml-0.5 text-[10.5px] font-semibold uppercase tracking-[0.07em] text-muted-foreground">
|
|
@@ -728,7 +1027,7 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
|
|
|
728
1027
|
|
|
729
1028
|
{(!readOnly || hasHostFooter) && (
|
|
730
1029
|
<div
|
|
731
|
-
className="annotation-exclude select-none mt-2 flex items-center gap-1 border-t border-border/55 pt-1.5"
|
|
1030
|
+
className="annotation-exclude select-none mt-2 flex flex-wrap items-center gap-1 pointer-coarse:gap-y-5 border-t border-border/55 pt-1.5"
|
|
732
1031
|
data-pinpoint-ignore=""
|
|
733
1032
|
>
|
|
734
1033
|
{!readOnly && (
|
|
@@ -737,6 +1036,7 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
|
|
|
737
1036
|
onClick={toggleNote}
|
|
738
1037
|
aria-expanded={noteOpen}
|
|
739
1038
|
className={cx(
|
|
1039
|
+
FOOT_BUTTON,
|
|
740
1040
|
'rounded-[5px] px-[7px] py-[3px] text-xs hover:bg-muted hover:text-foreground',
|
|
741
1041
|
noteOpen ? 'text-foreground' : 'text-muted-foreground',
|
|
742
1042
|
)}
|
|
@@ -751,6 +1051,7 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
|
|
|
751
1051
|
onClick={toggleSkip}
|
|
752
1052
|
aria-pressed={skipped}
|
|
753
1053
|
className={cx(
|
|
1054
|
+
FOOT_BUTTON,
|
|
754
1055
|
'rounded-[5px] px-[7px] py-[3px] text-xs hover:bg-muted hover:text-foreground',
|
|
755
1056
|
skipped ? 'text-foreground' : 'text-muted-foreground',
|
|
756
1057
|
)}
|
|
@@ -765,18 +1066,18 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
|
|
|
765
1066
|
<button
|
|
766
1067
|
type="button"
|
|
767
1068
|
onClick={acceptRecommended}
|
|
768
|
-
className=
|
|
1069
|
+
className={cx(FOOT_BUTTON, FOOT_FIT, FOOT_OWN_ROW, 'ml-auto rtl:ml-0 rtl:mr-auto rounded-md border border-primary/45 bg-primary/10 px-2.5 py-[3px] text-[12.5px] font-medium text-primary hover:bg-primary/20 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary')}
|
|
769
1070
|
>
|
|
770
1071
|
Accept recommended
|
|
771
1072
|
</button>
|
|
772
1073
|
)
|
|
773
1074
|
) : (
|
|
774
|
-
<div className=
|
|
1075
|
+
<div className={cx(FOOT_OWN_ROW, 'ml-auto rtl:ml-0 rtl:mr-auto flex min-w-0 flex-wrap items-center justify-end gap-1')}>
|
|
775
1076
|
{offerAccept && (
|
|
776
1077
|
<button
|
|
777
1078
|
type="button"
|
|
778
1079
|
onClick={acceptRecommended}
|
|
779
|
-
className=
|
|
1080
|
+
className={cx(FOOT_BUTTON, FOOT_FIT, 'rounded-md border border-primary/45 bg-primary/10 px-2.5 py-[3px] text-[12.5px] font-medium text-primary hover:bg-primary/20 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary')}
|
|
780
1081
|
>
|
|
781
1082
|
Accept recommended
|
|
782
1083
|
</button>
|
|
@@ -788,7 +1089,7 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
|
|
|
788
1089
|
type="button"
|
|
789
1090
|
onClick={cancel}
|
|
790
1091
|
disabled={saving}
|
|
791
|
-
className=
|
|
1092
|
+
className={cx(FOOT_BUTTON, FOOT_FIT, 'rounded-[5px] px-[7px] py-[3px] text-xs text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-60')}
|
|
792
1093
|
>
|
|
793
1094
|
Cancel
|
|
794
1095
|
</button>
|
|
@@ -797,7 +1098,7 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
|
|
|
797
1098
|
onClick={save}
|
|
798
1099
|
disabled={saving}
|
|
799
1100
|
aria-busy={saving || undefined}
|
|
800
|
-
className=
|
|
1101
|
+
className={cx(FOOT_BUTTON, FOOT_FIT, 'rounded-md bg-primary px-2.5 py-[3px] text-[12.5px] font-medium text-primary-foreground hover:bg-primary/90 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:opacity-60')}
|
|
801
1102
|
>
|
|
802
1103
|
{(typeof saveLabel === 'function' ? saveLabel(question) : saveLabel) || 'Save answer'}
|
|
803
1104
|
</button>
|
|
@@ -810,3 +1111,172 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
|
|
|
810
1111
|
</fieldset>
|
|
811
1112
|
);
|
|
812
1113
|
};
|
|
1114
|
+
|
|
1115
|
+
/** One choice a picture tile draws (a parsed question's choice, or the Inbox wire's). */
|
|
1116
|
+
export interface QuestionPictureTileChoice {
|
|
1117
|
+
label: string;
|
|
1118
|
+
description?: string | null;
|
|
1119
|
+
recommended?: boolean;
|
|
1120
|
+
image?: { alt: string; src: string } | null;
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
export interface QuestionPictureTilesProps {
|
|
1124
|
+
choices: readonly QuestionPictureTileChoice[];
|
|
1125
|
+
/** The URL each choice's picture loads from, in choice order; null (no picture shown) draws its alt text. */
|
|
1126
|
+
urls: readonly (string | null)[];
|
|
1127
|
+
selected: readonly string[];
|
|
1128
|
+
inputType: 'radio' | 'checkbox';
|
|
1129
|
+
/** The radio group's name. */
|
|
1130
|
+
name: string;
|
|
1131
|
+
readOnly?: boolean;
|
|
1132
|
+
/** The tile's radio or checkbox changed (the picture and the label pick through it). */
|
|
1133
|
+
onPick: (label: string, checked: boolean) => void;
|
|
1134
|
+
/** The separate zoom button (absent: no zoom button). Never picks. `index`: the choice's place in `choices`. */
|
|
1135
|
+
onZoom?: (url: string, alt: string, index: number) => void;
|
|
1136
|
+
onPictureError?: (url: string) => void;
|
|
1137
|
+
/** The host's reason a picture is held (its image resolver's `unavailable` title). */
|
|
1138
|
+
heldTitle?: (src: string) => string | null;
|
|
1139
|
+
/** How a label or description is drawn (the card's inline markdown). */
|
|
1140
|
+
renderText: (text: string) => React.ReactNode;
|
|
1141
|
+
/** A host's own props on each tile's label (the card's drag guard). */
|
|
1142
|
+
labelProps?: (label: string) => React.LabelHTMLAttributes<HTMLLabelElement>;
|
|
1143
|
+
/** A host's own props on each tile's input (decision mode answers on a click, or Enter). */
|
|
1144
|
+
inputProps?: (label: string) => React.InputHTMLAttributes<HTMLInputElement>;
|
|
1145
|
+
/** What sits under a choice's words (default: its Recommended tag). */
|
|
1146
|
+
renderBadges?: (choice: QuestionPictureTileChoice, index: number) => React.ReactNode;
|
|
1147
|
+
/** A small label over a tile's picture, top left (the card's `pictureBadge`); null or absent draws none. */
|
|
1148
|
+
pictureBadge?: (choice: { label: string; image: { alt: string; src: string } }) => string | null;
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
/**
|
|
1152
|
+
* Picture choices as tiles (approval "inbox-decision-mode", agent contract
|
|
1153
|
+
* lines): each tile is a native `<label>` holding the picture and a real radio
|
|
1154
|
+
* or checkbox, so the picture, the label and the keyboard pick; a separate
|
|
1155
|
+
* zoom button, outside the label, opens the picture and never picks. The
|
|
1156
|
+
* question card draws these, and so does the Inbox's decision mode (its own
|
|
1157
|
+
* size through `[data-question-tile*]` hooks), so the two never drift.
|
|
1158
|
+
*/
|
|
1159
|
+
export const QuestionPictureTiles: React.FC<QuestionPictureTilesProps> = ({
|
|
1160
|
+
choices,
|
|
1161
|
+
urls,
|
|
1162
|
+
selected,
|
|
1163
|
+
inputType,
|
|
1164
|
+
name,
|
|
1165
|
+
readOnly = false,
|
|
1166
|
+
onPick,
|
|
1167
|
+
onZoom,
|
|
1168
|
+
onPictureError,
|
|
1169
|
+
heldTitle,
|
|
1170
|
+
renderText,
|
|
1171
|
+
labelProps,
|
|
1172
|
+
inputProps,
|
|
1173
|
+
renderBadges,
|
|
1174
|
+
pictureBadge,
|
|
1175
|
+
}) => (
|
|
1176
|
+
<div
|
|
1177
|
+
className="mb-1 grid grid-cols-[repeat(auto-fill,minmax(150px,1fr))] gap-2"
|
|
1178
|
+
data-question-tiles=""
|
|
1179
|
+
>
|
|
1180
|
+
{choices.map((choice, index) => {
|
|
1181
|
+
const checked = selected.includes(choice.label);
|
|
1182
|
+
const shown = urls[index] ?? null;
|
|
1183
|
+
// The alt text names the picture; one that only repeats the label is decorative.
|
|
1184
|
+
const alt = choice.image ? (choice.image.alt || choice.label) : '';
|
|
1185
|
+
const decorative = !!choice.image && alt.trim() === choice.label.trim();
|
|
1186
|
+
const host = labelProps?.(choice.label);
|
|
1187
|
+
const input = inputProps?.(choice.label);
|
|
1188
|
+
return (
|
|
1189
|
+
<div
|
|
1190
|
+
key={choice.label}
|
|
1191
|
+
className={cx(
|
|
1192
|
+
'relative flex min-w-0 flex-col overflow-hidden rounded-[9px] border transition-colors has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-primary',
|
|
1193
|
+
checked ? 'border-primary/70 bg-primary/10' : 'border-border/80 bg-background/40',
|
|
1194
|
+
!readOnly && !checked && 'hover:border-foreground/30',
|
|
1195
|
+
)}
|
|
1196
|
+
data-question-tile=""
|
|
1197
|
+
data-question-tile-checked={checked ? '' : undefined}
|
|
1198
|
+
data-question-tile-recommended={choice.recommended ? '' : undefined}
|
|
1199
|
+
>
|
|
1200
|
+
<label
|
|
1201
|
+
{...host}
|
|
1202
|
+
className={cx('flex min-w-0 flex-1 flex-col', readOnly ? 'cursor-default' : 'cursor-pointer')}
|
|
1203
|
+
data-question-option=""
|
|
1204
|
+
>
|
|
1205
|
+
{choice.image && (
|
|
1206
|
+
<span
|
|
1207
|
+
className="annotation-exclude select-none block aspect-[4/3] w-full overflow-hidden border-b border-border/60 bg-muted/35"
|
|
1208
|
+
// Inline, not a utility class: the badge's position needs it, and no new class reaches the guides.show viewer's CSS.
|
|
1209
|
+
style={{ position: 'relative' }}
|
|
1210
|
+
data-pinpoint-ignore=""
|
|
1211
|
+
data-question-tile-picture=""
|
|
1212
|
+
>
|
|
1213
|
+
{(() => {
|
|
1214
|
+
const badge = pictureBadge?.({ label: choice.label, image: choice.image });
|
|
1215
|
+
return badge ? (
|
|
1216
|
+
<span style={PICTURE_BADGE_STYLE} data-question-picture-badge="">
|
|
1217
|
+
{badge}
|
|
1218
|
+
</span>
|
|
1219
|
+
) : null;
|
|
1220
|
+
})()}
|
|
1221
|
+
{shown ? (
|
|
1222
|
+
<img
|
|
1223
|
+
src={shown}
|
|
1224
|
+
alt={decorative ? '' : alt}
|
|
1225
|
+
draggable={false}
|
|
1226
|
+
loading="lazy"
|
|
1227
|
+
className="h-full w-full object-contain"
|
|
1228
|
+
data-question-picture=""
|
|
1229
|
+
data-zoomable=""
|
|
1230
|
+
data-image-src={choice.image.src}
|
|
1231
|
+
onError={() => onPictureError?.(shown)}
|
|
1232
|
+
/>
|
|
1233
|
+
) : (
|
|
1234
|
+
<span
|
|
1235
|
+
className="grid h-full place-items-center px-2 text-center text-xs text-muted-foreground"
|
|
1236
|
+
title={heldTitle?.(choice.image.src) ?? undefined}
|
|
1237
|
+
data-image-unavailable=""
|
|
1238
|
+
>
|
|
1239
|
+
{alt}
|
|
1240
|
+
</span>
|
|
1241
|
+
)}
|
|
1242
|
+
</span>
|
|
1243
|
+
)}
|
|
1244
|
+
<span className="flex w-full min-w-0 items-start gap-2 px-2.5 py-2" data-question-tile-caption="">
|
|
1245
|
+
<input
|
|
1246
|
+
{...input}
|
|
1247
|
+
type={inputType}
|
|
1248
|
+
name={name}
|
|
1249
|
+
checked={checked}
|
|
1250
|
+
onChange={(e) => onPick(choice.label, e.target.checked)}
|
|
1251
|
+
className="mt-[3px] h-[15px] w-[15px] shrink-0 cursor-pointer accent-primary disabled:cursor-default"
|
|
1252
|
+
data-question-control=""
|
|
1253
|
+
disabled={readOnly}
|
|
1254
|
+
/>
|
|
1255
|
+
<span className="min-w-0 flex-1 select-text text-[13px] leading-[1.4] text-foreground" data-question-part="choice">
|
|
1256
|
+
<span className="font-[550]" data-question-tile-label="">{renderText(choice.label)}</span>
|
|
1257
|
+
{choice.description && (
|
|
1258
|
+
<span className="block text-muted-foreground" data-question-tile-description="">{renderText(choice.description)}</span>
|
|
1259
|
+
)}
|
|
1260
|
+
{renderBadges
|
|
1261
|
+
? renderBadges(choice, index)
|
|
1262
|
+
: choice.recommended && <span className="mt-1 block"><Tag tone="rec">Recommended</Tag></span>}
|
|
1263
|
+
</span>
|
|
1264
|
+
</span>
|
|
1265
|
+
</label>
|
|
1266
|
+
{shown && onZoom && (
|
|
1267
|
+
<button
|
|
1268
|
+
type="button"
|
|
1269
|
+
onClick={() => onZoom(shown, alt, index)}
|
|
1270
|
+
aria-label={`Zoom: ${alt}`}
|
|
1271
|
+
className="annotation-exclude absolute right-1.5 top-1.5 grid h-7 w-7 place-items-center rounded-md bg-background/85 text-foreground shadow-sm ring-1 ring-border/70 before:absolute before:-inset-2 before:content-[''] hover:bg-background focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-primary"
|
|
1272
|
+
data-pinpoint-ignore=""
|
|
1273
|
+
data-question-zoom=""
|
|
1274
|
+
>
|
|
1275
|
+
<ZoomGlyph />
|
|
1276
|
+
</button>
|
|
1277
|
+
)}
|
|
1278
|
+
</div>
|
|
1279
|
+
);
|
|
1280
|
+
})}
|
|
1281
|
+
</div>
|
|
1282
|
+
);
|