@plannotator/ui 0.51.0 → 0.52.1

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.
@@ -70,12 +70,12 @@ import {
70
70
  } from '../utils/defaultNotesApp';
71
71
  import { useAgents } from '../hooks/useAgents';
72
72
  import { KeyboardShortcuts } from './KeyboardShortcuts';
73
- import { type QuickLabel, getQuickLabels, saveQuickLabels, resetQuickLabels, DEFAULT_QUICK_LABELS, getLabelColors, LABEL_COLOR_MAP } from '../utils/quickLabels';
74
73
  import { ThemeTab } from './ThemeTab';
75
- import { isMac, modKey, modKeyWord, altKey } from '../utils/platform';
74
+ import { modKey, modKeyWord } from '../utils/platform';
76
75
  import { getAIProviderSettings, resolveAIProviderSelection } from '../utils/aiProvider';
77
76
  import { AISettingsTab } from './AISettingsTab';
78
77
  import { HooksTab } from './settings/HooksTab';
78
+ import { QuickLabelsTab } from './settings/QuickLabelsTab';
79
79
  import { OverlayScrollArea } from './OverlayScrollArea';
80
80
  import {
81
81
  getFileBrowserSettings,
@@ -1063,9 +1063,6 @@ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange
1063
1063
  const [agentWarning, setAgentWarning] = useState<string | null>(null);
1064
1064
  const [autoCloseDelay, setAutoCloseDelayState] = useState<AutoCloseDelay>('off');
1065
1065
  const [defaultNotesApp, setDefaultNotesApp] = useState<DefaultNotesApp>('ask');
1066
- const [quickLabelsState, setQuickLabelsState] = useState<QuickLabel[]>([]);
1067
- const [editingTipIndex, setEditingTipIndex] = useState<number | null>(null);
1068
- const [editingTipValue, setEditingTipValue] = useState('');
1069
1066
  const [aiProvider, setAiProvider] = useState<string | null>(null);
1070
1067
  const [fileBrowserSettings, setFileBrowserSettings] = useState<FileBrowserSettings>({ enabled: false, directories: [] });
1071
1068
  const [newDirPath, setNewDirPath] = useState('');
@@ -1134,7 +1131,6 @@ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange
1134
1131
  setPermissionMode(getPermissionModeSettings().mode);
1135
1132
  setAutoCloseDelayState(getAutoCloseDelay());
1136
1133
  setDefaultNotesApp(getDefaultNotesApp());
1137
- setQuickLabelsState(getQuickLabels());
1138
1134
  const aiSettings = getAIProviderSettings();
1139
1135
  setAiProvider(resolveAIProviderSelection({ providers: aiProviders, origin, settings: aiSettings }).providerId);
1140
1136
  setFileBrowserSettings(getFileBrowserSettings());
@@ -2054,191 +2050,7 @@ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange
2054
2050
  )}
2055
2051
 
2056
2052
  {/* === LABELS TAB === */}
