@plannotator/ui 0.36.0 → 0.38.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.
Files changed (37) hide show
  1. package/HANDOFF.md +6 -4
  2. package/README.md +57 -1
  3. package/components/ActionMenu.tsx +22 -24
  4. package/components/AnnotationToolbar.tsx +3 -1
  5. package/components/ApproveDropdown.tsx +15 -18
  6. package/components/CommentPopover.tsx +9 -4
  7. package/components/DecisionControl.tsx +626 -0
  8. package/components/DocBadges.tsx +9 -6
  9. package/components/FloatingQuickLabelPicker.tsx +4 -1
  10. package/components/KeyboardShortcuts.tsx +1 -0
  11. package/components/PlanHeaderMenu.tsx +1 -1
  12. package/components/Settings.tsx +67 -6
  13. package/components/StickyHeaderLane.tsx +16 -40
  14. package/components/ToolbarButtons.tsx +31 -9
  15. package/components/Viewer.tsx +277 -95
  16. package/components/VimTargetReticle.tsx +1 -1
  17. package/components/blocks/AlertBlock.tsx +51 -4
  18. package/components/compactHeaderLayout.ts +52 -0
  19. package/config/reviewView.ts +1 -0
  20. package/config/settings.ts +48 -3
  21. package/configure.ts +9 -0
  22. package/hooks/useAnnotationHighlighter.ts +31 -19
  23. package/hooks/useDismissablePopover.ts +65 -0
  24. package/hooks/useVimDocumentFocus.ts +6 -0
  25. package/package.json +22 -22
  26. package/shortcuts/decisionControl.shortcuts.ts +31 -0
  27. package/shortcuts/index.ts +1 -0
  28. package/shortcuts/plan-review/documentView.shortcuts.ts +13 -0
  29. package/styles.css +1 -1
  30. package/theme.css +11 -0
  31. package/utils/alertTitle.ts +69 -0
  32. package/utils/decisionSpec.ts +430 -0
  33. package/utils/platform.ts +16 -0
  34. package/utils/vimScroll.ts +1 -1
  35. package/sprite_package_additional/index.html +0 -34
  36. package/sprite_package_new/index.html +0 -34
  37. package/sprite_package_pulluphang/index.html +0 -34
package/theme.css CHANGED
@@ -912,6 +912,17 @@ html:has([data-pn-compact-touch-layout='true'])
912
912
  gap: 0.5rem;
913
913
  }
914
914
 
915
+ /* Percentages in translate use the toolbar's own width. Correct only its
916
+ * horizontal overflow; leave the existing transform animation untouched. */
917
+ html:has([data-pn-compact-touch-layout='true'])
918
+ [data-pn-annotation-toolbar-centered='true'] {
919
+ translate: clamp(
920
+ calc(var(--pn-viewport-offset-left, 0px) + var(--pn-safe-left, 0px) - var(--pn-annotation-toolbar-anchor-x) + 50%),
921
+ 0px,
922
+ calc(var(--pn-viewport-offset-left, 0px) + var(--pn-viewport-width, 100vw) - var(--pn-safe-right, 0px) - var(--pn-annotation-toolbar-anchor-x) - 50%)
923
+ ) 0;
924
+ }
925
+
915
926
  html:has([data-pn-compact-touch-layout='true'])
