@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.
- package/HANDOFF.md +235 -0
- package/README.md +15 -0
- package/components/AnnotationToolbar.tsx +2 -2
- package/components/BlockRenderer.tsx +16 -1
- package/components/CommentPopover.tsx +4 -0
- package/components/CompletionOverlay.tsx +12 -5
- package/components/FloatingQuickLabelPicker.tsx +2 -2
- package/components/QuickLabelDropdown.tsx +9 -4
- package/components/ServerSessionReplacedBanner.tsx +29 -0
- package/components/Settings.tsx +3 -191
- package/components/Viewer.tsx +44 -1
- package/components/blocks/QuestionBlock.tsx +137 -8
- package/components/html-viewer/bridge-script.asset.js +106 -6
- package/components/html-viewer/bridge-script.ts +106 -6
- package/components/settings/QuickLabelsTab.tsx +378 -0
- package/hooks/useAIChat.ts +57 -1
- package/hooks/useAnnotationHighlighter.ts +28 -0
- package/package.json +2 -2
- package/styles.css +1 -1
- package/types.ts +6 -0
- package/utils/parser.ts +149 -7
- package/utils/quickLabels.ts +80 -0
- package/utils/serverSession.ts +85 -0
package/components/Settings.tsx
CHANGED
|
@@ -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 {
|
|
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' && (
|
package/components/Viewer.tsx
CHANGED
|
@@ -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
|
-
{
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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 ||
|
|
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=""
|