2057
- {activeTab === 'labels' && (
2058
- <>
2059
- <div className="flex items-center justify-between">
2060
- <div>
2061
- <div className="text-sm font-medium flex items-center gap-2">Quick Labels</div>
2062
- <div className="text-xs text-muted-foreground">
2063
- Preset annotations for one-click feedback
2064
- </div>
2065
- </div>
2066
- <button
2067
- onClick={() => {
2068
- resetQuickLabels();
2069
- setQuickLabelsState(DEFAULT_QUICK_LABELS);
2070
- }}
2071
- className="text-[10px] text-muted-foreground hover:text-foreground transition-colors"
2072
- >
2073
- Reset to defaults
2074
- </button>
2075
- </div>
2076
-
2077
- <style>{`
2078
- @keyframes tip-slide-open {
2079
- from { opacity: 0; transform: translateY(-4px); }
2080
- to { opacity: 1; transform: translateY(0); }
2081
- }
2082
- `}</style>
2083
- <div className="space-y-1.5">
2084
- {quickLabelsState.map((label, index) => {
2085
- const colors = getLabelColors(label.color);
2086
- const hasTip = !!label.tip;
2087
- const isEditingTip = editingTipIndex === index;
2088
- return (
2089
- <div key={index} className="rounded-lg overflow-hidden" style={{ backgroundColor: colors.bg }}>
2090
- {/* Main row */}
2091
- <div className="flex items-center gap-2 p-2">
2092
- <span className="text-sm flex-shrink-0">{label.emoji}</span>
2093
- <input
2094
- type="text"
2095
- value={label.text}
2096
- onChange={(e) => {
2097
- const updated = [...quickLabelsState];
2098
- updated[index] = {
2099
- ...label,
2100
- text: e.target.value,
2101
- id: e.target.value.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, ''),
2102
- };
2103
- setQuickLabelsState(updated);
2104
- saveQuickLabels(updated);
2105
- }}
2106
- className="flex-1 px-2 py-1 bg-background/80 rounded text-xs focus:outline-none focus:ring-1 focus:ring-primary/50"
2107
- />
2108
- {/* Tip indicator button */}
2109
- <button
2110
- onClick={() => {
2111
- if (isEditingTip) {
2112
- setEditingTipIndex(null);
2113
- } else {
2114
- setEditingTipIndex(index);
2115
- setEditingTipValue(label.tip || '');
2116
- }
2117
- }}
2118
- className={`relative p-1 rounded transition-all flex-shrink-0 ${
2119
- hasTip
2120
- ? 'bg-foreground/10 text-foreground/70 hover:text-foreground border border-foreground/15'
2121
- : 'text-muted-foreground/30 hover:text-muted-foreground/60 border border-dashed border-muted-foreground/20 hover:border-muted-foreground/40'
2122
- }`}
2123
- title={hasTip ? `Tip: ${label.tip}` : 'Add AI instruction tip'}
2124
- >
2125
- <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
2126
- <path strokeLinecap="round" strokeLinejoin="round" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
2127
- </svg>
2128
- {hasTip && (
2129
- <span className="absolute -top-0.5 -right-0.5 w-1.5 h-1.5 rounded-full bg-foreground/50" />
2130
- )}
2131
- </button>
2132
- <select
2133
- value={label.color}
2134
- onChange={(e) => {
2135
- const updated = [...quickLabelsState];
2136
- updated[index] = { ...label, color: e.target.value };
2137
- setQuickLabelsState(updated);
2138
- saveQuickLabels(updated);
2139
- }}
2140
- className="px-1.5 py-1 bg-background/80 rounded text-[10px] focus:outline-none focus:ring-1 focus:ring-primary/50"
2141
- >
2142
- {Object.keys(LABEL_COLOR_MAP).map(c => (
2143
- <option key={c} value={c}>{c}</option>
2144
- ))}
2145
- </select>
2146
- <span className="text-[10px] text-muted-foreground/50 font-mono w-8 text-center flex-shrink-0">
2147
- {index < 10 ? `${altKey}${isMac ? '' : '+'}${index === 9 ? '0' : index + 1}` : ''}
2148
- </span>
2149
- <button
2150
- onClick={() => {
2151
- const updated = quickLabelsState.filter((_, i) => i !== index);
2152
- setQuickLabelsState(updated);
2153
- saveQuickLabels(updated);
2154
- if (editingTipIndex === index) setEditingTipIndex(null);
2155
- }}
2156
- className="p-1 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors flex-shrink-0"
2157
- title="Remove label"
2158
- >
2159
- <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
2160
- <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
2161
- </svg>
2162
- </button>
2163
- </div>
2164
- {/* Tip editor — slides open below the row */}
2165
- {isEditingTip && (
2166
- <div
2167
- className="flex items-center gap-1.5 px-2 pb-2 pt-0"
2168
- style={{ animation: 'tip-slide-open 0.15s ease-out' }}
2169
- >
2170
- <svg className="w-3 h-3 text-muted-foreground/40 flex-shrink-0 ml-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
2171
- <path strokeLinecap="round" strokeLinejoin="round" d="M17 8l4 4m0 0l-4 4m4-4H3" />
2172
- </svg>
2173
- <input
2174
- type="text"
2175
- value={editingTipValue}
2176
- onChange={(e) => setEditingTipValue(e.target.value)}
2177
- onKeyDown={(e) => {
2178
- if (e.key === 'Enter') {
2179
- const updated = [...quickLabelsState];
2180
- updated[index] = { ...label, tip: editingTipValue || undefined };
2181
- setQuickLabelsState(updated);
2182
- saveQuickLabels(updated);
2183
- setEditingTipIndex(null);
2184
- }
2185
- if (e.key === 'Escape') {
2186
- e.preventDefault();
2187
- e.stopPropagation();
2188
- setEditingTipIndex(null);
2189
- }
2190
- }}
2191
- placeholder="AI instruction tip..."
2192
- className="flex-1 px-2 py-1 bg-background/60 rounded text-[10px] text-muted-foreground placeholder:text-muted-foreground/30 focus:outline-none focus:ring-1 focus:ring-primary/50"
2193
- autoFocus
2194
- onFocus={(e) => { e.target.setSelectionRange(0, 0); e.target.scrollLeft = 0; }}
2195
- />
2196
- <button
2197
- onClick={() => {
2198
- const updated = [...quickLabelsState];
2199
- updated[index] = { ...label, tip: editingTipValue || undefined };
2200
- setQuickLabelsState(updated);
2201
- saveQuickLabels(updated);
2202
- setEditingTipIndex(null);
2203
- }}
2204
- className="p-1 rounded text-muted-foreground/50 hover:text-green-500 hover:bg-green-500/10 transition-colors flex-shrink-0"
2205
- title="Save tip"
2206
- >
2207
- <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
2208
- <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
2209
- </svg>
2210
- </button>
2211
- </div>
2212
- )}
2213
- </div>
2214
- );
2215
- })}
2216
- </div>
2217
-
2218
- {quickLabelsState.length < 12 && (
2219
- <button
2220
- onClick={() => {
2221
- const newLabel: QuickLabel = {
2222
- id: `custom-${Date.now()}`,
2223
- emoji: '📌',
2224
- text: 'New label',
2225
- color: 'blue',
2226
- };
2227
- const updated = [...quickLabelsState, newLabel];
2228
- setQuickLabelsState(updated);
2229
- saveQuickLabels(updated);
2230
- }}
2231
- className="w-full py-1.5 text-xs text-muted-foreground hover:text-foreground border border-dashed border-border rounded-lg hover:border-foreground/30 transition-colors"
2232
- >
2233
- + Add label
2234
- </button>
2235
- )}
2236
-
2237
- <div className="text-[10px] text-muted-foreground/70">
2238
- Use {altKey}{isMac ? '' : '+'}1 through {altKey}{isMac ? '' : '+'}0 when the annotation toolbar is visible to apply a label instantly.
2239
- </div>
2240
- </>
2241
- )}
2053
+ {activeTab === 'labels' && <QuickLabelsTab />}
2242
2054
 