916
927
  [data-pn-touch-target]:not(:disabled):not([aria-disabled='true']):active {
917
928
  filter: brightness(0.92);
@@ -0,0 +1,69 @@
1
+ /**
2
+ * The title-line grammar for GitHub alerts (`> [!NOTE|TIP|IMPORTANT|WARNING|CAUTION]`).
3
+ *
4
+ * The first body line of an alert is a TITLE LINE when the whole line matches:
5
+ *
6
+ * [<emoji> ][**<title>**][ <!-- icon: <name> -->]
7
+ *
8
+ * that is, in order: optionally one emoji (followed by one space when anything
9
+ * follows it); optionally a bold-only run `**title**` with no other characters
10
+ * around it; optionally one space and an HTML comment `<!-- icon: name -->`
11
+ * where name is `[A-Za-z0-9][A-Za-z0-9_-]*`; at least one of the three
12
+ * present; trailing whitespace ignored. It only counts when the NEXT body line
13
+ * is empty (the `>` line GitHub needs to start a new paragraph) or the block
14
+ * ends there. Anything else is ordinary body and the alert renders as before.
15
+ *
16
+ * Decided from bytes alone, so a host editor can apply the same rule to the
17
+ * raw lines it decorates. Kept pure and dependency-free for that reason.
18
+ */
19
+
20
+ export interface AlertTitleLine {
21
+ /** The leading emoji, when present (one grapheme, ZWJ/modifier sequences included). */
22
+ emoji?: string;
23
+ /** The bold-only title text, when present (the `**` marks stripped). */
24
+ title?: string;
25
+ /** The `<!-- icon: name -->` name, when present. Never rendered as text. */
26
+ icon?: string;
27
+ /** The body after the title line and its separating empty line. */
28
+ rest: string;
29
+ }
30
+
31
+ // One emoji: a regional-indicator pair (flag) or a pictographic base with
32
+ // optional skin-tone / variation / keycap marks, joined by ZWJ to more of the
33
+ // same. Deliberately not the whole Unicode emoji property (which would admit
34
+ // bare digits and `#`).
35
+ const EMOJI =
36
+ '(?:\\p{RI}\\p{RI}|\\p{Extended_Pictographic}(?:\\p{Emoji_Modifier}|\\uFE0F|\\u20E3)*' +
37
+ '(?:\\u200D\\p{Extended_Pictographic}(?:\\p{Emoji_Modifier}|\\uFE0F)*)*)';
38
+
39
+ const ICON_NAME = '[A-Za-z0-9][A-Za-z0-9_-]*';
40
+
41
+ const TITLE_LINE = new RegExp(
42
+ `^(?:(${EMOJI})(?= |$) ?)?(?:\\*\\*([^*\\n]+?)\\*\\*)?(?: ?<!--\\s*icon:\\s*(${ICON_NAME})\\s*-->)?$`,
43
+ 'u',
44
+ );
45
+
46
+ /**
47
+ * Split an alert body into its title line and the rest, or return null when
48
+ * the first line is not a title line by the grammar above.
49
+ */
50
+ export function parseAlertTitleLine(body: string): AlertTitleLine | null {
51
+ const firstBreak = body.indexOf('\n');
52
+ const first = firstBreak === -1 ? body : body.slice(0, firstBreak);
53
+ const after = firstBreak === -1 ? '' : body.slice(firstBreak + 1);
54
+
55
+ const match = first.trimEnd().match(TITLE_LINE);
56
+ if (!match) return null;
57
+ const [, emoji, title, icon] = match;
58
+ if (!emoji && !title && !icon) return null;
59
+
60
+ // The next line must be empty, or the block must end.
61
+ if (after !== '') {
62
+ const secondBreak = after.indexOf('\n');
63
+ const second = secondBreak === -1 ? after : after.slice(0, secondBreak);
64
+ if (second.trim() !== '') return null;
65
+ const rest = secondBreak === -1 ? '' : after.slice(secondBreak + 1);
66
+ return { emoji, title: title?.trim(), icon, rest };
67
+ }
68
+ return { emoji, title: title?.trim(), icon, rest: '' };
69
+ }
@@ -0,0 +1,430 @@
1
+ /**
2
+ * Pure state→spec mapping for the unified header decision control.
3
+ *
4
+ * No React, no DOM, no imports — this is what keeps the state matrix testable
5
+ * in the plain `bun test` lane and keeps "both apps and both states are data,
6
+ * not forked components" true. `DecisionControl.tsx` renders whatever this
7
+ * returns; the apps translate ids into handlers.
8
+ *
9
+ * NOT host-supported surface: like ActionMenu/ConfirmDialog, this module is
10
+ * app-shared chrome and is deliberately absent from the README supported-import
11
+ * list and the strict-consumer tsconfig.
12
+ *
13
+ * Labels, subtitles and confirm strings are the approved prototype's
14
+ * (DESIGN_final-proposal.html `spec()`), authoritative over any older branch
15
+ * or mock copy — except where a later maintainer ruling supersedes it: the
16
+ * non-gate empty menu carries ONE composer ("Send a note…"), and no
17
+ * user-facing string uses an em dash.
18
+ */
19
+
20
+ export type DecisionActionId =
21
+ | 'primary' // the left segment
22
+ | 'note-with-approval' // "Approve with a note…" (approval flows only)
23
+ | 'request-changes' // "Request changes…"
24
+ | 'note-with-feedback' // "Send with a note…"
25
+ | 'approve-with-notes' // review + gate-annotate; capability-gated
26
+ | 'discard-and-finish'; // "Done/Approve, discard n annotations…"
27
+
28
+ export type DecisionTone = 'success' | 'primary' | 'neutral' | 'destructive';
29
+
30
+ export interface DecisionPrimary {
31
+ id: 'primary';
32
+ label: string; // 'Done' | 'Approve' | 'Send Feedback' | 'Post Comments'
33
+ shortLabel?: string; // 'Send' — the lg-breakpoint label
34
+ mobileLabel?: string; // compact/touch row label
35
+ title: string; // tooltip / aria description
36
+ tone: Exclude<DecisionTone, 'destructive'>;
37
+ icon?: 'check' | 'send';
38
+ count?: number; // rendered as the inline pill; omitted when 0
39
+ /**
40
+ * Platform self-approval (PR6, §3.4): rendered dimmed but NOT disabled.
41
+ * The reason surfaces through the shared Tooltip + aria-describedby (the
42
+ * native title is deliberately dropped when muted, pinned by test), and
43
+ * every invocation path (click, Mod+Enter, compact row) is a no-op. The
44
+ * caret stays live so the menu's non-approve paths remain reachable.
45
+ */
46
+ muted?: boolean;
47
+ }
48
+
49
+ export interface DecisionComposer {
50
+ title: string; // popover back-button title, e.g. 'Send with a note'
51
+ actionLabel: string; // the composer's own button, e.g. 'Send feedback with note'
52
+ tone: Exclude<DecisionTone, 'destructive'>;
53
+ icon?: 'check' | 'send';
54
+ placeholder: string; // 'Add a note...'
55
+ }
56
+
57
+ export interface DecisionConfirm {
58
+ title: string;
59
+ message: string;
60
+ confirmText: string;
61
+ }
62
+
63
+ export interface DecisionMenuItem {
64
+ id: Exclude<DecisionActionId, 'primary'>;
65
+ label: string;
66
+ subtitle: string;
67
+ tone: DecisionTone;
68
+ icon?: 'check' | 'send';
69
+ dividerBefore?: boolean;
70
+ composer?: DecisionComposer; // present ⇒ the item morphs the popover
71
+ confirm?: DecisionConfirm; // present ⇒ the item raises one confirm
72
+ /**
73
+ * Platform self-approval (PR6, §3.4): the row renders disabled with the
74
+ * reason in its subtitle. Muted rather than removed, so the menu still
75
+ * documents the path and no state is a dead end (the non-approve rows
76
+ * stay live).
77
+ */
78
+ muted?: boolean;
79
+ }
80
+
81
+ export interface DecisionSpec {
82
+ primary: DecisionPrimary;
83
+ items: DecisionMenuItem[];
84
+ }
85
+
86
+ export interface DecisionSpecInput {
87
+ app: 'annotate' | 'review';
88
+ /** Annotate: `gate`. Review: always true — review's primary decision IS approval. */
89
+ gate: boolean;
90
+ /** The count rendered in the pill and interpolated into labels. */
91
+ count: number;
92
+ /**
93
+ * Whether there is anything to send. Deliberately separate from `count`:
94
+ * annotate counts direct edits / saved-file changes / attachments as
95
+ * feedback with count 0 (`hasFeedbackContent` in the annotate app).
96
+ */
97
+ hasFeedback: boolean;
98
+ /** Does the runtime deliver feedback on approve? Gates every approve-carrying item. */
99
+ approvalNotesSupported: boolean;
100
+ /**
101
+ * M1 ruling: the session's feedback was already delivered through the
102
+ * annotate agent terminal, which is why `hasFeedback` reads false. The
103
+ * empty-flip state keeps its `Done` primary AND its transport (the outer
104
+ * agent's stdout consumer may never have seen the terminal delivery, so
105
+ * the full payload still posts) — only the copy changes, because "reviewed
106
+ * with no feedback" would be a lie in that state. Copy is free prose,
107
+ * NOT frozen.
108
+ */
109
+ feedbackDelivered?: boolean;
110
+ /**
111
+ * PR6 (§3.4): presence selects the platform (PR/MR destination) arm — the
112
+ * same DecisionSpec shape with NO composer and NO confirm items, ever:
113
+ * every platform action opens the existing ReviewSubmissionDialog, whose
114
+ * own general-comment textarea is the only note field on that side (a
115
+ * second composer would double-post via buildFileScopedBody).
116
+ * `approvalNotesSupported` is deliberately ignored by this arm — the
117
+ * platform posts to the forge API natively — so approve-carrying items
118
+ * gate only on `selfAuthored`, muted rather than removed.
119
+ */
120
+ platform?: DecisionPlatformInput;
121
+ }
122
+
123
+ export interface DecisionPlatformInput {
124
+ /** Platform display name ('GitHub' / 'GitLab') — named in the self-approval tooltip. */
125
+ label: string;
126
+ /** The PR/MR noun ('PR' / 'MR') the short mute reason uses. */
127
+ mrLabel: string;
128
+ /** The viewer authored this PR/MR: approve paths mute, never disappear. */
129
+ selfAuthored: boolean;
130
+ }
131
+
132
+ export const DECISION_NOTE_PLACEHOLDER = 'Add a note...';
133
+
134
+ function annotationNoun(count: number): string {
135
+ return count === 1 ? 'annotation' : 'annotations';
136
+ }
137
+
138
+ /**
139
+ * The empty state: no feedback to send, the primary is the positive finish.
140
+ * `approvalFlow` (gate annotate, or review) makes it `Approve`; plain annotate
141
+ * gets `Done`.
142
+ */
143
+ function buildEmptySpec(input: DecisionSpecInput, approvalFlow: boolean): DecisionSpec {
144
+ // "Approve with a note…" carries a note on the approve channel, which four
145
+ // runtimes still discard — it renders only where the advert says delivery
146
+ // works (never an item that silently drops content). Non-gate annotate has
147
+ // no approve channel and no positive-note item at all: the maintainer ruled
148
+ // the old "Done with a note…" / "Request changes…" pair collapsed into the
149
+ // single "Send a note…" below, because their only difference was framing on
150
+ // the same /api/feedback transport.
151
+ const positive: DecisionMenuItem | null = approvalFlow && input.approvalNotesSupported
152
+ ? {
153
+ id: 'note-with-approval',
154
+ label: 'Approve with a note…',
155
+ subtitle: 'Approve and send a short note with it',
156
+ tone: 'success',
157
+ icon: 'check',
158
+ composer: {
159
+ title: 'Approve with a note',
160
+ actionLabel: 'Approve and send note',
161
+ tone: 'success',
162
+ icon: 'check',
163
+ placeholder: DECISION_NOTE_PLACEHOLDER,
164
+ },
165
+ }
166
+ : null;
167
+
168
+ const requestChanges: DecisionMenuItem = approvalFlow
169
+ ? {
170
+ id: 'request-changes',
171
+ // Frozen copy (maintainer-approved): 'Request changes…'.
172
+ label: 'Request changes…',
173
+ subtitle: 'Write overall feedback, sent as a change request',
174
+ tone: 'primary',
175
+ icon: 'send',
176
+ dividerBefore: positive !== null,
177
+ composer: {
178
+ title: 'Request changes',
179
+ actionLabel: 'Send as feedback',
180
+ tone: 'primary',
181
+ icon: 'send',
182
+ placeholder: DECISION_NOTE_PLACEHOLDER,
183
+ },
184
+ }
185
+ : {
186
+ // Maintainer ruling (empty-menu collapse): the one non-gate composer.
187
+ // Same id and route as the old change request (plain /api/feedback,
188
+ // no approval framing) — only the copy is new. Free prose, NOT frozen.
189
+ id: 'request-changes',
190
+ label: 'Send a note…',
191
+ subtitle: 'Write a note and send it as feedback',
192
+ tone: 'primary',
193
+ icon: 'send',
194
+ dividerBefore: false,
195
+ composer: {
196
+ title: 'Send a note',
197
+ actionLabel: 'Send as feedback',
198
+ tone: 'primary',
199
+ icon: 'send',
200
+ placeholder: DECISION_NOTE_PLACEHOLDER,
201
+ },
202
+ };
203
+
204
+ return {
205
+ primary: approvalFlow
206
+ ? {
207
+ id: 'primary',
208
+ // Frozen copy (maintainer-approved): 'Approve'.
209
+ label: 'Approve',
210
+ title: 'Approve: no changes requested',
211
+ tone: 'success',
212
+ icon: 'check',
213
+ }
214
+ : {
215
+ id: 'primary',
216
+ // Frozen copy (maintainer-approved): 'Done'.
217
+ label: 'Done',
218
+ // M1 ruling: in the agent-terminal delivered state the transport is
219
+ // unchanged (the full payload still posts, because the outer agent
220
+ // on stdout may never have seen the terminal delivery), so the
221
+ // tooltip must not claim "no feedback". Free prose, NOT frozen.
222
+ title: input.feedbackDelivered
223
+ ? 'Finish: sends the session record (feedback already shared in the terminal)'
224
+ : 'Finish: records that you reviewed with no feedback',
225
+ // Maintainer ruling (post-demo): Done without a gate is a positive
226
+ // finish, NOT an approval — no success tone, no check icon, so it
227
+ // can never be mistaken for the gate/review Approve.
228
+ tone: 'neutral',
229
+ },
230
+ items: positive ? [positive, requestChanges] : [requestChanges],
231
+ };
232
+ }
233
+
234
+ /**
235
+ * The feedback state: something to send, the primary is `Send Feedback`.
236
+ * Identical across apps; only the discard item's verb follows the flow.
237
+ */
238
+ function buildFeedbackSpec(input: DecisionSpecInput, approvalFlow: boolean): DecisionSpec {
239
+ const { count } = input;
240
+ const noun = annotationNoun(count);
241
+
242
+ const items: DecisionMenuItem[] = [
243
+ {
244
+ id: 'note-with-feedback',
245
+ label: 'Send with a note…',
246
+ subtitle:
247
+ count > 0
248
+ ? `Add an overall note on top of your ${count} ${noun}`
249
+ : 'Add an overall note on top of your feedback',
250
+ tone: 'primary',
251
+ icon: 'send',
252
+ composer: {
253
+ title: 'Send with a note',
254
+ actionLabel: 'Send feedback with note',
255
+ tone: 'primary',
256
+ icon: 'send',
257
+ placeholder: DECISION_NOTE_PLACEHOLDER,
258
+ },
259
+ },
260
+ ];
261
+
262
+ // The divider separates "send" alternates from "approve away" alternates —
263
+ // it sits before whichever approve-flavoured item comes first.
264
+ let dividerPending = true;
265
+
266
+ // Capability-gated only (F2 ruling, maintainer-confirmed): at count 0 the feedback is direct edits / saved-file
267
+ // changes / attachments, and a capable approve transport delivers those too,
268
+ // so the item stays offered with zero-form copy — no "0 annotations"
269
+ // language. Subtitles are free prose, NOT frozen.
270
+ if (approvalFlow && input.approvalNotesSupported) {
271
+ items.push({
272
+ id: 'approve-with-notes',
273
+ // Frozen copy (maintainer-approved): 'Approve with notes'.
274
+ label: 'Approve with notes',
275
+ subtitle:
276
+ count > 0
277
+ ? `Approve; your ${count} ${noun} ride along as non-blocking guidance`
278
+ : 'Approve; your edits and attachments ride along as non-blocking guidance',
279
+ tone: 'success',
280
+ icon: 'check',
281
+ dividerBefore: dividerPending,
282
+ });
283
+ dividerPending = false;
284
+ }
285
+
286
+ // Destructive by definition — it throws the annotations away — so it always
287
+ // carries the one confirm the new model keeps. With count 0 there is nothing
288
+ // to discard and the item is omitted.
289
+ if (count > 0) {
290
+ items.push({
291
+ id: 'discard-and-finish',
292
+ label: approvalFlow
293
+ ? `Approve, discard ${count} ${noun}…`
294
+ : `Done, discard ${count} ${noun}…`,
295
+ subtitle: 'Asks to confirm: the annotations are not sent',
296
+ tone: 'destructive',
297
+ icon: 'check',
298
+ dividerBefore: dividerPending,
299
+ // L5: neutral wording — the count can include findings from other
300
+ // tools, and the non-gate record still carries any direct edits.
301
+ // Free prose, NOT frozen.
302
+ confirm: approvalFlow
303
+ ? {
304
+ title: `Discard ${count} ${noun} and approve?`,
305
+ message:
306
+ 'These annotations are change requests, including any from other tools. Approving without them tells the agent no changes are needed.',
307
+ // Frozen copy (maintainer-approved): 'Discard & approve'.
308
+ confirmText: 'Discard & approve',
309
+ }
310
+ : {
311
+ title: `Discard ${count} ${noun} and finish?`,
312
+ message:
313
+ 'These annotations are change requests, including any from other tools. Finishing without them sends a positive review record; any direct edits still ride along.',
314
+ // Frozen copy (maintainer-approved): 'Discard & finish'.
315
+ confirmText: 'Discard & finish',
316
+ },
317
+ });
318
+ }
319
+
320
+ return {
321
+ primary: {
322
+ id: 'primary',
323
+ // Frozen copy (maintainer-approved): 'Send Feedback'.
324
+ label: 'Send Feedback',
325
+ shortLabel: 'Send',
326
+ mobileLabel: 'Send feedback',
327
+ title: 'Send your feedback to the agent',
328
+ tone: 'primary',
329
+ icon: 'send',
330
+ count: count > 0 ? count : undefined,
331
+ },
332
+ items,
333
+ };
334
+ }
335
+
336
+ /**
337
+ * The platform (PR) arm — PR6, §3.4, per the approved DESIGN_header-pr-mode
338
+ * mock. Reuses the agent ids so the handler Record stays closed, but every
339
+ * item is composer-less and confirm-less: labels tell the reviewer which mode
340
+ * the ReviewSubmissionDialog opens in, nothing more. `discard-and-finish` is
341
+ * never emitted — the dialog owns what happens to unsent annotations.
342
+ */
343
+ function buildPlatformSpec(input: DecisionSpecInput, platform: DecisionPlatformInput): DecisionSpec {
344
+ const { count } = input;
345
+ const { selfAuthored } = platform;
346
+ const noun = platform.mrLabel === 'MR' ? 'merge request' : 'pull request';
347
+ // Frozen copy (maintainer-approved): the self-approval mute reason.
348
+ const selfReason = `You can't approve your own ${noun} on ${platform.label}.`;
349
+ const selfReasonShort = `You can't approve your own ${platform.mrLabel}`;
350
+
351
+ if (!input.hasFeedback) {
352
+ return {
353
+ primary: {
354
+ id: 'primary',
355
+ // Frozen copy (maintainer-approved): 'Approve'.
356
+ label: 'Approve',
357
+ title: selfAuthored ? selfReason : 'Approve: no changes needed',
358
+ tone: 'success',
359
+ icon: 'check',
360
+ ...(selfAuthored ? { muted: true } : {}),
361
+ },
362
+ items: [
363
+ {
364
+ id: 'note-with-approval',
365
+ // Approved design (PR6 detail confirmed): the empty-state platform
366
+ // menu is "Approve with a comment…" + "Request changes…".
367
+ label: 'Approve with a comment…',
368
+ subtitle: selfAuthored
369
+ ? selfReasonShort
370
+ : 'Opens the submission dialog; the comment rides the review body',
371
+ tone: 'success',
372
+ icon: 'check',
373
+ ...(selfAuthored ? { muted: true } : {}),
374
+ },
375
+ {
376
+ id: 'request-changes',
377
+ // Frozen copy (maintainer-approved): 'Request changes…'.
378
+ label: 'Request changes…',
379
+ subtitle: 'Overall feedback, zero line comments, via the dialog',
380
+ tone: 'primary',
381
+ icon: 'send',
382
+ dividerBefore: true,
383
+ },
384
+ ],
385
+ };
386
+ }
387
+
388
+ return {
389
+ primary: {
390
+ id: 'primary',
391
+ // Frozen copy (maintainer-approved): 'Post Comments'.
392
+ label: 'Post Comments',
393
+ shortLabel: 'Post',
394
+ mobileLabel: 'Post comments',
395
+ title: `Post review to ${platform.label}`,
396
+ tone: 'primary',
397
+ icon: 'send',
398
+ count: count > 0 ? count : undefined,
399
+ },
400
+ items: [
401
+ {
402
+ id: 'approve-with-notes',
403
+ label: 'Approve with comments…',
404
+ subtitle: selfAuthored ? selfReasonShort : 'Submission dialog in approve mode',
405
+ tone: 'success',
406
+ icon: 'check',
407
+ ...(selfAuthored ? { muted: true } : {}),
408
+ },
409
+ {
410
+ id: 'note-with-feedback',
411
+ label: 'Post comments, then…',
412
+ subtitle: 'Request changes or stay neutral, chosen in the dialog',
413
+ tone: 'primary',
414
+ icon: 'send',
415
+ dividerBefore: true,
416
+ },
417
+ ],
418
+ };
419
+ }
420
+
421
+ export function buildDecisionSpec(input: DecisionSpecInput): DecisionSpec {
422
+ // PR6 (§3.4): the platform destination maps onto the same spec shape with
423
+ // no composer items; presence of the arm selects it outright.
424
+ if (input.platform) return buildPlatformSpec(input, input.platform);
425
+ // Review's primary positive decision IS approval, gate flag or not.
426
+ const approvalFlow = input.app === 'review' || input.gate;
427
+ return input.hasFeedback
428
+ ? buildFeedbackSpec(input, approvalFlow)
429
+ : buildEmptySpec(input, approvalFlow);
430
+ }
package/utils/platform.ts CHANGED
@@ -8,4 +8,20 @@ export const isMac = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(
8
8
  export const modKey = isMac ? '⌘' : 'Ctrl';
9
9
  export const altKey = isMac ? '⌥' : 'Alt';
10
10
  export const submitHint = isMac ? '⌘↵' : 'Ctrl+Enter';
11
+ /**
12
+ * The primary modifier spelled for PROSE ("Cmd+click"), where `modKey`'s ⌘
13
+ * glyph reads as an artifact rather than a word. Use this in sentences;
14
+ * use `modKey` in key hints and shortcut chips.
15
+ */
16
+ export const modKeyWord = isMac ? 'Cmd' : 'Ctrl';
17
+ /**
18
+ * The primary modifier's own `KeyboardEvent.key` name, for features that arm
19
+ * on the modifier being HELD rather than on a chord. Pair with `isModKeyHeld`:
20
+ * this identifies the key itself, that reads the held flag off any event.
21
+ */
22
+ export const modEventKey = isMac ? 'Meta' : 'Control';
23
+ /** Whether the primary modifier is down for this event, on this platform. */
24
+ export function isModKeyHeld(event: { metaKey: boolean; ctrlKey: boolean }): boolean {
25
+ return isMac ? event.metaKey : event.ctrlKey;
26
+ }
11
27
  export const isWindows = typeof navigator !== 'undefined' && /^Win/.test(navigator.platform);
@@ -142,7 +142,7 @@ export function scrollVimTargetIntoView(
142
142
 
143
143
  const margin = resolveVimScrollMargin(viewportRect.height);
144
144
  const stickyBottom = viewport
145
- .querySelector<HTMLElement>('[data-sticky-actions]')
145
+ .querySelector<HTMLElement>('[data-viewer-document-header], [data-sticky-actions]')
146
146
  ?.getBoundingClientRect().bottom;
147
147
  const topMargin = stickyBottom !== undefined
148
148
  ? Math.max(margin, stickyBottom - viewportRect.top + 8)
@@ -1,34 +0,0 @@
1
- <!DOCTYPE html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="UTF-8" />
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
- <title>Sprite Animation</title>
7
- <style>
8
- body {
9
- display: flex;
10
- justify-content: center;
11
- align-items: center;
12
- height: 100vh;
13
- background-color: #f0f0f0;
14
- margin: 0;
15
- }
16
- .animation {
17
- width: 176px;
18
- height: 96px;
19
- background: url("sprite.png") left center;
20
- animation: play 5s steps(24) infinite;
21
- /* image-rendering: pixelated; */ /* Uncomment if resizing up */
22
- }
23
-
24
- @keyframes play {
25
- to {
26
- background-position: -4224px 0;
27
- }
28
- }
29
- </style>
30
- </head>
31
- <body>
32
- <div class="animation"></div>
33
- </body>
34
- </html>
@@ -1,34 +0,0 @@
1
- <!DOCTYPE html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="UTF-8" />
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
- <title>Sprite Animation</title>
7
- <style>
8
- body {
9
- display: flex;
10
- justify-content: center;
11
- align-items: center;
12
- height: 100vh;
13
- background-color: #f0f0f0;
14
- margin: 0;
15
- }
16
- .animation {
17
- width: 96px;
18
- height: 96px;
19
- background: url("sprite.png") left center;
20
- animation: play 3s steps(12) infinite;
21
- /* image-rendering: pixelated; */ /* Uncomment if resizing up */
22
- }
23
-
24
- @keyframes play {
25
- to {
26
- background-position: -1152px 0;
27
- }
28
- }
29
- </style>
30
- </head>
31
- <body>
32
- <div class="animation"></div>
33
- </body>
34
- </html>
@@ -1,34 +0,0 @@
1
- <!DOCTYPE html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="UTF-8" />
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
- <title>Sprite Animation</title>
7
- <style>
8
- body {
9
- display: flex;
10
- justify-content: center;
11
- align-items: center;
12
- height: 100vh;
13
- background-color: #f0f0f0;
14
- margin: 0;
15
- }
16
- .animation {
17
- width: 96px;
18
- height: 96px;
19
- background: url("sprite.png") left center;
20
- animation: play 3s steps(24) infinite;
21
- /* image-rendering: pixelated; */ /* Uncomment if resizing up */
22
- }
23
-
24
- @keyframes play {
25
- to {
26
- background-position: -2304px 0;
27
- }
28
- }
29
- </style>
30
- </head>
31
- <body>
32
- <div class="animation"></div>
33
- </body>
34
- </html>