@plannotator/ui 0.47.0 → 0.49.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.
@@ -0,0 +1,591 @@
1
+ import React, { useId, useRef, useState } from 'react';
2
+ import {
3
+ emptyQuestionAnswer,
4
+ isQuestionAnswerEmpty,
5
+ questionStatus,
6
+ recommendedQuestionAnswer,
7
+ type IndexedQuestion,
8
+ type ParsedQuestion,
9
+ type QuestionAnswer,
10
+ } from '@plannotator/core/question-block';
11
+ import { InlineMarkdown } from '../InlineMarkdown';
12
+ import { renderProseBody } from './proseBody';
13
+
14
+ /**
15
+ * A `:::question` block rendered as an answer card (the approved "Card"
16
+ * variant): an eyebrow with "Question N of M" and a status tag, the prompt,
17
+ * its context, then the choices (native radios / checkboxes plus an always
18
+ * present "Other…"), or a text box for a free-text question, and a footer
19
+ * with Add note · Skip · Accept recommended.
20
+ *
21
+ * The prompt, context and choice text stay ordinary document prose, so a
22
+ * reviewer can select and annotate any of it; only the chrome (eyebrow, tags,
23
+ * footer) is `annotation-exclude`. A click on a choice row picks it only when
24
+ * the pointer barely moved and no text is selected, so a drag across option
25
+ * text makes an annotation, never a pick.
26
+ *
27
+ * State is derived: `answer` comes from the host (Plannotator: the document's
28
+ * annotation carrying `questionAnswer` for this key). Two ways to write:
29
+ * - live (`onAnswer`): every change calls it with the next answer, or null
30
+ * when it became empty; the host upserts or removes the annotation.
31
+ * - explicit save (`onSaveAnswer`): changes stay a local draft; the footer
32
+ * shows Save answer and Cancel (and no Skip), Save calls `onSaveAnswer`
33
+ * once, Cancel returns to the host's answer.
34
+ * With neither the block is read-only.
35
+ *
36
+ * A host can add its own actions at the right of the footer
37
+ * (`renderFooter`), and a question carrying `Decision: when answered` or a
38
+ * `Decision: [statement](url)` link shows that beside the prompt.
39
+ */
40
+
41
+ /** Pointer travel (px) above which a press on a choice row is a drag. */
42
+ const DRAG_THRESHOLD_MOUSE = 4;
43
+ const DRAG_THRESHOLD_TOUCH = 10;
44
+ /** A second click on the same choice within this window is a double-click:
45
+ * it selects a word, so the pick its first click made is taken back. */
46
+ const DOUBLE_CLICK_REVERT_MS = 800;
47
+
48
+ const now = (): number => (typeof performance !== 'undefined' ? performance.now() : Date.now());
49
+
50
+ const cx = (...parts: Array<string | false | null | undefined>) => parts.filter(Boolean).join(' ');
51
+
52
+ const Tag: React.FC<{ tone: 'rec' | 'ok' | 'skip' | 'open'; children: React.ReactNode; className?: string }> = ({ tone, children, className }) => (
53
+ <span
54
+ className={cx(
55
+ 'annotation-exclude select-none inline-flex items-center gap-1 whitespace-nowrap rounded px-1.5 py-px align-[1px] text-[10.5px] font-semibold tracking-[0.03em]',
56
+ tone === 'rec' && 'bg-primary/15 text-primary',
57
+ tone === 'ok' && 'bg-success/15 text-success',
58
+ tone === 'skip' && 'bg-muted text-muted-foreground',
59
+ tone === 'open' && 'border border-dashed border-border text-muted-foreground',
60
+ className,
61
+ )}
62
+ data-pinpoint-ignore=""
63
+ >
64
+ {children}
65
+ </span>
66
+ );
67
+
68
+ const CheckGlyph = () => (
69
+ <svg viewBox="0 0 16 16" width="10" height="10" fill="none" stroke="currentColor" strokeWidth="2.2" aria-hidden="true">
70
+ <path d="m3.5 8.5 3 3 6-7" strokeLinecap="round" strokeLinejoin="round" />
71
+ </svg>
72
+ );
73
+
74
+ const DiamondGlyph = () => (
75
+ <svg viewBox="0 0 24 24" width="10" height="10" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true" className="shrink-0">
76
+ <path d="M2.7 10.3a2.41 2.41 0 0 0 0 3.41l7.59 7.59a2.41 2.41 0 0 0 3.41 0l7.59-7.59a2.41 2.41 0 0 0 0-3.41l-7.59-7.59a2.41 2.41 0 0 0-3.41 0Z" strokeLinejoin="round" />
77
+ </svg>
78
+ );
79
+
80
+ /** Two answers say the same thing (identity and line ignored). */
81
+ const sameAnswer = (a: QuestionAnswer | null | undefined, b: QuestionAnswer | null | undefined): boolean => {
82
+ const norm = (x: QuestionAnswer | null | undefined) =>
83
+ !x || isQuestionAnswerEmpty(x)
84
+ ? ''
85
+ : JSON.stringify([x.selected, x.other ?? '', x.text ?? '', x.note ?? '', !!x.skipped]);
86
+ return norm(a) === norm(b);
87
+ };
88
+
89
+ /** What `onSaveAnswer` may return: nothing (the save is done), or a promise
90
+ * the card waits on (rejecting keeps the draft so the reviewer can retry). */
91
+ export type QuestionSaveResult = void | Promise<unknown>;
92
+
93
+ export interface QuestionBlockProps {
94
+ blockId: string;
95
+ indexed: IndexedQuestion;
96
+ /** How many question blocks the document has (for "Question N of M"). */
97
+ total: number;
98
+ answer?: QuestionAnswer;
99
+ onAnswer?: (blockId: string, answer: QuestionAnswer | null, key: string) => void;
100
+ /** Explicit save mode (takes precedence over `onAnswer`): edits stay a
101
+ * draft in the card until Save, which calls this with the question's key
102
+ * and the answer (null when the draft is empty). The host stores it and
103
+ * passes it back as `answer`. */
104
+ onSaveAnswer?: (key: string, answer: QuestionAnswer | null) => QuestionSaveResult;
105
+ /** Label of the explicit-save-mode Save button (default "Save answer"),
106
+ * or a function of the question that returns it. */
107
+ saveLabel?: string | ((question: ParsedQuestion) => string);
108
+ /** Host actions at the right end of the footer, given the question and its
109
+ * saved answer (never the unsaved draft). Rendered in read-only cards too;
110
+ * return null for nothing. */
111
+ renderFooter?: (question: IndexedQuestion, answer: QuestionAnswer | undefined) => React.ReactNode;
112
+ onOpenLinkedDoc?: (path: string) => void;
113
+ onOpenCodeFile?: (path: string) => void;
114
+ imageBaseDir?: string;
115
+ onImageClick?: (src: string, alt: string) => void;
116
+ githubRepo?: string;
117
+ repoHost?: string;
118
+ onNavigateAnchor?: (hash: string) => void;
119
+ }
120
+
121
+ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
122
+ blockId,
123
+ indexed,
124
+ total,
125
+ answer: savedAnswer,
126
+ onAnswer: onLiveAnswer,
127
+ onSaveAnswer,
128
+ saveLabel,
129
+ renderFooter,
130
+ onOpenLinkedDoc,
131
+ onOpenCodeFile,
132
+ imageBaseDir,
133
+ onImageClick,
134
+ githubRepo,
135
+ repoHost,
136
+ onNavigateAnchor,
137
+ }) => {
138
+ const { question, number } = indexed;
139
+ const saveMode = !!onSaveAnswer;
140
+ // Save mode: the unsaved draft (null = the reviewer emptied it). Absent
141
+ // means the card shows the host's answer.
142
+ const [draft, setDraft] = useState<{ answer: QuestionAnswer | null } | null>(null);
143
+ const [saving, setSaving] = useState(false);
144
+ const answer: QuestionAnswer | undefined = saveMode && draft ? draft.answer ?? undefined : savedAnswer;
145
+ const dirty = saveMode && !!draft && !sameAnswer(draft.answer, savedAnswer);
146
+ // Every edit goes through here: straight to the host in live mode, into the
147
+ // draft in save mode.
148
+ // A draft edited back to the saved answer is dropped, so a later host
149
+ // update to `answer` shows instead of a stale copy.
150
+ const onAnswer = onSaveAnswer
151
+ ? (_blockId: string, next: QuestionAnswer | null) =>
152
+ setDraft(sameAnswer(next, savedAnswer) ? null : { answer: next })
153
+ : onLiveAnswer;
154
+ const readOnly = !onAnswer;
155
+
156
+ const save = () => {
157
+ if (!onSaveAnswer || !draft || saving) return;
158
+ const next = draft.answer && !isQuestionAnswerEmpty(draft.answer) ? draft.answer : null;
159
+ const submitted = draft;
160
+ const result = onSaveAnswer(question.key, next);
161
+ if (result && typeof (result as Promise<unknown>).then === 'function') {
162
+ setSaving(true);
163
+ (result as Promise<unknown>).then(
164
+ () => {
165
+ setSaving(false);
166
+ // An edit made while the save was in flight stays a draft.
167
+ setDraft((current) => (current === submitted ? null : current));
168
+ },
169
+ // A failed save keeps the draft; the host reports the error.
170
+ () => setSaving(false),
171
+ );
172
+ } else {
173
+ setDraft(null);
174
+ }
175
+ };
176
+ const cancel = () => {
177
+ setDraft(null);
178
+ setNotePinned(null);
179
+ };
180
+ const hostFooter = renderFooter?.(indexed, savedAnswer);
181
+ const hasHostFooter = hostFooter !== null && hostFooter !== undefined && hostFooter !== false;
182
+ const uid = useId().replace(/:/g, '');
183
+ const promptId = `q-prompt-${uid}`;
184
+ const contextId = `q-context-${uid}`;
185
+ const suggestId = `q-suggest-${uid}`;
186
+ const noteLabelId = `q-note-${uid}`;
187
+ // null = follow the answer (open while a note exists); a click pins it.
188
+ const [notePinned, setNotePinned] = useState<boolean | null>(null);
189
+ const noteOpen = notePinned ?? !!answer?.note?.trim();
190
+ const pressRef = useRef<{ x: number; y: number; touch: boolean } | null>(null);
191
+ const noteRef = useRef<HTMLTextAreaElement>(null);
192
+ const lastPickRef = useRef<{ label: string; before: QuestionAnswer | undefined; at: number } | null>(null);
193
+
194
+ const inline = (text: string) => (
195
+ <InlineMarkdown
196
+ text={text}
197
+ imageBaseDir={imageBaseDir}
198
+ onImageClick={onImageClick}
199
+ onOpenLinkedDoc={onOpenLinkedDoc}
200
+ onOpenCodeFile={onOpenCodeFile}
201
+ githubRepo={githubRepo}
202
+ repoHost={repoHost}
203
+ onNavigateAnchor={onNavigateAnchor}
204
+ />
205
+ );
206
+
207
+ // A note alone does not change a settled choice: the [x] stays drawn (and
208
+ // the status stays Settled) until the reviewer picks, fills Other or skips.
209
+ const status = questionStatus(question, answer);
210
+ const answered = status === 'answered';
211
+ const skipped = status === 'skipped';
212
+ const settled = status === 'settled';
213
+ const settledLabels = question.choices.filter((c) => c.settled).map((c) => c.label);
214
+ const hasRecommendation = question.choices.some((c) => c.recommended) || !!question.suggestedText;
215
+ const selected = answered || skipped ? answer!.selected : settledLabels;
216
+ const base = answer ?? emptyQuestionAnswer(indexed);
217
+
218
+ const commit = (next: QuestionAnswer) => {
219
+ if (!onAnswer) return;
220
+ onAnswer(blockId, isQuestionAnswerEmpty(next) ? null : next, question.key);
221
+ };
222
+
223
+ const pick = (label: string, checked: boolean) => {
224
+ lastPickRef.current = { label, before: answer, at: now() };
225
+ const { skipped: _s, ...rest } = base;
226
+ if (question.kind === 'multi') {
227
+ const nextSelected = checked
228
+ ? [...selected.filter((l) => l !== label), label]
229
+ : selected.filter((l) => l !== label);
230
+ // Keep document order, not click order.
231
+ const order = question.choices.map((c) => c.label);
232
+ nextSelected.sort((a, b) => order.indexOf(a) - order.indexOf(b));
233
+ commit({ ...rest, selected: nextSelected });
234
+ } else if (checked) {
235
+ const { other: _o, ...single } = rest;
236
+ commit({ ...single, selected: [label] });
237
+ }
238
+ };
239
+
240
+ const setOther = (value: string) => {
241
+ const { skipped: _s, other: _o, ...rest } = base;
242
+ const keepSelected = question.kind === 'multi' ? selected : value.trim() ? [] : rest.selected;
243
+ commit({ ...rest, selected: keepSelected, ...(value ? { other: value } : {}) });
244
+ };
245
+
246
+ const setText = (value: string) => {
247
+ const { skipped: _s, text: _t, ...rest } = base;
248
+ commit({ ...rest, ...(value ? { text: value } : {}) });
249
+ };
250
+
251
+ const setNote = (value: string) => {
252
+ const { note: _n, ...rest } = base;
253
+ commit({ ...rest, ...(value ? { note: value } : {}) });
254
+ };
255
+
256
+ const toggleSkip = () => {
257
+ if (base.skipped) {
258
+ const { skipped: _s, ...rest } = base;
259
+ commit(rest);
260
+ return;
261
+ }
262
+ const { other: _o, text: _t, ...rest } = base;
263
+ commit({ ...rest, selected: [], skipped: true });
264
+ };
265
+
266
+ const acceptRecommended = () => {
267
+ const next = recommendedQuestionAnswer(indexed, base);
268
+ if (next) commit(next);
269
+ };
270
+
271
+ const toggleNote = () => {
272
+ const opening = !noteOpen;
273
+ setNotePinned(opening);
274
+ if (opening) requestAnimationFrame(() => noteRef.current?.focus());
275
+ };
276
+
277
+ // Drag-vs-pick guard. A native <label> forwards its click to the input, so
278
+ // cancelling the click is what keeps a text drag from also picking. It runs
279
+ // in the capture phase so the cancel lands before any label activation.
280
+ const onRowPointerDown = (event: React.PointerEvent) => {
281
+ pressRef.current = { x: event.clientX, y: event.clientY, touch: event.pointerType === 'touch' };
282
+ };
283
+ const onRowClick = (event: React.MouseEvent, label: string) => {
284
+ const press = pressRef.current;
285
+ pressRef.current = null;
286
+ if (event.target instanceof HTMLInputElement) return;
287
+ // A double-click selects a word to annotate, never picks: the second
288
+ // click is cancelled, and the pick the first click made (if any, on this
289
+ // choice, just now) is taken back.
290
+ if (event.detail >= 2) {
291
+ event.preventDefault();
292
+ const last = lastPickRef.current;
293
+ lastPickRef.current = null;
294
+ if (onAnswer && last && last.label === label && now() - last.at <= DOUBLE_CLICK_REVERT_MS) {
295
+ const before = last.before;
296
+ onAnswer(blockId, before && !isQuestionAnswerEmpty(before) ? before : null, question.key);
297
+ }
298
+ return;
299
+ }
300
+ const limit = press?.touch ? DRAG_THRESHOLD_TOUCH : DRAG_THRESHOLD_MOUSE;
301
+ const moved = !!press && Math.hypot(event.clientX - press.x, event.clientY - press.y) > limit;
302
+ const selection = typeof window !== 'undefined' ? window.getSelection() : null;
303
+ const selecting = !!selection && !selection.isCollapsed && selection.toString().trim() !== '';
304
+ if (moved || selecting) event.preventDefault();
305
+ };
306
+
307
+ const eyebrow = number > 0
308
+ ? `Question ${number} of ${total}${question.kind === 'multi' ? ' · pick any' : ''}`
309
+ : `Question${question.kind === 'multi' ? ' · pick any' : ''}`;
310
+ const groupName = `question-${uid}`;
311
+ const inputType = question.kind === 'multi' ? 'checkbox' : 'radio';
312
+ const other = answer?.other ?? '';
313
+ const otherOn = other.trim() !== '';
314
+
315
+ const describedBy = [question.context ? contextId : null, question.kind === 'text' && question.suggestedText && !answered ? suggestId : null]
316
+ .filter(Boolean)
317
+ .join(' ') || undefined;
318
+
319
+ return (
320
+ <fieldset
321
+ className={cx(
322
+ 'question-block my-5 min-w-0 rounded-[10px] border px-3.5 pb-2.5 pt-3',
323
+ // Native radios/checkboxes follow the theme's mode. ThemeProvider marks
324
+ // light mode with `.light` on the root; a host without theme classes
325
+ // keeps the page's own color-scheme.
326
+ '[:root[class*=theme-]:not(.light)_&]:[color-scheme:dark]',
327
+ 'bg-[color-mix(in_oklab,var(--background)_35%,var(--card))]',
328
+ answered ? 'border-primary/45' : 'border-border/85',
329
+ )}
330
+ data-block-id={blockId}
331
+ data-block-type="directive"
332
+ data-directive-kind={question.directiveKind}
333
+ data-question-key={question.key}
334
+ data-question-status={status}
335
+ aria-labelledby={promptId}
336
+ aria-describedby={describedBy}
337
+ aria-disabled={readOnly || undefined}
338
+ data-question-decision={question.decision ? 'recorded' : question.decisionOnAnswer ? 'on-answer' : undefined}
339
+ >
340
+ <div className="annotation-exclude select-none mb-1.5 flex items-center gap-2" data-pinpoint-ignore="">
341
+ <span className="text-[10.5px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{eyebrow}</span>
342
+ <span className="ml-auto">
343
+ {question.decision && <><Tag tone="rec"><DiamondGlyph />Decision</Tag>{' '}</>}
344
+ {!question.decision && question.decisionOnAnswer && <><Tag tone="rec"><DiamondGlyph />Records a decision</Tag>{' '}</>}
345
+ {status === 'answered' && <Tag tone="ok"><CheckGlyph />Answered</Tag>}
346
+ {status === 'settled' && <Tag tone="ok"><CheckGlyph />Settled</Tag>}
347
+ {status === 'skipped' && <Tag tone="skip">Skipped</Tag>}
348
+ {status === 'open' && <Tag tone="open">Open</Tag>}
349
+ </span>
350
+ </div>
351
+
352
+ <p id={promptId} className="m-0 text-[15px] font-semibold leading-[1.45] text-foreground">
353
+ {inline(question.prompt)}
354
+ </p>
355
+ {(question.decision || question.decisionOnAnswer) && (
356
+ <div
357
+ className="annotation-exclude mt-2 mb-0.5 flex items-center gap-2 rounded-lg bg-primary/8 px-[9px] py-1.5 text-[12.5px] leading-[18px] text-foreground [&>svg]:text-primary"
358
+ data-pinpoint-ignore=""
359
+ data-question-decision-row=""
360
+ >
361
+ <DiamondGlyph />
362
+ {question.decision ? (
363
+ <span className="min-w-0">
364
+ <span className="select-none text-muted-foreground">Decision: </span>
365
+ <a
366
+ href={question.decision.url}
367
+ target="_blank"
368
+ rel="noopener noreferrer"
369
+ className="text-primary underline underline-offset-2 hover:text-primary/80"
370
+ >
371
+ {/* Plain text: inline markdown here could nest an autolink in the link. */}
372
+ {question.decision.statement}
373
+ </a>
374
+ </span>
375
+ ) : (
376
+ <span className="select-none">Answering this records a decision</span>
377
+ )}
378
+ </div>
379
+ )}
380
+ {question.context && (
381
+ <div id={contextId} className="mt-1 text-[13px] leading-normal text-muted-foreground">
382
+ {renderProseBody({
383
+ body: question.context,
384
+ paragraphClassName: 'text-[13px] leading-normal',
385
+ listClassName: 'text-[13px] leading-normal',
386
+ imageBaseDir,
387
+ onImageClick,
388
+ onOpenLinkedDoc,
389
+ onOpenCodeFile,
390
+ onNavigateAnchor,
391
+ githubRepo,
392
+ repoHost,
393
+ })}
394
+ </div>
395
+ )}
396
+
397
+ {question.kind === 'text' ? (
398
+ <>
399
+ {question.suggestedText && !answered && (
400
+ <div id={suggestId} className="mt-2 flex items-start gap-2 text-[12.5px] text-muted-foreground">
401
+ <span className="min-w-0 flex-1">
402
+ <Tag tone="rec">Suggested</Tag> {inline(question.suggestedText)}
403
+ </span>
404
+ {!readOnly && (
405
+ <button
406
+ type="button"
407
+ onClick={acceptRecommended}
408
+ 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"
409
+ >
410
+ Use
411
+ </button>
412
+ )}
413
+ </div>
414
+ )}
415
+ <textarea
416
+ className={cx(
417
+ // annotation-exclude: typed text is not document text, so a
418
+ // text-search restore of another annotation never lands in it.
419
+ 'annotation-exclude mt-2.5 block min-h-[74px] w-full resize-y rounded-[7px] border border-border bg-background px-2.5 py-2 text-[13.5px] leading-normal text-foreground outline-none placeholder:text-muted-foreground/70 focus:border-ring focus:ring-1 focus:ring-ring disabled:cursor-default',
420
+ skipped && 'opacity-55',
421
+ )}
422
+ aria-labelledby={promptId}
423
+ placeholder={readOnly ? '' : 'Type your answer…'}
424
+ value={answer?.text ?? ''}
425
+ onChange={(e) => setText(e.target.value)}
426
+ disabled={readOnly}
427
+ />
428
+ </>
429
+ ) : (
430
+ <div
431
+ className={cx('mt-2.5 grid gap-0.5', skipped && 'opacity-55')}
432
+ role={inputType === 'radio' ? 'radiogroup' : 'group'}
433
+ aria-labelledby={promptId}
434
+ >
435
+ {question.choices.map((choice) => {
436
+ const checked = selected.includes(choice.label);
437
+ return (
438
+ <label
439
+ key={choice.label}
440
+ className={cx(
441
+ 'flex items-start gap-2.5 rounded-[7px] px-[9px] py-1.5 transition-colors',
442
+ readOnly ? 'cursor-default' : 'cursor-pointer hover:bg-muted/45',
443
+ checked && 'bg-primary/12 hover:bg-primary/12',
444
+ )}
445
+ data-question-option=""
446
+ onPointerDown={onRowPointerDown}
447
+ onClickCapture={(event) => onRowClick(event, choice.label)}
448
+ >
449
+ <input
450
+ type={inputType}
451
+ name={groupName}
452
+ checked={checked}
453
+ onChange={(e) => pick(choice.label, e.target.checked)}
454
+ className="mt-[3px] h-[15px] w-[15px] shrink-0 cursor-pointer accent-primary disabled:cursor-default"
455
+ disabled={readOnly}
456
+ />
457
+ <span className="min-w-0 flex-1 select-text text-[13.5px] leading-[1.45] text-foreground">
458
+ <span className="font-[550]">{inline(choice.label)}</span>
459
+ {choice.description && (
460
+ <span className="text-muted-foreground"> — {inline(choice.description)}</span>
461
+ )}
462
+ {choice.recommended && <> <Tag tone="rec">Recommended</Tag></>}
463
+ </span>
464
+ </label>
465
+ );
466
+ })}
467
+ <div className="flex items-center gap-2.5 rounded-[7px] px-[9px] py-[5px]">
468
+ <span
469
+ aria-hidden="true"
470
+ className={cx(
471
+ 'annotation-exclude select-none grid h-[15px] w-[15px] shrink-0 place-items-center border-[1.5px] text-[11px] leading-none',
472
+ question.kind === 'multi' ? 'rounded-[3px]' : 'rounded-full',
473
+ otherOn ? 'border-solid border-primary bg-primary text-primary-foreground' : 'border-dashed border-border text-muted-foreground',
474
+ )}
475
+ >
476
+ {otherOn ? <CheckGlyph /> : '+'}
477
+ </span>
478
+ <input
479
+ type="text"
480
+ value={other}
481
+ onChange={(e) => setOther(e.target.value)}
482
+ disabled={readOnly}
483
+ placeholder={readOnly ? '' : 'Other…'}
484
+ aria-label={number > 0 ? `Other answer to question ${number}` : 'Other answer'}
485
+ className="annotation-exclude min-w-0 flex-1 border-0 border-b border-transparent bg-transparent py-[3px] text-[13.5px] text-foreground outline-none placeholder:text-muted-foreground/70 focus:border-ring"
486
+ />
487
+ </div>
488
+ </div>
489
+ )}
490
+
491
+ {noteOpen && (!readOnly || !!answer?.note?.trim()) && (
492
+ <div className="mt-2">
493
+ <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">
494
+ Note
495
+ </p>
496
+ <textarea
497
+ ref={noteRef}
498
+ className="annotation-exclude block min-h-[54px] w-full resize-y rounded-[7px] border border-border bg-background px-2.5 py-2 text-[13.5px] leading-normal text-foreground outline-none placeholder:text-muted-foreground/70 focus:border-ring focus:ring-1 focus:ring-ring"
499
+ aria-labelledby={noteLabelId}
500
+ placeholder="Context for the agent…"
501
+ value={answer?.note ?? ''}
502
+ onChange={(e) => setNote(e.target.value)}
503
+ disabled={readOnly}
504
+ />
505
+ </div>
506
+ )}
507
+
508
+ {(!readOnly || hasHostFooter) && (
509
+ <div
510
+ className="annotation-exclude select-none mt-2 flex items-center gap-1 border-t border-border/55 pt-1.5"
511
+ data-pinpoint-ignore=""
512
+ >
513
+ {!readOnly && (
514
+ <button
515
+ type="button"
516
+ onClick={toggleNote}
517
+ aria-expanded={noteOpen}
518
+ className={cx(
519
+ 'rounded-[5px] px-[7px] py-[3px] text-xs hover:bg-muted hover:text-foreground',
520
+ noteOpen ? 'text-foreground' : 'text-muted-foreground',
521
+ )}
522
+ >
523
+ {noteOpen ? 'Hide note' : 'Add note'}
524
+ </button>
525
+ )}
526
+ {/* Skip is a live-mode draft state; an explicit save never writes it. */}
527
+ {!readOnly && !saveMode && (
528
+ <button
529
+ type="button"
530
+ onClick={toggleSkip}
531
+ aria-pressed={skipped}
532
+ className={cx(
533
+ 'rounded-[5px] px-[7px] py-[3px] text-xs hover:bg-muted hover:text-foreground',
534
+ skipped ? 'text-foreground' : 'text-muted-foreground',
535
+ )}
536
+ >
537
+ {skipped ? 'Unskip' : 'Skip'}
538
+ </button>
539
+ )}
540
+ {/* Plannotator's own footer (no host actions, no save bar) keeps the
541
+ Accept button as the one right-aligned item. */}
542
+ {!hasHostFooter && !dirty ? (
543
+ !readOnly && !answered && !settled && hasRecommendation && (
544
+ <button
545
+ type="button"
546
+ onClick={acceptRecommended}
547
+ className="ml-auto rounded-md border border-primary/45 bg-primary/10 px-2.5 py-[3px] text-[12.5px] font-medium text-primary hover:bg-primary/20 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
548
+ >
549
+ Accept recommended
550
+ </button>
551
+ )
552
+ ) : (
553
+ <div className="ml-auto flex items-center gap-1">
554
+ {!readOnly && !answered && !settled && hasRecommendation && (
555
+ <button
556
+ type="button"
557
+ onClick={acceptRecommended}
558
+ className="rounded-md border border-primary/45 bg-primary/10 px-2.5 py-[3px] text-[12.5px] font-medium text-primary hover:bg-primary/20 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
559
+ >
560
+ Accept recommended
561
+ </button>
562
+ )}
563
+ {hasHostFooter && <span className="inline-flex items-center gap-1" data-question-host-footer="">{hostFooter}</span>}
564
+ {dirty && (
565
+ <>
566
+ <button
567
+ type="button"
568
+ onClick={cancel}
569
+ disabled={saving}
570
+ className="rounded-[5px] px-[7px] py-[3px] text-xs text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-60"
571
+ >
572
+ Cancel
573
+ </button>
574
+ <button
575
+ type="button"
576
+ onClick={save}
577
+ disabled={saving}
578
+ aria-busy={saving || undefined}
579
+ className="rounded-md bg-primary px-2.5 py-[3px] text-[12.5px] font-medium text-primary-foreground hover:bg-primary/90 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:opacity-60"
580
+ >
581
+ {(typeof saveLabel === 'function' ? saveLabel(question) : saveLabel) || 'Save answer'}
582
+ </button>
583
+ </>
584
+ )}
585
+ </div>
586
+ )}
587
+ </div>
588
+ )}
589
+ </fieldset>
590
+ );
591
+ };
@@ -599,6 +599,23 @@ export const SETTINGS = {
599
599
  },