2243
2055
  {/* === VIM TAB === */}
2244
2056
  {activeTab === 'vim' && mode !== 'review' && (
@@ -6,7 +6,7 @@ import { AnnotationType, type Block, type Annotation, type EditorMode, type Inpu
6
6
  import { applyHighlight, codeBlockClassName, onCodeHighlightSwap } from '../utils/codeHighlight';
7
7
  import { paintCodeBlockMark } from '../utils/codeBlockMark';
8
8
  import { useFenceTheme } from '../hooks/useFenceTheme';
9
- import { computeListIndices, groupBlocks, type Frontmatter, type FrontmatterValue } from '../utils/parser';
9
+ import { computeListIndices, groupBlocks, selectionSourceLines, type Frontmatter, type FrontmatterValue } from '../utils/parser';
10
10
  import { buildHeadingSlugMap } from '../utils/slugify';
11
11
  import { copyTextToClipboard } from '../utils/clipboard';
12
12
  import { BlockRenderer } from './BlockRenderer';
@@ -218,6 +218,35 @@ export interface ViewerProps {
218
218
  * decision"), given the indexed question and its saved answer. Rendered in
219
219
  * read-only cards too; return null for none. */
220
220
  renderQuestionFooter?: (question: IndexedQuestion, answer: QuestionAnswer | undefined) => React.ReactNode;
221
+ /** Whether answering a question records a decision, per question. On a
222
+ * `Decision: when answered` question: false draws its "Records a
223
+ * decision" tag dimmed with a dotted outline and hides the "Answering this
224
+ * records a decision" row; true or undefined keeps today's card. On a
225
+ * question with no decision line it is ignored unless
226
+ * `questionDecisionScope` is `'any'`; then a boolean adds the tag in that
227
+ * state (true also shows the row) and undefined adds nothing. Has no
228
+ * effect on a question whose decision is already recorded. */
229
+ questionDecisionRecording?: (question: IndexedQuestion) => boolean | undefined;
230
+ /** Which questions can carry the "Records a decision" tag (ui 0.52.1).
231
+ * `'when-answered'` (default, the 0.52.0 behaviour): only questions with
232
+ * `Decision: when answered`. `'any'`: also a question with no decision
233
+ * line, when `questionDecisionRecording` returns a boolean for it. */
234
+ questionDecisionScope?: 'when-answered' | 'any';
235
+ /** Makes a question's "Records a decision" tag a toggle button
236
+ * (`aria-pressed`); a click calls this with the question's key and the
237
+ * next state, which the host stores and returns from
238
+ * `questionDecisionRecording`. Ignored when `readOnly`. */
239
+ onToggleQuestionDecisionRecording?: (key: string, next: boolean) => void;
240
+ /** Opens the host's decision card (ui 0.52.1): the tag splits into a
241
+ * diamond switch (the toggle above) and the words "Records a decision" as
242
+ * a button that calls this with the question's key and the words' element
243
+ * (anchor your popover to it). Offered on and off. Ignored when
244
+ * `readOnly`. */
245
+ onOpenQuestionDecision?: (key: string, anchor: HTMLElement) => void;
246
+ /** `'none'` hides each question card's own status tag (Open / Answered /
247
+ * Settled / Skipped) so the host can draw its own; the decision tags stay.
248
+ * Default `'card'`. */
249
+ questionStatusTag?: 'card' | 'none';
221
250
  onAskAI?: CommentAskAIHandler;
222
251
  /** Whether comment popovers offer image attachments. Hosts without an
223
252
  * uploadTransport pass false so the attach affordance never dead-ends.
@@ -590,6 +619,11 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
590
619
  onSaveQuestionAnswer,
591
620
  saveQuestionAnswerLabel,
592
621
  renderQuestionFooter,
622
+ questionDecisionRecording,
623
+ questionDecisionScope,
624
+ onToggleQuestionDecisionRecording,
625
+ onOpenQuestionDecision,
626
+ questionStatusTag,
593
627
  onAskAI,
594
628
  allowImages = true,
595
629
  readOnly = false,
@@ -1551,6 +1585,11 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1551
1585
  onSaveQuestionAnswer={readOnly ? undefined : onSaveQuestionAnswer}
1552
1586
  saveQuestionAnswerLabel={saveQuestionAnswerLabel}
1553
1587
  renderQuestionFooter={renderQuestionFooter}
1588
+ questionDecisionRecording={question ? questionDecisionRecording?.(question) : undefined}
1589
+ questionDecisionScope={questionDecisionScope}
1590
+ onToggleQuestionDecisionRecording={readOnly ? undefined : onToggleQuestionDecisionRecording}
1591
+ onOpenQuestionDecision={readOnly ? undefined : onOpenQuestionDecision}
1592
+ questionStatusTag={questionStatusTag}
1554
1593
  />
1555
1594
  );
1556
1595
  })()
@@ -1715,6 +1754,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1715
1754
  label: 'Selected text',
1716
1755
  text: hookCommentPopover.selectedText ?? hookCommentPopover.contextText,
1717
1756
  sourcePath: linkedDocInfo?.filepath ?? sourceInfo,
1757
+ ...selectionSourceLines(blocks, hookCommentPopover.blockIds ?? []),
1718
1758
  }}
1719
1759
  />
1720
1760
  )}
@@ -1740,6 +1780,9 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1740
1780
  label: viewerCommentPopover.isGlobal ? 'Document' : 'Code block',
1741
1781
  text: viewerCommentPopover.selectedText,
1742
1782
  sourcePath: linkedDocInfo?.filepath ?? sourceInfo,
1783
+ ...(!viewerCommentPopover.isGlobal && viewerCommentPopover.codeBlock
1784
+ ? selectionSourceLines(blocks, [viewerCommentPopover.codeBlock.block.id])
1785
+ : null),
1743
1786
  }}
1744
1787
  />
1745
1788
  )}
@@ -36,7 +36,13 @@ import { QuestionContext } from './QuestionContext';
36
36
  *
37
37
  * A host can add its own actions at the right of the footer
38
38
  * (`renderFooter`), and a question carrying `Decision: when answered` or a
39
- * `Decision: [statement](url)` link shows that beside the prompt.
39
+ * `Decision: [statement](url)` link shows that beside the prompt. A host can
40
+ * also let the reviewer switch a question's decision recording off and on
41
+ * (`decisionRecording` + `onToggleDecisionRecording`; with
42
+ * `decisionScope: 'any'`, any question without a recorded decision once the
43
+ * host supplies a state), open its own decision
44
+ * card from the tag's words (`onOpenDecision`), and hide the card's own
45
+ * status tag to draw its own (`statusTag: 'none'`).
40
46
  */
41
47
 
42
48
  /** Pointer travel (px) above which a press on a choice row is a drag. */
@@ -50,7 +56,7 @@ const now = (): number => (typeof performance !== 'undefined' ? performance.now(
50
56
 
51
57
  const cx = (...parts: Array<string | false | null | undefined>) => parts.filter(Boolean).join(' ');
52
58
 
53
- const Tag: React.FC<{ tone: 'rec' | 'ok' | 'skip' | 'open'; children: React.ReactNode; className?: string }> = ({ tone, children, className }) => (
59
+ const Tag: React.FC<{ tone: 'rec' | 'ok' | 'skip' | 'open'; children: React.ReactNode; className?: string; style?: React.CSSProperties }> = ({ tone, children, className, style }) => (
54
60
  <span
55
61
  className={cx(
56
62
  '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]',
@@ -60,6 +66,7 @@ const Tag: React.FC<{ tone: 'rec' | 'ok' | 'skip' | 'open'; children: React.Reac
60
66
  tone === 'open' && 'border border-dashed border-border text-muted-foreground',
61
67
  className,
62
68
  )}
69
+ style={style}
63
70
  data-pinpoint-ignore=""
64
71
  >
65
72
  {children}
@@ -78,6 +85,19 @@ const DiamondGlyph = () => (
78
85
  </svg>
79
86
  );
80
87
 
88
+ /** Draws a decision tag in its "off" state: the same words, dimmed, with a
89
+ * dotted outline instead of the tinted fill. Dimmed means the theme's
90
+ * muted-foreground, not opacity: 60% opacity on primary text dropped the
91
+ * default light theme under 3:1, while muted-foreground is the token every
92
+ * theme already tunes for secondary text. Inline so it adds no utility
93
+ * classes to the shared stylesheet and wins over the tag's `text-primary`. */
94
+ const DECISION_OFF_STYLE: React.CSSProperties = {
95
+ background: 'transparent',
96
+ color: 'var(--muted-foreground)',
97
+ outline: '1px dotted currentColor',
98
+ outlineOffset: '-1px',
99
+ };
100
+
81
101
  /** Two answers say the same thing (identity and line ignored). */
82
102
  const sameAnswer = (a: QuestionAnswer | null | undefined, b: QuestionAnswer | null | undefined): boolean => {
83
103
  const norm = (x: QuestionAnswer | null | undefined) =>
@@ -110,6 +130,40 @@ export interface QuestionBlockProps {
110
130
  * saved answer (never the unsaved draft). Rendered in read-only cards too;
111
131
  * return null for nothing. */
112
132
  renderFooter?: (question: IndexedQuestion, answer: QuestionAnswer | undefined) => React.ReactNode;
133
+ /** Whether answering this question records a decision. On a question
134
+ * carrying `Decision: when answered` it defaults to true; false draws the
135
+ * "Records a decision" tag dimmed with a dotted outline and hides the
136
+ * "Answering this records a decision" row. On a question WITHOUT a
137
+ * decision line it is ignored unless `decisionScope` is `'any'` (ui
138
+ * 0.52.1); then a defined value adds the tag in that state (false: off;
139
+ * true: on, with the row), so a host can let any answer become a
140
+ * decision, and undefined leaves the question with no tag. Has no effect
141
+ * on a question whose decision is already recorded
142
+ * (`Decision: [statement](url)`). */
143
+ decisionRecording?: boolean;
144
+ /** Which questions can carry the "Records a decision" tag (ui 0.52.1).
145
+ * `'when-answered'` (default, the 0.52.0 behaviour): only a question that
146
+ * carries `Decision: when answered`. `'any'`: also a question with no
147
+ * decision line, once the host passes a `decisionRecording` for it. */
148
+ decisionScope?: 'when-answered' | 'any';
149
+ /** Makes the "Records a decision" tag a toggle button (`aria-pressed`):
150
+ * a click calls this with the question's key and the next state. The host
151
+ * stores it and passes it back as `decisionRecording`. Not offered in a
152
+ * read-only card (no answer handler), which draws the tag as it stands. */
153
+ onToggleDecisionRecording?: (key: string, next: boolean) => void;
154
+ /** Opens the host's decision card (ui 0.52.1). Given, the tag splits into
155
+ * two targets inside one visual tag: the diamond is the switch
156
+ * (`onToggleDecisionRecording`, labelled "Record as a decision") and the
157
+ * words "Records a decision" are a button (`aria-haspopup="dialog"`) that
158
+ * calls this with the question's key and the words' own element, to
159
+ * anchor a popover. Offered whether recording is on or off (the host
160
+ * decides what an off question's card does). Not offered in a read-only
161
+ * card. */
162
+ onOpenDecision?: (key: string, anchor: HTMLElement) => void;
163
+ /** `'none'` hides the card's own status tag (Open / Answered / Settled /
164
+ * Skipped) so the host can draw its own; the decision tags stay. Default
165
+ * `'card'`. */
166
+ statusTag?: 'card' | 'none';
113
167
  onOpenLinkedDoc?: (path: string) => void;
114
168
  onOpenCodeFile?: (path: string) => void;
115
169
  imageBaseDir?: string;
@@ -128,6 +182,11 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
128
182
  onSaveAnswer,
129
183
  saveLabel,
130
184
  renderFooter,
185
+ decisionRecording,
186
+ decisionScope = 'when-answered',
187
+ onToggleDecisionRecording,
188
+ onOpenDecision,
189
+ statusTag = 'card',
131
190
  onOpenLinkedDoc,
132
191
  onOpenCodeFile,
133
192
  imageBaseDir,
@@ -320,6 +379,21 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
320
379
  const other = answer?.other ?? '';
321
380
  const otherOn = other.trim() !== '';
322
381
 
382
+ // A `Decision: when answered` question the host may switch off, or (with
383
+ // `decisionScope: 'any'`) any other question once the host supplies a
384
+ // recording state for it. A recorded decision (`question.decision`) is
385
+ // never affected.
386
+ const decisionCapable = !question.decision
387
+ && (!!question.decisionOnAnswer || (decisionScope === 'any' && decisionRecording !== undefined));
388
+ const recordingOn = decisionRecording ?? true;
389
+ const decisionToggleable = decisionCapable && !readOnly && !!onToggleDecisionRecording;
390
+ const decisionOpenable = decisionCapable && !readOnly && !!onOpenDecision;
391
+ // Present only when the host speaks to recording, so a card without these
392
+ // props carries exactly the attributes it always did.
393
+ const recordingAttr = decisionCapable && (decisionRecording !== undefined || !!onToggleDecisionRecording)
394
+ ? (recordingOn ? 'on' : 'off')
395
+ : undefined;
396
+
323
397
  const describedBy = [question.context ? contextId : null, question.kind === 'text' && question.suggestedText && !answered ? suggestId : null]
324
398
  .filter(Boolean)
325
399
  .join(' ') || undefined;
@@ -344,23 +418,78 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
344
418
  aria-describedby={describedBy}
345
419
  aria-disabled={readOnly || undefined}
346
420
  data-question-decision={question.decision ? 'recorded' : question.decisionOnAnswer ? 'on-answer' : undefined}
421
+ data-question-decision-recording={recordingAttr}
347
422
  >
348
423
  <div className="annotation-exclude select-none mb-1.5 flex items-center gap-2" data-pinpoint-ignore="">
349
424
  <span className="text-[10.5px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{eyebrow}</span>
350
425
  <span className="ml-auto">
351
426
  {question.decision && <><Tag tone="rec"><DiamondGlyph />Decision</Tag>{' '}</>}
352
- {!question.decision && question.decisionOnAnswer && <><Tag tone="rec"><DiamondGlyph />Records a decision</Tag>{' '}</>}
353
- {status === 'answered' && <Tag tone="ok"><CheckGlyph />Answered</Tag>}
354
- {status === 'settled' && <Tag tone="ok"><CheckGlyph />Settled</Tag>}
355
- {status === 'skipped' && <Tag tone="skip">Skipped</Tag>}
356
- {status === 'open' && <Tag tone="open">Open</Tag>}
427
+ {decisionCapable && (decisionOpenable ? (
428
+ // Two targets in one tag: the diamond switches recording, the
429
+ // words open the host's decision card. Siblings, never nested.
430
+ // Target size (WCAG 2.5.8) without changing the drawn tag: the
431
+ // diamond's invisible `before:` box reaches 6 px past it on every
432
+ // side (24 px wide, about 30 px tall), and the words, positioned
433
+ // and later in the tree, paint over its right-hand part, so a
434
+ // click on the words is always the words. The words (110 px wide)
435
+ // meet the spacing exception: nothing else to click within 12 px
436
+ // of their centre line.
437
+ <><span
438
+ className="annotation-exclude select-none inline-flex items-stretch whitespace-nowrap rounded align-[1px] text-[10.5px] font-semibold tracking-[0.03em] bg-primary/15 text-primary"
439
+ style={recordingOn ? undefined : DECISION_OFF_STYLE}
440
+ data-pinpoint-ignore=""
441
+ data-question-decision-tag=""
442
+ >{decisionToggleable ? (
443
+ <button
444
+ type="button"
445
+ aria-pressed={recordingOn}
446
+ aria-label="Record as a decision"
447
+ title="Record as a decision"
448
+ onClick={() => onToggleDecisionRecording!(question.key, !recordingOn)}
449
+ className="relative inline-flex cursor-pointer items-center rounded pl-1.5 pr-0.5 before:absolute before:-inset-1.5 before:content-[''] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
450
+ data-question-decision-toggle=""
451
+ ><DiamondGlyph /></button>
452
+ ) : (
453
+ <span className="inline-flex items-center pl-1.5 pr-0.5"><DiamondGlyph /></span>
454
+ )}<button
455
+ type="button"
456
+ aria-haspopup="dialog"
457
+ onClick={(event) => onOpenDecision!(question.key, event.currentTarget)}
458
+ className="relative cursor-pointer rounded py-px pl-0.5 pr-1.5 hover:underline underline-offset-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
459
+ data-question-decision-open=""
460
+ >Records a decision</button></span>{' '}</>
461
+ ) : decisionToggleable ? (
462
+ // Focus shows as a ring, not an outline: the dotted outline is
463
+ // what draws the off state.
464
+ <><button
465
+ type="button"
466
+ aria-pressed={recordingOn}
467
+ onClick={() => onToggleDecisionRecording!(question.key, !recordingOn)}
468
+ className="annotation-exclude select-none inline-flex cursor-pointer items-center gap-1 whitespace-nowrap rounded px-1.5 py-px align-[1px] text-[10.5px] font-semibold tracking-[0.03em] bg-primary/15 text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
469
+ style={recordingOn ? undefined : DECISION_OFF_STYLE}
470
+ data-pinpoint-ignore=""
471
+ data-question-decision-toggle=""
472
+ ><DiamondGlyph />Records a decision</button>{' '}</>
473
+ ) : recordingOn ? (
474
+ <><Tag tone="rec"><DiamondGlyph />Records a decision</Tag>{' '}</>
475
+ ) : (
476
+ <><Tag tone="rec" style={DECISION_OFF_STYLE}><DiamondGlyph />Records a decision</Tag>{' '}</>
477
+ ))}
478
+ {statusTag !== 'none' && (
479
+ <>
480
+ {status === 'answered' && <Tag tone="ok"><CheckGlyph />Answered</Tag>}
481
+ {status === 'settled' && <Tag tone="ok"><CheckGlyph />Settled</Tag>}
482
+ {status === 'skipped' && <Tag tone="skip">Skipped</Tag>}
483
+ {status === 'open' && <Tag tone="open">Open</Tag>}
484
+ </>
485
+ )}
357
486
  </span>
358
487
  </div>
359
488
 
360
489
  <p id={promptId} className="m-0 text-[15px] font-semibold leading-[1.45] text-foreground" data-question-part="prompt">
361
490
  {inline(question.prompt)}
362
491
  </p>
363
- {(question.decision || question.decisionOnAnswer) && (
492
+ {(question.decision || (decisionCapable && recordingOn)) && (
364
493
  <div
365
494
  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"
366
495
  data-pinpoint-ignore=""