@plannotator/ui 0.52.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 +88 -4
- package/README.md +7 -0
- package/components/BlockRenderer.tsx +7 -1
- package/components/Viewer.tsx +26 -8
- package/components/blocks/QuestionBlock.tsx +75 -13
- package/package.json +1 -1
- package/styles.css +1 -1
package/HANDOFF.md
CHANGED
|
@@ -1880,7 +1880,7 @@ Pinned by `components/DiagramBlock.askAI.test.tsx` and
|
|
|
1880
1880
|
|
|
1881
1881
|
---
|
|
1882
1882
|
|
|
1883
|
-
## Ask AI names a text selection's source lines (
|
|
1883
|
+
## Ask AI names a text selection's source lines (ui 0.52.0; additive, no core change)
|
|
1884
1884
|
|
|
1885
1885
|
An Ask AI question asked from a markdown text selection now says which
|
|
1886
1886
|
source lines the selection is on, so a phrase that occurs more than once in
|
|
@@ -1920,7 +1920,7 @@ heading for single-block selections) and `utils/aiPrompt.test.ts`.
|
|
|
1920
1920
|
|
|
1921
1921
|
---
|
|
1922
1922
|
|
|
1923
|
-
## `CompletionOverlay` `done` state (
|
|
1923
|
+
## `CompletionOverlay` `done` state (ui 0.52.0; additive)
|
|
1924
1924
|
|
|
1925
1925
|
`submitted` accepts a new value, `'done'`: the session finished with nothing
|
|
1926
1926
|
sent (Plannotator uses it for an annotate Done with nothing to send). It
|
|
@@ -1937,7 +1937,7 @@ so this is a visible change for `'exited'` only there. Every other value
|
|
|
1937
1937
|
renders exactly as before. The overlay root now carries
|
|
1938
1938
|
`data-completion-state={submitted}`.
|
|
1939
1939
|
|
|
1940
|
-
## Quick Labels: reorder and editable emoji (
|
|
1940
|
+
## Quick Labels: reorder and editable emoji (ui 0.52.0; additive, no core change)
|
|
1941
1941
|
|
|
1942
1942
|
Settings → Labels (#1736) lets a person move a label up or down (per-row
|
|
1943
1943
|
buttons, `aria-label="Move <text> up|down"`, a polite live region announces
|
|
@@ -1970,7 +1970,7 @@ What a host should know:
|
|
|
1970
1970
|
emoji grapheme, including ZWJ sequences, flags, keycaps and skin tones, or
|
|
1971
1971
|
null) and `emojiFromFieldInput`. Nothing was removed or renamed.
|
|
1972
1972
|
|
|
1973
|
-
## Question decision toggle and status tag (ui 0.52.0
|
|
1973
|
+
## Question decision toggle and status tag (ui 0.52.0; additive, no core change)
|
|
1974
1974
|
|
|
1975
1975
|
What Workspaces asked for: the reviewer can switch a `Decision: when answered`
|
|
1976
1976
|
question's recording off and on from the card itself, and the host can draw
|
|
@@ -2029,8 +2029,92 @@ row hidden when off, recorded and plain questions untouched, read-only cards
|
|
|
2029
2029
|
static, `statusTag: 'none'`, the `Viewer` threading, and a card with none of
|
|
2030
2030
|
the props rendering the same markup.
|
|
2031
2031
|
|
|
2032
|
+
## Decision toggle on any question, and an opener for the host's decision card (ui 0.52.1; additive, no core change)
|
|
2033
|
+
|
|
2034
|
+
Two gaps Workspaces found in 0.52.0 against its approved Inbox design
|
|
2035
|
+
("the tag is the switch" on every question, off by default; "clicking the
|
|
2036
|
+
tag's words opens the decision card"). Plannotator passes none of this, and a
|
|
2037
|
+
card without the new props renders byte-identically to 0.52.0 (checked by
|
|
2038
|
+
rendering main and the branch through `BlockRenderer` and `Viewer`,
|
|
2039
|
+
interactive and read-only, with no props, with a 0.52.0-shaped host, and with
|
|
2040
|
+
a 0.52.0 host returning a boolean for every question).
|
|
2041
|
+
|
|
2042
|
+
| `Viewer` | `BlockRenderer` | `QuestionBlock` |
|
|
2043
|
+
| --- | --- | --- |
|
|
2044
|
+
| `questionDecisionScope?: 'when-answered' \| 'any'` | same | `decisionScope?: 'when-answered' \| 'any'` |
|
|
2045
|
+
| `onOpenQuestionDecision?: (key, anchor: HTMLElement) => void` | same | `onOpenDecision?: (key, anchor: HTMLElement) => void` |
|
|
2046
|
+
|
|
2047
|
+
1. **Any question can record a decision, opt-in.** With
|
|
2048
|
+
`questionDecisionScope="any"` (`decisionScope: 'any'`),
|
|
2049
|
+
`decisionRecording` / `questionDecisionRecording` also reaches a question
|
|
2050
|
+
WITHOUT a decision line: a boolean adds the "◇ Records a decision" tag in
|
|
2051
|
+
that state (and, with `onToggleDecisionRecording`, makes it the toggle),
|
|
2052
|
+
`undefined` leaves the question as it was (no tag). The default,
|
|
2053
|
+
`'when-answered'`, is the 0.52.0 behaviour: only `Decision: when answered`
|
|
2054
|
+
questions carry the tag, and a boolean returned for a plain question is
|
|
2055
|
+
ignored, as 0.52.0 documented. It is an explicit opt-in rather than
|
|
2056
|
+
"a boolean means show it" so that a 0.52.0 host that returns a boolean for
|
|
2057
|
+
every question does not grow tags on its plain questions in a patch
|
|
2058
|
+
release. On shows the same "Answering this records
|
|
2059
|
+
a decision" row a `Decision: when answered` question shows; off hides it.
|
|
2060
|
+
The default for such a question is whatever you return: start it `false`
|
|
2061
|
+
for "off by default". A question with a recorded decision
|
|
2062
|
+
(`Decision: [statement](url)`) is still never affected, and
|
|
2063
|
+
`data-question-decision` still describes the question's own line (absent
|
|
2064
|
+
for a plain question); `data-question-decision-recording="on" | "off"`
|
|
2065
|
+
appears once you supply a state.
|
|
2066
|
+
2. **A separate opener (`onOpenDecision` / `onOpenQuestionDecision`).** Given,
|
|
2067
|
+
the tag is drawn as two adjacent targets inside one visual tag
|
|
2068
|
+
(`[data-question-decision-tag]`), siblings, never nested:
|
|
2069
|
+
- the diamond is the switch: `<button aria-pressed aria-label="Record as a
|
|
2070
|
+
decision" data-question-decision-toggle>` calling
|
|
2071
|
+
`onToggleDecisionRecording(key, next)`. Without a toggle handler the
|
|
2072
|
+
diamond is decoration, not a control.
|
|
2073
|
+
- the words "Records a decision" are `<button aria-haspopup="dialog"
|
|
2074
|
+
data-question-decision-open>`, calling `onOpenDecision(key, element)`
|
|
2075
|
+
with the words' own button as the popover anchor. The card keeps no
|
|
2076
|
+
open state; your popover owns focus and returns it to the anchor.
|
|
2077
|
+
The opener is offered whether recording is on or off: what an off
|
|
2078
|
+
question's card does (switch it on as it opens, or open a card that says
|
|
2079
|
+
it is off) is the host's call. Tab order is switch, then words, both with
|
|
2080
|
+
the `ring-2 ring-primary` focus ring the 0.52.0 toggle uses (a ring, not
|
|
2081
|
+
an outline, because the dotted outline draws the off state, which is now
|
|
2082
|
+
on the wrapping tag). The off look is unchanged: muted-foreground text and
|
|
2083
|
+
a dotted outline (#1744's contrast fix). Without `onOpenDecision` the
|
|
2084
|
+
whole tag is the toggle exactly as in 0.52.0.
|
|
2085
|
+
3. **Read-only cards are never interactive.** No answer handler (or
|
|
2086
|
+
`Viewer` `readOnly`, which also drops `onOpenQuestionDecision`) draws the
|
|
2087
|
+
tag as a plain tag in its current state.
|
|
2088
|
+
|
|
2089
|
+
Measured in headless Chromium with the default theme: switch and words are
|
|
2090
|
+
7.37:1 (dark) / 8.98:1 (light) off, 5.84:1 / 5.15:1 on; the focus ring is
|
|
2091
|
+
7.53:1 / 6.53:1 against the card. The split tag draws exactly the 0.52.0 tag
|
|
2092
|
+
(127.66 × 17.75 px, the same 4 px between diamond and words). Target size
|
|
2093
|
+
(WCAG 2.5.8) is met without changing that look: the diamond (18 × 17.75 px
|
|
2094
|
+
drawn) carries an invisible `before:` box 6 px past it on every side, so it
|
|
2095
|
+
clicks as 24 × 29.75 px; the words, positioned and later in the tree, paint
|
|
2096
|
+
over the box's right-hand part, so a click on the words is always the words,
|
|
2097
|
+
and the words (110 px wide) meet the spacing exception.
|
|
2098
|
+
|
|
2099
|
+
**No stylesheet change**: only utility classes the package already ships
|
|
2100
|
+
(the hit box is the `before:absolute before:-inset-1.5 before:content-['']`
|
|
2101
|
+
pattern of the annotation panel's icon buttons), so `styles.css` and the
|
|
2102
|
+
guides.show viewer manifest are unchanged (`check:manifest` in sync).
|
|
2103
|
+
|
|
2104
|
+
Pinned by `components/blocks/QuestionBlock.decisionAnyQuestion.test.tsx`
|
|
2105
|
+
(DOM-gated): the tag on a plain question only with the `'any'` scope and a
|
|
2106
|
+
supplied state (a boolean without the scope changes nothing), the
|
|
2107
|
+
toggle's arguments and the row on/off, the switch and the opener as sibling
|
|
2108
|
+
buttons with the anchor element, tab order, the opener without a toggle
|
|
2109
|
+
handler, 0.52.0 behavior without `onOpenDecision`, read-only cards, `Viewer`
|
|
2110
|
+
threading, and no-prop markup.
|
|
2111
|
+
|
|
2032
2112
|
---
|
|
2033
2113
|
|
|
2114
|
+
## Stale-tab guard: `serverSession` (ui 0.52.0, core 0.25.12; additive)
|
|
2115
|
+
|
|
2116
|
+
Plannotator's servers issue one random `serverSession` nonce per process (new `@plannotator/core/server-session`) and refuse a decision that echoes a different one with `409 { code: "session_mismatch" }`, so a tab left open on a reused port cannot decide a new session. The client half is `utils/serverSession` (`adoptServerSession`, `withServerSession`, `withServerSessionQuery`, `noteServerSessionMismatch`, `useServerSessionReplaced`) plus `components/ServerSessionReplacedBanner`. Hosts that post decisions to their own backend need none of it: nothing in the shared components sends the field on its own, and a request without it is always accepted.
|
|
2117
|
+
|
|
2034
2118
|
## The law (guardrails for anyone editing `@plannotator/ui`)
|
|
2035
2119
|
|
|
2036
2120
|
These are enforced socially and, where possible, by CI. They exist because a prior from-scratch reimplementation of this UI broke the app and was reverted.
|
package/README.md
CHANGED
|
@@ -392,6 +392,13 @@ See HANDOFF.md § "Question host seams (ui 0.49.0, core 0.25.9)".
|
|
|
392
392
|
|
|
393
393
|
See HANDOFF.md § "Question decision toggle and status tag (ui 0.52.0, unreleased; …)".
|
|
394
394
|
|
|
395
|
+
**Decision toggle on any question, and a decision-card opener (ui 0.52.1, unreleased).** Also optional on `Viewer`, also unused by Plannotator:
|
|
396
|
+
|
|
397
|
+
- `questionDecisionScope?: 'when-answered' | 'any'` (default `'when-answered'`, the 0.52.0 behaviour). With `'any'`, `questionDecisionRecording(question)` also applies to a question with no decision line: a boolean adds the "Records a decision" tag in that state (true also shows the "Answering this records a decision" row); `undefined` leaves it tag-less. Return `false` for "off by default".
|
|
398
|
+
- `onOpenQuestionDecision(key, anchor)`: splits the tag into a diamond switch (`aria-pressed`, "Record as a decision", driven by `onToggleQuestionDecisionRecording`) and the words as a button (`aria-haspopup="dialog"`) that calls this with its own element to anchor your decision card. Offered on and off; not offered under `readOnly`. Without it the whole tag is the toggle, as in 0.52.0.
|
|
399
|
+
|
|
400
|
+
See HANDOFF.md § "Decision toggle on any question, and an opener for the host's decision card (ui 0.52.1, …)".
|
|
401
|
+
|
|
395
402
|
### Diff file tree (`components/DiffFileTree`; next ui release)
|
|
396
403
|
|
|
397
404
|
A read-only file tree for a list of changed files, to sit beside a host's own per-file diff list. It is Plannotator's code-review tree, not a lookalike: the builder (`utils/diffFileTree`), the folder open/closed hook (`hooks/useDiffFileTreeExpansion`) and the row atoms moved here from `packages/review-editor`, and the review now renders from them.
|
|
@@ -53,11 +53,15 @@ export const BlockRenderer: React.FC<{
|
|
|
53
53
|
renderQuestionFooter?: QuestionBlockProps['renderFooter'];
|
|
54
54
|
/** Whether answering this question records a decision (see `QuestionBlock`). */
|
|
55
55
|
questionDecisionRecording?: QuestionBlockProps['decisionRecording'];
|
|
56
|
+
/** `'any'` lets a question without a decision line carry the tag (see `QuestionBlock`). */
|
|
57
|
+
questionDecisionScope?: QuestionBlockProps['decisionScope'];
|
|
56
58
|
/** Makes the "Records a decision" tag a toggle (see `QuestionBlock`). */
|
|
57
59
|
onToggleQuestionDecisionRecording?: QuestionBlockProps['onToggleDecisionRecording'];
|
|
60
|
+
/** Opens the host's decision card from the tag's words (see `QuestionBlock`). */
|
|
61
|
+
onOpenQuestionDecision?: QuestionBlockProps['onOpenDecision'];
|
|
58
62
|
/** `'none'` hides the card's own status tag (see `QuestionBlock`). */
|
|
59
63
|
questionStatusTag?: QuestionBlockProps['statusTag'];
|
|
60
|
-
}> = ({ block, onOpenLinkedDoc, onOpenCodeFile, imageBaseDir, onImageClick, onToggleCheckbox, checkboxOverrides, orderedIndex, githubRepo, repoHost, headingAnchorId, onNavigateAnchor, question, questionTotal, questionAnswer, onAnswerQuestion, onSaveQuestionAnswer, saveQuestionAnswerLabel, renderQuestionFooter, questionDecisionRecording, onToggleQuestionDecisionRecording, questionStatusTag }) => {
|
|
64
|
+
}> = ({ block, onOpenLinkedDoc, onOpenCodeFile, imageBaseDir, onImageClick, onToggleCheckbox, checkboxOverrides, orderedIndex, githubRepo, repoHost, headingAnchorId, onNavigateAnchor, question, questionTotal, questionAnswer, onAnswerQuestion, onSaveQuestionAnswer, saveQuestionAnswerLabel, renderQuestionFooter, questionDecisionRecording, questionDecisionScope, onToggleQuestionDecisionRecording, onOpenQuestionDecision, questionStatusTag }) => {
|
|
61
65
|
switch (block.type) {
|
|
62
66
|
case 'heading': {
|
|
63
67
|
const Tag = `h${block.level || 1}` as React.ElementType;
|
|
@@ -185,7 +189,9 @@ export const BlockRenderer: React.FC<{
|
|
|
185
189
|
saveLabel={saveQuestionAnswerLabel}
|
|
186
190
|
renderFooter={renderQuestionFooter}
|
|
187
191
|
decisionRecording={questionDecisionRecording}
|
|
192
|
+
decisionScope={questionDecisionScope}
|
|
188
193
|
onToggleDecisionRecording={onToggleQuestionDecisionRecording}
|
|
194
|
+
onOpenDecision={onOpenQuestionDecision}
|
|
189
195
|
statusTag={questionStatusTag}
|
|
190
196
|
onOpenLinkedDoc={onOpenLinkedDoc}
|
|
191
197
|
onOpenCodeFile={onOpenCodeFile}
|
package/components/Viewer.tsx
CHANGED
|
@@ -218,17 +218,31 @@ 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
|
|
222
|
-
*
|
|
223
|
-
* tag dimmed with a dotted outline and
|
|
224
|
-
* decision" row; true or undefined keeps today's card.
|
|
225
|
-
* question
|
|
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. */
|
|
226
229
|
questionDecisionRecording?: (question: IndexedQuestion) => boolean | undefined;
|
|
227
|
-
/**
|
|
228
|
-
*
|
|
229
|
-
*
|
|
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
|
|
230
238
|
* `questionDecisionRecording`. Ignored when `readOnly`. */
|
|
231
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;
|
|
232
246
|
/** `'none'` hides each question card's own status tag (Open / Answered /
|
|
233
247
|
* Settled / Skipped) so the host can draw its own; the decision tags stay.
|
|
234
248
|
* Default `'card'`. */
|
|
@@ -606,7 +620,9 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
|
|
606
620
|
saveQuestionAnswerLabel,
|
|
607
621
|
renderQuestionFooter,
|
|
608
622
|
questionDecisionRecording,
|
|
623
|
+
questionDecisionScope,
|
|
609
624
|
onToggleQuestionDecisionRecording,
|
|
625
|
+
onOpenQuestionDecision,
|
|
610
626
|
questionStatusTag,
|
|
611
627
|
onAskAI,
|
|
612
628
|
allowImages = true,
|
|
@@ -1570,7 +1586,9 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
|
|
1570
1586
|
saveQuestionAnswerLabel={saveQuestionAnswerLabel}
|
|
1571
1587
|
renderQuestionFooter={renderQuestionFooter}
|
|
1572
1588
|
questionDecisionRecording={question ? questionDecisionRecording?.(question) : undefined}
|
|
1589
|
+
questionDecisionScope={questionDecisionScope}
|
|
1573
1590
|
onToggleQuestionDecisionRecording={readOnly ? undefined : onToggleQuestionDecisionRecording}
|
|
1591
|
+
onOpenQuestionDecision={readOnly ? undefined : onOpenQuestionDecision}
|
|
1574
1592
|
questionStatusTag={questionStatusTag}
|
|
1575
1593
|
/>
|
|
1576
1594
|
);
|
|
@@ -37,9 +37,12 @@ import { QuestionContext } from './QuestionContext';
|
|
|
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
39
|
* `Decision: [statement](url)` link shows that beside the prompt. A host can
|
|
40
|
-
* also let the reviewer switch a
|
|
41
|
-
*
|
|
42
|
-
*
|
|
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'`).
|
|
43
46
|
*/
|
|
44
47
|
|
|
45
48
|
/** Pointer travel (px) above which a press on a choice row is a drag. */
|
|
@@ -127,17 +130,36 @@ export interface QuestionBlockProps {
|
|
|
127
130
|
* saved answer (never the unsaved draft). Rendered in read-only cards too;
|
|
128
131
|
* return null for nothing. */
|
|
129
132
|
renderFooter?: (question: IndexedQuestion, answer: QuestionAnswer | undefined) => React.ReactNode;
|
|
130
|
-
/** Whether answering
|
|
131
|
-
*
|
|
132
|
-
* with a dotted outline and hides the
|
|
133
|
-
*
|
|
134
|
-
*
|
|
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)`). */
|
|
135
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';
|
|
136
149
|
/** Makes the "Records a decision" tag a toggle button (`aria-pressed`):
|
|
137
150
|
* a click calls this with the question's key and the next state. The host
|
|
138
151
|
* stores it and passes it back as `decisionRecording`. Not offered in a
|
|
139
152
|
* read-only card (no answer handler), which draws the tag as it stands. */
|
|
140
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;
|
|
141
163
|
/** `'none'` hides the card's own status tag (Open / Answered / Settled /
|
|
142
164
|
* Skipped) so the host can draw its own; the decision tags stay. Default
|
|
143
165
|
* `'card'`. */
|
|
@@ -161,7 +183,9 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
|
|
|
161
183
|
saveLabel,
|
|
162
184
|
renderFooter,
|
|
163
185
|
decisionRecording,
|
|
186
|
+
decisionScope = 'when-answered',
|
|
164
187
|
onToggleDecisionRecording,
|
|
188
|
+
onOpenDecision,
|
|
165
189
|
statusTag = 'card',
|
|
166
190
|
onOpenLinkedDoc,
|
|
167
191
|
onOpenCodeFile,
|
|
@@ -355,11 +379,15 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
|
|
|
355
379
|
const other = answer?.other ?? '';
|
|
356
380
|
const otherOn = other.trim() !== '';
|
|
357
381
|
|
|
358
|
-
// A `Decision: when answered` question the host may switch off
|
|
359
|
-
//
|
|
360
|
-
|
|
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));
|
|
361
388
|
const recordingOn = decisionRecording ?? true;
|
|
362
389
|
const decisionToggleable = decisionCapable && !readOnly && !!onToggleDecisionRecording;
|
|
390
|
+
const decisionOpenable = decisionCapable && !readOnly && !!onOpenDecision;
|
|
363
391
|
// Present only when the host speaks to recording, so a card without these
|
|
364
392
|
// props carries exactly the attributes it always did.
|
|
365
393
|
const recordingAttr = decisionCapable && (decisionRecording !== undefined || !!onToggleDecisionRecording)
|
|
@@ -396,7 +424,41 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
|
|
|
396
424
|
<span className="text-[10.5px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{eyebrow}</span>
|
|
397
425
|
<span className="ml-auto">
|
|
398
426
|
{question.decision && <><Tag tone="rec"><DiamondGlyph />Decision</Tag>{' '}</>}
|
|
399
|
-
{decisionCapable && (
|
|
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 ? (
|
|
400
462
|
// Focus shows as a ring, not an outline: the dotted outline is
|
|
401
463
|
// what draws the off state.
|
|
402
464
|
<><button
|
|
@@ -427,7 +489,7 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
|
|
|
427
489
|
<p id={promptId} className="m-0 text-[15px] font-semibold leading-[1.45] text-foreground" data-question-part="prompt">
|
|
428
490
|
{inline(question.prompt)}
|
|
429
491
|
</p>
|
|
430
|
-
{(question.decision || (
|
|
492
|
+
{(question.decision || (decisionCapable && recordingOn)) && (
|
|
431
493
|
<div
|
|
432
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"
|
|
433
495
|
data-pinpoint-ignore=""
|