600
600
  toServer: (value: boolean) => ({ reviewAnalysis: { callFlow: value } }),
601
601
  },
602
+ /**
603
+ * Opt-in background auto-update of the compiled CLI (#1634). The server
604
+ * always sends the config-file value (default false), so the cookie is only
605
+ * a mirror and can never turn this on by itself.
606
+ */
607
+ autoUpdate: {
608
+ defaultValue: false as boolean,
609
+ fromCookie: () => {
610
+ const v = storage.getItem('plannotator-auto-update');
611
+ return v === 'true' ? true : v === 'false' ? false : undefined;
612
+ },
613
+ toCookie: (v: boolean) => storage.setItem('plannotator-auto-update', String(v)),
614
+ serverKey: 'autoUpdate',
615
+ fromServer: (sc: Record<string, unknown>) =>
616
+ typeof sc.autoUpdate === 'boolean' ? sc.autoUpdate : undefined,
617
+ toServer: (v: boolean) => ({ autoUpdate: v }),
618
+ },
602
619
  conventionalComments: {
603
620
  defaultValue: false as boolean,
604
621
  fromCookie: () => {
@@ -1105,6 +1105,10 @@ export function useAnnotationHighlighter({
1105
1105
  // verdict, so it is neither attempted nor unanchored here (the same
1106
1106
  // rule the raw-HTML pinpoints follow on their surface).
1107
1107
  if (ann.diagramAnchor) return;
1108
+ // An answer to a `:::question` block quotes the prompt but is not a
1109
+ // selection: the question block draws it from the annotation, so it is
1110
+ // never painted as a highlight (and never reported as unanchored here).
1111
+ if (ann.questionAnswer) return;
1108
1112
  // #881: a row whose quote is only whitespace has nothing to anchor to.
1109
1113
  // Older drafts carry such rows (a double-click at a block boundary used
1110
1114
  // to create one), and the stored positions still resolve, so restoring
@@ -137,6 +137,10 @@ export function usePinpoint({
137
137
 
138
138
  const handleClick = (e: MouseEvent) => {
139
139
  const target = e.target as HTMLElement;
140
+ // A `:::question` card's form controls are the answer UI, never a pin
141
+ // target: a click on a radio, a checkbox or a text box answers.
142
+ const inQuestion = !!target.closest?.('fieldset.question-block');
143
+ if (inQuestion && target.closest('input, textarea, select')) return;
140
144
  const resolved = resolveSemanticTargetAtPoint(
141
145
  buildSemanticTargetGraph(container),
142
146
  target,
@@ -144,6 +148,12 @@ export function usePinpoint({
144
148
  );
145
149
  if (!resolved) return;
146
150
 
151
+ // Pinning a choice's text must not also pick the choice: the row is a
152
+ // <label>, which would forward this click to its radio or checkbox.
153
+ if (inQuestion && target.closest('[data-question-option]')) {
154
+ e.preventDefault();
155
+ }
156
+
147
157
  // Prevent link navigation in pinpoint mode
148
158
  const link = (target.closest('a') as HTMLAnchorElement | null);
149
159
  if (link && container.contains(link)) {