@plannotator/ui 0.53.1 → 0.54.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.
- package/HANDOFF.md +217 -11
- package/README.md +65 -10
- package/components/AgentToolAnnouncementDialog.tsx +1 -1
- package/components/AgentsTab.tsx +1 -1
- package/components/AnnotationPanel.tsx +142 -24
- package/components/AnnotationSidebar.tsx +1 -1
- package/components/AnnotationToolbar.tsx +64 -9
- package/components/ApproveDropdown.tsx +7 -9
- package/components/AskSessionAnnouncementDialog.tsx +1 -1
- package/components/AttachmentsButton.tsx +1 -1
- package/components/BlockRenderer.tsx +19 -5
- package/components/CodeFilePopout.tsx +139 -4
- package/components/CommentPopover.tsx +44 -8
- package/components/DecisionControl.tsx +31 -5
- package/components/DiagramBlock.tsx +17 -12
- package/components/EditorAnnotationCard.tsx +1 -1
- package/components/ExportModal.tsx +77 -7
- package/components/HostTitle.tsx +0 -4
- package/components/ImageAnnotator/Toolbar.tsx +25 -1
- package/components/ImageAnnotator/index.tsx +24 -4
- package/components/ImageLightbox.tsx +658 -120
- package/components/ImagePeek.tsx +38 -4
- package/components/ImageRegions.tsx +369 -0
- package/components/ImageThumbnail.tsx +1 -1
- package/components/ImageZoomStage.tsx +796 -0
- package/components/InboxAnnouncementDialog.tsx +202 -0
- package/components/InlineMarkdown.tsx +152 -24
- package/components/KeyboardShortcuts.tsx +23 -0
- package/components/LazyBlockBoundary.tsx +100 -0
- package/components/ListMarker.tsx +3 -3
- package/components/LookAndFeelAnnouncementDialog.tsx +12 -2
- package/components/MenuVersionSection.tsx +31 -9
- package/components/PlanDecisionMenuExtras.tsx +129 -0
- package/components/PlanHeaderMenu.tsx +15 -1
- package/components/ProviderIcons.tsx +8 -0
- package/components/RefHoverCard.tsx +913 -0
- package/components/SendFailureToast.tsx +126 -0
- package/components/Settings.tsx +617 -126
- package/components/TerminalToolsAnnouncementDialog.tsx +1 -1
- package/components/ThemeProvider.tsx +14 -1
- package/components/ThemeTab.tsx +16 -24
- package/components/Tooltip.tsx +49 -18
- package/components/Viewer.tsx +280 -41
- package/components/ai/DocumentAIChatPanel.tsx +29 -3
- package/components/ai/NewChatButton.tsx +74 -0
- package/components/blocks/CodeBlock.tsx +1 -1
- package/components/blocks/HtmlBlock.tsx +7 -1
- package/components/blocks/QuestionBlock.tsx +522 -62
- package/components/diagram/DiagramComposer.tsx +5 -1
- package/components/diagram/DiagramPending.tsx +12 -2
- package/components/diagram/DiagramViewer.tsx +19 -3
- package/components/html-embed/HtmlEmbedBlock.tsx +374 -0
- package/components/html-embed/SvgFenceBlock.tsx +46 -0
- package/components/html-embed/index.ts +3 -0
- package/components/html-embed/languages.ts +15 -0
- package/components/html-embed/loadGate.ts +107 -0
- package/components/html-viewer/HtmlViewer.tsx +47 -6
- package/components/html-viewer/bridge-script.asset.js +173 -1
- package/components/html-viewer/bridge-script.ts +173 -1
- package/components/html-viewer/useHtmlAnnotation.ts +125 -0
- package/components/imageZoom.ts +265 -0
- package/components/plan-diff/PlanCleanDiffView.tsx +1 -1
- package/components/plan-diff/PlanDiffViewer.tsx +13 -2
- package/components/plan-diff/PlanRawDiffView.tsx +1 -1
- package/components/sidebar/FileBrowser.tsx +16 -2
- package/components/sidebar/SidebarTabs.tsx +3 -0
- package/config/index.ts +3 -0
- package/config/reviewView.ts +41 -1
- package/config/settings.ts +155 -4
- package/configure.ts +28 -0
- package/hooks/pfm/useCodeFilePopout.ts +7 -1
- package/hooks/useAIChat.ts +72 -25
- package/hooks/useAnnotationDraft.ts +71 -6
- package/hooks/useAnnotationHighlighter.ts +57 -3
- package/hooks/useCodeAnnotationDraft.ts +113 -17
- package/hooks/useCookieHostSetting.ts +96 -0
- package/hooks/useDocumentFont.ts +17 -0
- package/hooks/useDocumentTextSize.ts +20 -0
- package/hooks/useFileBrowser.ts +10 -1
- package/hooks/useLinkedDoc.ts +226 -13
- package/hooks/usePackageChangesSetting.ts +90 -0
- package/hooks/usePlanLookDialog.ts +67 -0
- package/hooks/useSharing.ts +11 -7
- package/hooks/useThemePaletteHalves.ts +37 -0
- package/hooks/useUpdateCheck.ts +22 -4
- package/hooks/useUpdateToast.ts +1 -1
- package/package.json +23 -22
- package/shortcuts/code-review/annotationToolbar.shortcuts.ts +1 -0
- package/shortcuts/code-review/explorer.shortcuts.ts +82 -0
- package/shortcuts/core.ts +63 -4
- package/shortcuts/index.ts +1 -0
- package/shortcuts/plan-review/annotationPanel.shortcuts.ts +1 -1
- package/shortcuts/plan-review/commentPopover.shortcuts.ts +1 -0
- package/shortcuts/runtime.ts +4 -3
- package/styles.css +1 -1
- package/theme.css +179 -1
- package/themes/plannotator-graphite.css +66 -0
- package/types.ts +26 -9
- package/utils/agentSwitch.ts +41 -20
- package/utils/agentToolSetting.ts +1 -1
- package/utils/aiChatFormat.ts +23 -7
- package/utils/annotationOwnsHighlight.ts +4 -2
- package/utils/asciidocParser.ts +570 -0
- package/utils/autoUpdateNotice.ts +24 -0
- package/utils/belowTitleSlot.ts +174 -0
- package/utils/codeLineScroll.ts +100 -0
- package/utils/commentSubmitKey.ts +48 -0
- package/utils/cssFontFamily.ts +65 -0
- package/utils/decisionSpec.ts +190 -2
- package/utils/defaultNotesApp.ts +1 -1
- package/utils/diffFonts.ts +4 -1
- package/utils/documentFont.ts +68 -0
- package/utils/documentTextSize.ts +54 -0
- package/utils/fontDetect.ts +63 -0
- package/utils/inboxAnnouncement.ts +78 -0
- package/utils/lookAndFeelAnnouncement.ts +82 -5
- package/utils/markdownExtensions.ts +2 -2
- package/utils/mermaid.ts +20 -3
- package/utils/mermaidTheme.ts +3 -51
- package/utils/notion.ts +58 -0
- package/utils/parser.ts +28 -1
- package/utils/platform.ts +46 -0
- package/utils/refCard.ts +82 -0
- package/utils/richClipboard.ts +380 -0
- package/utils/richClipboardLists.ts +57 -0
- package/utils/richCopyPolicy.ts +22 -0
- package/utils/scrollAnchor.ts +82 -0
- package/utils/sectionRefs.ts +123 -0
- package/utils/selectionBoundary.ts +40 -0
- package/utils/sendFailure.ts +85 -0
- package/utils/sharing.ts +5 -2
- package/utils/themeRegistry.ts +10 -0
- package/utils/themeTokens.ts +74 -0
- package/utils/updateNotice.ts +30 -8
- package/utils/vscodePanel.ts +9 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import React, { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { ArrowUpRight, Play } from 'lucide-react';
|
|
3
|
+
import { Dialog, DialogContent, DialogDescription, DialogTitle } from './ui/dialog';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* One-time announcement for the Plannotator Inbox, on plan review, annotate
|
|
7
|
+
* and code review. Nearly full screen: the Inbox film from plannotator.ai/inbox
|
|
8
|
+
* fills the top, under it one title, one sentence, "Close" and one link to the
|
|
9
|
+
* Inbox page.
|
|
10
|
+
*
|
|
11
|
+
* Built on the shared dialog (`./ui/dialog`, Base UI): modal, focus trapped,
|
|
12
|
+
* focus restored on close, the backdrop and Escape close it. Like the other
|
|
13
|
+
* announcements it swallows Mod+Enter on the capture phase, so a keystroke
|
|
14
|
+
* aimed at it cannot approve a plan or post a review behind it.
|
|
15
|
+
*
|
|
16
|
+
* The film is the marketing page's own file (`apps/marketing/public/assets/
|
|
17
|
+
* inbox-film.mp4`, 1920x1080, ~14 MB, voice-led), served from
|
|
18
|
+
* plannotator.ai/assets like the other announcements' footage. It never
|
|
19
|
+
* autoplays and is `preload="none"`: opening the dialog fetches only the poster
|
|
20
|
+
* (~120 KB), and the film downloads when the reader presses play. Until then
|
|
21
|
+
* the frame is the poster with one play button, as on the Inbox page; after
|
|
22
|
+
* that the native controls take over (sound, seeking, full screen). If the
|
|
23
|
+
* film cannot load, the frame says so and the rest of the dialog is unchanged.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** The Inbox page on plannotator.ai. */
|
|
27
|
+
export const INBOX_PAGE_URL = 'https://plannotator.ai/inbox';
|
|
28
|
+
|
|
29
|
+
/** Where the marketing deploy publishes `apps/marketing/public/assets/`. */
|
|
30
|
+
const MEDIA_BASE_URL = 'https://plannotator.ai/assets';
|
|
31
|
+
|
|
32
|
+
export const INBOX_FILM = {
|
|
33
|
+
mp4: `${MEDIA_BASE_URL}/inbox-film.mp4`,
|
|
34
|
+
poster: `${MEDIA_BASE_URL}/inbox-film-poster.jpg`,
|
|
35
|
+
} as const;
|
|
36
|
+
|
|
37
|
+
/** The film is 1920x1080. */
|
|
38
|
+
const FILM_ASPECT = 16 / 9;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The panel's width also follows the viewport height, so on a short window the
|
|
42
|
+
* film shrinks until film and footer fit instead of scrolling the film away.
|
|
43
|
+
* 2rem is the overlay's padding, 10rem the footer on a wide panel.
|
|
44
|
+
*/
|
|
45
|
+
const PANEL_WIDTH =
|
|
46
|
+
`min(1200px, 100%, calc((var(--pn-viewport-height, 100dvh) - 2rem - 10rem) * ${FILM_ASPECT}))`;
|
|
47
|
+
|
|
48
|
+
interface InboxAnnouncementDialogProps {
|
|
49
|
+
readonly isOpen: boolean;
|
|
50
|
+
/**
|
|
51
|
+
* Marks the announcement seen and closes it. Wired to Close, Escape, the
|
|
52
|
+
* backdrop and the link to the Inbox page.
|
|
53
|
+
*/
|
|
54
|
+
readonly onDismiss: () => void;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function Film() {
|
|
58
|
+
const videoRef = useRef<HTMLVideoElement>(null);
|
|
59
|
+
const [started, setStarted] = useState(false);
|
|
60
|
+
const [failed, setFailed] = useState(false);
|
|
61
|
+
|
|
62
|
+
const play = () => {
|
|
63
|
+
setStarted(true);
|
|
64
|
+
// A rejected play() (autoplay policy, no media) leaves the native
|
|
65
|
+
// controls up, which offer play again.
|
|
66
|
+
void videoRef.current?.play()?.catch(() => {});
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<div className="relative w-full shrink-0 overflow-hidden border-b border-border bg-muted" style={{ aspectRatio: String(FILM_ASPECT) }}>
|
|
71
|
+
<video
|
|
72
|
+
ref={videoRef}
|
|
73
|
+
data-inbox-film
|
|
74
|
+
poster={INBOX_FILM.poster}
|
|
75
|
+
controls={started}
|
|
76
|
+
playsInline
|
|
77
|
+
preload="none"
|
|
78
|
+
aria-label="The Plannotator Inbox film"
|
|
79
|
+
// A source that cannot be fetched errors on <source>; a file that was
|
|
80
|
+
// fetched but cannot be decoded errors on the element itself.
|
|
81
|
+
onError={() => setFailed(true)}
|
|
82
|
+
className="absolute inset-0 h-full w-full bg-muted object-contain"
|
|
83
|
+
>
|
|
84
|
+
<source src={INBOX_FILM.mp4} type="video/mp4" onError={() => setFailed(true)} />
|
|
85
|
+
</video>
|
|
86
|
+
|
|
87
|
+
{failed ? (
|
|
88
|
+
<p
|
|
89
|
+
data-inbox-film-unavailable
|
|
90
|
+
className="absolute inset-0 grid place-items-center bg-background/75 px-6 text-center text-sm text-muted-foreground backdrop-blur-sm"
|
|
91
|
+
>
|
|
92
|
+
The film could not load. It is also on the Inbox page.
|
|
93
|
+
</p>
|
|
94
|
+
) : !started ? (
|
|
95
|
+
<button
|
|
96
|
+
type="button"
|
|
97
|
+
onClick={play}
|
|
98
|
+
aria-label="Play the Inbox film"
|
|
99
|
+
data-inbox-film-play
|
|
100
|
+
className="group absolute inset-0 grid place-items-center outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary"
|
|
101
|
+
>
|
|
102
|
+
<span className="grid size-16 place-items-center rounded-full border border-border/60 bg-background/80 text-foreground shadow-lg backdrop-blur-sm transition-transform motion-reduce:transition-none group-hover:scale-105 sm:size-20">
|
|
103
|
+
<Play className="ml-1 size-6 fill-current sm:size-7" aria-hidden="true" />
|
|
104
|
+
</span>
|
|
105
|
+
</button>
|
|
106
|
+
) : null}
|
|
107
|
+
</div>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function InboxAnnouncementDialog({ isOpen, onDismiss }: InboxAnnouncementDialogProps) {
|
|
112
|
+
const closeRef = useRef<HTMLButtonElement>(null);
|
|
113
|
+
const onDismissRef = useRef(onDismiss);
|
|
114
|
+
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
onDismissRef.current = onDismiss;
|
|
117
|
+
}, [onDismiss]);
|
|
118
|
+
|
|
119
|
+
useEffect(() => {
|
|
120
|
+
if (!isOpen) return;
|
|
121
|
+
const handleKeyDown = (event: KeyboardEvent) => {
|
|
122
|
+
// Escape is handled here rather than by the dialog so that no window
|
|
123
|
+
// handler of the App behind it (close a panel, leave a mode) sees it.
|
|
124
|
+
if (event.key === 'Escape') {
|
|
125
|
+
event.preventDefault();
|
|
126
|
+
event.stopPropagation();
|
|
127
|
+
onDismissRef.current();
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
// Both apps submit their decision on Mod+Enter from a window-level
|
|
131
|
+
// handler; swallow it on the capture phase.
|
|
132
|
+
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
|
|
133
|
+
event.preventDefault();
|
|
134
|
+
event.stopPropagation();
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
document.addEventListener('keydown', handleKeyDown, true);
|
|
138
|
+
return () => document.removeEventListener('keydown', handleKeyDown, true);
|
|
139
|
+
}, [isOpen]);
|
|
140
|
+
|
|
141
|
+
if (!isOpen) return null;
|
|
142
|
+
|
|
143
|
+
return (
|
|
144
|
+
<Dialog
|
|
145
|
+
open
|
|
146
|
+
onOpenChange={(open) => {
|
|
147
|
+
if (!open) onDismissRef.current();
|
|
148
|
+
}}
|
|
149
|
+
>
|
|
150
|
+
<DialogContent
|
|
151
|
+
hideClose
|
|
152
|
+
initialFocus={closeRef}
|
|
153
|
+
data-inbox-announcement-dialog
|
|
154
|
+
backdropClassName="bg-background/90 backdrop-blur-sm"
|
|
155
|
+
style={{ width: PANEL_WIDTH }}
|
|
156
|
+
className="max-h-full max-w-none rounded-xl bg-card text-card-foreground shadow-2xl motion-reduce:transition-none"
|
|
157
|
+
>
|
|
158
|
+
<Film />
|
|
159
|
+
|
|
160
|
+
<div className="flex min-h-0 flex-col gap-5 overflow-y-auto px-5 py-5 sm:flex-row sm:items-end sm:justify-between sm:gap-8 sm:px-7 sm:py-6">
|
|
161
|
+
<div className="min-w-0">
|
|
162
|
+
<DialogTitle className="text-balance text-xl font-semibold tracking-tight sm:text-2xl">
|
|
163
|
+
The Plannotator Inbox
|
|
164
|
+
</DialogTitle>
|
|
165
|
+
<DialogDescription className="mt-1.5 max-w-xl text-pretty leading-relaxed">
|
|
166
|
+
Questions, blockers and reviews from your agents arrive in one inbox while they work,
|
|
167
|
+
and your answers go back to them.
|
|
168
|
+
</DialogDescription>
|
|
169
|
+
</div>
|
|
170
|
+
|
|
171
|
+
<div className="flex shrink-0 flex-col-reverse gap-2 sm:flex-row sm:items-center">
|
|
172
|
+
<button
|
|
173
|
+
ref={closeRef}
|
|
174
|
+
type="button"
|
|
175
|
+
data-inbox-announcement-close
|
|
176
|
+
onClick={() => onDismissRef.current()}
|
|
177
|
+
className="min-h-10 rounded-lg border border-border px-4 text-sm font-medium text-foreground outline-none transition-colors motion-reduce:transition-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-card"
|
|
178
|
+
>
|
|
179
|
+
Close
|
|
180
|
+
</button>
|
|
181
|
+
<a
|
|
182
|
+
href={INBOX_PAGE_URL}
|
|
183
|
+
target="_blank"
|
|
184
|
+
rel="noopener noreferrer"
|
|
185
|
+
data-inbox-announcement-learn-more
|
|
186
|
+
// Following the link counts as seeing the announcement. Closed on
|
|
187
|
+
// the next task: a link removed from the page during its own click
|
|
188
|
+
// is not followed.
|
|
189
|
+
onClick={() => {
|
|
190
|
+
setTimeout(() => onDismissRef.current(), 0);
|
|
191
|
+
}}
|
|
192
|
+
className="inline-flex min-h-10 items-center justify-center gap-1.5 rounded-lg bg-primary px-5 text-sm font-medium text-primary-foreground outline-none transition-opacity motion-reduce:transition-none hover:opacity-90 focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-card"
|
|
193
|
+
>
|
|
194
|
+
Learn about the Inbox
|
|
195
|
+
<ArrowUpRight className="size-4" aria-hidden="true" />
|
|
196
|
+
</a>
|
|
197
|
+
</div>
|
|
198
|
+
</div>
|
|
199
|
+
</DialogContent>
|
|
200
|
+
</Dialog>
|
|
201
|
+
);
|
|
202
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import React, { useState, useRef, useCallback, useEffect, useMemo } from "react";
|
|
2
2
|
import { createPortal } from "react-dom";
|
|
3
3
|
import { isCodeFilePath, isCodeFilePathStrict, CODE_PATH_BARE_REGEX, parseCodePath } from "@plannotator/core/code-file";
|
|
4
|
-
import { forgeRefLinks } from "@plannotator/core/forge-refs";
|
|
4
|
+
import { findCrossRepoRef, forgeRefLinks, matchCrossRepoRef, type CrossRepoRef } from "@plannotator/core/forge-refs";
|
|
5
5
|
import { ensureHighlight, highlightToHtml } from "../utils/codeHighlight";
|
|
6
6
|
import { useFenceTheme } from "../hooks/useFenceTheme";
|
|
7
7
|
import { transformPlainText } from "../utils/inlineTransforms";
|
|
@@ -380,6 +380,34 @@ export function trimUrlTail(url: string): string {
|
|
|
380
380
|
return url;
|
|
381
381
|
}
|
|
382
382
|
|
|
383
|
+
// Index of the first bare `http(s)://` URL that starts after index 0 and
|
|
384
|
+
// before `end` at a word boundary AND runs past `end`, or -1. Only such a
|
|
385
|
+
// URL needs the chunk split: one that ends inside the chunk is linked by
|
|
386
|
+
// emitPlainTextWithBareUrls in the same pass, exactly as before, so a
|
|
387
|
+
// paragraph of many URLs stays one pass instead of a loop turn (and a
|
|
388
|
+
// rescan of the rest of the text) per URL. Index 0 is left to the caller's
|
|
389
|
+
// own bare-URL check, which already ran for it.
|
|
390
|
+
const BARE_URL_SCHEME = /https?:\/\//g;
|
|
391
|
+
const BARE_URL_AT = /https?:\/\/[^\s<>"']+/y;
|
|
392
|
+
function bareUrlRunningPast(text: string, end: number): number {
|
|
393
|
+
// Only a scheme that starts before `end` counts, so read no further than
|
|
394
|
+
// `end` plus the longest scheme ("https://" less its first character).
|
|
395
|
+
const window = text.slice(0, end + 7);
|
|
396
|
+
BARE_URL_SCHEME.lastIndex = 1;
|
|
397
|
+
let m: RegExpExecArray | null;
|
|
398
|
+
while ((m = BARE_URL_SCHEME.exec(window)) !== null && m.index < end) {
|
|
399
|
+
if (/\w/.test(window[m.index - 1])) continue;
|
|
400
|
+
BARE_URL_AT.lastIndex = m.index;
|
|
401
|
+
const url = BARE_URL_AT.exec(text);
|
|
402
|
+
if (!url) continue;
|
|
403
|
+
const urlEnd = m.index + trimUrlTail(url[0]).length;
|
|
404
|
+
if (urlEnd > end) return m.index;
|
|
405
|
+
// The URL ends inside the chunk; look for the next one after it.
|
|
406
|
+
BARE_URL_SCHEME.lastIndex = Math.max(urlEnd, m.index + 1);
|
|
407
|
+
}
|
|
408
|
+
return -1;
|
|
409
|
+
}
|
|
410
|
+
|
|
383
411
|
// Scan a plain-text chunk for bare https?:// URLs and bare code file paths
|
|
384
412
|
// at word boundaries, emitting them as interactive nodes. Surrounding text
|
|
385
413
|
// passes through transformPlainText for emoji shortcodes + smart punctuation.
|
|
@@ -474,35 +502,73 @@ function emitPlainTextWithBareUrls(
|
|
|
474
502
|
}
|
|
475
503
|
}
|
|
476
504
|
|
|
477
|
-
/**
|
|
505
|
+
/**
|
|
506
|
+
* An image a host resolver chose; a load failure shows the placeholder.
|
|
507
|
+
*
|
|
508
|
+
* The `<img>` element itself never leaves the document: on failure the
|
|
509
|
+
* placeholder is inserted BEFORE it (React's reference node is then the
|
|
510
|
+
* `<img>`, which it owns) and the `<img>` is hidden. Replacing the `<img>`
|
|
511
|
+
* would make React insert before the node that follows it, and when that is
|
|
512
|
+
* a text node the annotation highlighter already moved into a `<mark>` (a
|
|
513
|
+
* restored comment starting right after the image), `insertBefore` throws
|
|
514
|
+
* and the whole document unmounts. A loaded image's DOM is unchanged.
|
|
515
|
+
*/
|
|
478
516
|
const HostResolvedImage: React.FC<{
|
|
479
517
|
src: string;
|
|
480
518
|
alt: string;
|
|
519
|
+
/** The src as written in the markdown (`data-image-src`, read by the image viewer's region comments). */
|
|
520
|
+
writtenSrc?: string;
|
|
481
521
|
onImageClick?: (src: string, alt: string) => void;
|
|
482
|
-
}> = ({ src, alt, onImageClick }) => {
|
|
522
|
+
}> = ({ src, alt, writtenSrc, onImageClick }) => {
|
|
483
523
|
const [failedSrc, setFailedSrc] = useState<string | null>(null);
|
|
484
|
-
|
|
485
|
-
return (
|
|
486
|
-
<span className={UNAVAILABLE_IMAGE_CLASS} title="Image could not be loaded" data-image-unavailable="">
|
|
487
|
-
{alt || 'Image'}
|
|
488
|
-
</span>
|
|
489
|
-
);
|
|
490
|
-
}
|
|
524
|
+
const failed = failedSrc === src;
|
|
491
525
|
return (
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
526
|
+
<>
|
|
527
|
+
{failed && (
|
|
528
|
+
<span key="unavailable" className={`${UNAVAILABLE_IMAGE_CLASS} annotation-exclude`} title="Image could not be loaded" data-image-unavailable="">
|
|
529
|
+
{alt || 'Image'}
|
|
530
|
+
</span>
|
|
531
|
+
)}
|
|
532
|
+
<img
|
|
533
|
+
key="image"
|
|
534
|
+
src={src}
|
|
535
|
+
alt={failed ? '' : alt}
|
|
536
|
+
data-image-src={writtenSrc}
|
|
537
|
+
data-image-failed={failed ? '' : undefined}
|
|
538
|
+
hidden={failed || undefined}
|
|
539
|
+
aria-hidden={failed || undefined}
|
|
540
|
+
className={failed ? undefined : 'max-w-full rounded my-2 cursor-zoom-in'}
|
|
541
|
+
loading="lazy"
|
|
542
|
+
onError={() => setFailedSrc(src)}
|
|
543
|
+
onClick={(e) => {
|
|
544
|
+
e.stopPropagation();
|
|
545
|
+
if (!failed) onImageClick?.(src, alt);
|
|
546
|
+
}}
|
|
547
|
+
/>
|
|
548
|
+
</>
|
|
503
549
|
);
|
|
504
550
|
};
|
|
505
551
|
|
|
552
|
+
/**
|
|
553
|
+
* Whether a digit-only 6/8-digit `#123456` is an issue ref (when a ref link can
|
|
554
|
+
* be built) rather than a hex swatch. Off by default: every host renders such
|
|
555
|
+
* values as colours. The Plannotator Inbox turns it on through
|
|
556
|
+
* `configurePlannotatorUI({ digitHexIssueRefs: true })` (#1932).
|
|
557
|
+
*/
|
|
558
|
+
let digitHexIssueRefs = false;
|
|
559
|
+
|
|
560
|
+
export function setDigitHexIssueRefs(on: boolean): void {
|
|
561
|
+
digitHexIssueRefs = on;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
export function resetDigitHexIssueRefs(): void {
|
|
565
|
+
digitHexIssueRefs = false;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
export function getDigitHexIssueRefs(): boolean {
|
|
569
|
+
return digitHexIssueRefs;
|
|
570
|
+
}
|
|
571
|
+
|
|
506
572
|
/**
|
|
507
573
|
* Scanner that walks a text string and emits React nodes for inline markdown:
|
|
508
574
|
* emphasis (**bold**, *italic*, _italic_, ***both***), `code`, ~~strikethrough~~,
|
|
@@ -543,6 +609,13 @@ export const InlineMarkdown: React.FC<{
|
|
|
543
609
|
const resolveLocalLink = useLocalLinkResolver();
|
|
544
610
|
const resolveImage = useImageResolver();
|
|
545
611
|
const refLinks = forgeRefLinks({ display: githubRepo, host: repoHost });
|
|
612
|
+
// Cross-repo `owner/repo#123` refs: only for a caller that names its
|
|
613
|
+
// project's repo (it knows the forge); otherwise they stay plain text, never
|
|
614
|
+
// a guessed github.com link. With a code-file handler, an `owner/repo` that
|
|
615
|
+
// is a code path not known to be missing stays the file (`src/app.js#12`).
|
|
616
|
+
const crossRepoProject = githubRepo ? { host: repoHost } : null;
|
|
617
|
+
const crossRepoAccept = (ref: CrossRepoRef): boolean =>
|
|
618
|
+
!(onOpenCodeFile && isCodeFilePathStrict(ref.path) && gateCodePath(ref.path, validation ?? null).render !== 'plain');
|
|
546
619
|
const parts: React.ReactNode[] = [];
|
|
547
620
|
let remaining = text;
|
|
548
621
|
let key = 0;
|
|
@@ -650,6 +723,32 @@ export const InlineMarkdown: React.FC<{
|
|
|
650
723
|
}
|
|
651
724
|
}
|
|
652
725
|
|
|
726
|
+
// Cross-repo ref: owner/repo#123, shown as written, linked on the
|
|
727
|
+
// project's forge host. Read before file-path detection, so
|
|
728
|
+
// `vercel/next.js#1` is a ref, not a path (unless the viewer knows that
|
|
729
|
+
// file, see crossRepoAccept); `docs/guide.md#2` stays a path.
|
|
730
|
+
const crossRepoRef = crossRepoProject ? matchCrossRepoRef(remaining, previousChar, crossRepoProject) : null;
|
|
731
|
+
if (crossRepoRef && crossRepoAccept(crossRepoRef)) {
|
|
732
|
+
parts.push(
|
|
733
|
+
crossRepoRef.href ? (
|
|
734
|
+
<a
|
|
735
|
+
key={key++}
|
|
736
|
+
href={crossRepoRef.href}
|
|
737
|
+
target="_blank"
|
|
738
|
+
rel="noopener noreferrer"
|
|
739
|
+
className="text-primary font-medium hover:underline"
|
|
740
|
+
>
|
|
741
|
+
{crossRepoRef.label}
|
|
742
|
+
</a>
|
|
743
|
+
) : (
|
|
744
|
+
crossRepoRef.label
|
|
745
|
+
),
|
|
746
|
+
);
|
|
747
|
+
remaining = remaining.slice(crossRepoRef.label.length);
|
|
748
|
+
previousChar = crossRepoRef.label[crossRepoRef.label.length - 1];
|
|
749
|
+
continue;
|
|
750
|
+
}
|
|
751
|
+
|
|
653
752
|
// Autolinks: <https://url> or <email@domain.com>
|
|
654
753
|
match = remaining.match(/^<(https?:\/\/[^>]+)>/);
|
|
655
754
|
if (match) {
|
|
@@ -846,9 +945,19 @@ export const InlineMarkdown: React.FC<{
|
|
|
846
945
|
}
|
|
847
946
|
|
|
848
947
|
// Hex color swatch — 3/4-digit forms need an a-f letter to avoid matching issue refs like #123.
|
|
948
|
+
// 6/8-digit forms with no letter (#186379) are ambiguous. By default they
|
|
949
|
+
// stay swatches (greys like #333333 are common in docs; six-digit issues are
|
|
950
|
+
// rare). A host that opts in with `configurePlannotatorUI({ digitHexIssueRefs:
|
|
951
|
+
// true })` (the Plannotator Inbox, #1932) gets an issue ref instead, but only
|
|
952
|
+
// when a ref link can actually be built (`refLinks`: a repo path on a known
|
|
953
|
+
// forge) and the ref branch below would take it: a word boundary and no
|
|
954
|
+
// leading zero (issue numbers never start with 0, so #000000 stays a colour).
|
|
849
955
|
match = remaining.match(
|
|
850
956
|
/^(#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|(?=[0-9a-fA-F]*[a-fA-F])[0-9a-fA-F]{4}|(?=[0-9a-fA-F]*[a-fA-F])[0-9a-fA-F]{3}))(?![0-9a-fA-F\w])/,
|
|
851
957
|
);
|
|
958
|
+
if (match && digitHexIssueRefs && refLinks && /^#[1-9]\d*$/.test(match[1]) && !/\w/.test(previousChar)) {
|
|
959
|
+
match = null;
|
|
960
|
+
}
|
|
852
961
|
if (match) {
|
|
853
962
|
const hex = match[1];
|
|
854
963
|
parts.push(
|
|
@@ -872,8 +981,8 @@ export const InlineMarkdown: React.FC<{
|
|
|
872
981
|
}
|
|
873
982
|
|
|
874
983
|
// Issue / PR reference: #123 — only at word boundary, digits only.
|
|
875
|
-
// Hex-swatch branch above already consumed #fff / #123abc etc
|
|
876
|
-
// #\d+ here is
|
|
984
|
+
// Hex-swatch branch above already consumed #fff / #123abc etc. (and digit-only
|
|
985
|
+
// #123456 / #12345678 unless the host opted in), so a bare #\d+ here is an issue ref.
|
|
877
986
|
if (!/\w/.test(previousChar)) {
|
|
878
987
|
match = remaining.match(/^#(\d+)(?!\w)/);
|
|
879
988
|
if (match) {
|
|
@@ -1027,12 +1136,13 @@ export const InlineMarkdown: React.FC<{
|
|
|
1027
1136
|
resolveImage ? (
|
|
1028
1137
|
// With a host resolver, an image that fails to load becomes the
|
|
1029
1138
|
// same placeholder instead of a broken-image icon.
|
|
1030
|
-
<HostResolvedImage key={key++} src={imgSrc} alt={alt} onImageClick={onImageClick} />
|
|
1139
|
+
<HostResolvedImage key={key++} src={imgSrc} alt={alt} writtenSrc={src} onImageClick={onImageClick} />
|
|
1031
1140
|
) : (
|
|
1032
1141
|
<img
|
|
1033
1142
|
key={key++}
|
|
1034
1143
|
src={imgSrc}
|
|
1035
1144
|
alt={alt}
|
|
1145
|
+
data-image-src={src}
|
|
1036
1146
|
className="max-w-full rounded my-2 cursor-zoom-in"
|
|
1037
1147
|
loading="lazy"
|
|
1038
1148
|
onClick={(e) => {
|
|
@@ -1272,6 +1382,24 @@ export const InlineMarkdown: React.FC<{
|
|
|
1272
1382
|
// multi-char patterns like emoji shortcodes. Bare URLs are instead
|
|
1273
1383
|
// detected inline via emitPlainTextWithBareUrls() below.
|
|
1274
1384
|
const nextSpecial = remaining.slice(1).search(/[\*_`\[!~\\<#@$]/);
|
|
1385
|
+
// A bare URL that starts inside this plain-text chunk and runs past its
|
|
1386
|
+
// end would be cut there, and `_`, `~`, `#`, `@`, `$` and `!` all end a
|
|
1387
|
+
// chunk (`…/w/ws_01…` rendered a link to `…/w/ws`). Stop the chunk where
|
|
1388
|
+
// that URL starts instead, so the bare-URL branch above takes it whole.
|
|
1389
|
+
const chunkEnd = nextSpecial === -1 ? remaining.length : nextSpecial + 1;
|
|
1390
|
+
// Same for an owner/repo#123 ref: `#` (and `_` in a name) ends a chunk, so
|
|
1391
|
+
// stop before the ref and let the cross-repo branch above take it whole.
|
|
1392
|
+
// A ref inside a URL never splits it: the URL starts first and wins.
|
|
1393
|
+
const urlAt = bareUrlRunningPast(remaining, chunkEnd);
|
|
1394
|
+
const refAt = crossRepoProject ? findCrossRepoRef(remaining, urlAt === -1 ? chunkEnd : urlAt, crossRepoProject, crossRepoAccept) : -1;
|
|
1395
|
+
const splitAt = refAt !== -1 ? refAt : urlAt;
|
|
1396
|
+
if (splitAt !== -1) {
|
|
1397
|
+
const before = remaining.slice(0, splitAt);
|
|
1398
|
+
emitPlainTextWithBareUrls(before, previousChar, parts, () => key++, onOpenCodeFile, validation, imageBaseDir);
|
|
1399
|
+
previousChar = before[before.length - 1] || previousChar;
|
|
1400
|
+
remaining = remaining.slice(splitAt);
|
|
1401
|
+
continue;
|
|
1402
|
+
}
|
|
1275
1403
|
const plainText = nextSpecial === -1 ? remaining : remaining.slice(0, nextSpecial + 1);
|
|
1276
1404
|
emitPlainTextWithBareUrls(plainText, previousChar, parts, () => key++, onOpenCodeFile, validation, imageBaseDir);
|
|
1277
1405
|
previousChar = plainText[plainText.length - 1] || previousChar;
|
|
@@ -2,7 +2,11 @@ import React from 'react';
|
|
|
2
2
|
import {
|
|
3
3
|
formatShortcutBindingTokens,
|
|
4
4
|
listScopeShortcuts,
|
|
5
|
+
resolveShortcutBindings,
|
|
6
|
+
reviewExplorerShortcuts,
|
|
7
|
+
reviewMarkdownPreviewShortcuts,
|
|
5
8
|
vimSelectionShortcuts,
|
|
9
|
+
type ShortcutScopeDefinition,
|
|
6
10
|
} from '../shortcuts';
|
|
7
11
|
import { isMac, modKey, altKey } from '../utils/platform';
|
|
8
12
|
|
|
@@ -141,6 +145,23 @@ const vimShortcuts: ShortcutSection[] = (() => {
|
|
|
141
145
|
return Array.from(sections, ([title, shortcuts]) => ({ title, shortcuts }));
|
|
142
146
|
})();
|
|
143
147
|
|
|
148
|
+
/** Sections read from registry scopes (this system's keys), so they cannot drift. */
|
|
149
|
+
function scopeSections(scope: ShortcutScopeDefinition<any>): ShortcutSection[] {
|
|
150
|
+
const sections = new Map<string, Shortcut[]>();
|
|
151
|
+
const entries = listScopeShortcuts(scope)
|
|
152
|
+
.sort((left, right) => (left.displayOrder ?? 0) - (right.displayOrder ?? 0));
|
|
153
|
+
for (const entry of entries) {
|
|
154
|
+
const shortcuts = sections.get(entry.section) ?? [];
|
|
155
|
+
shortcuts.push({
|
|
156
|
+
keys: formatShortcutBindingTokens(resolveShortcutBindings(entry)[0] ?? ''),
|
|
157
|
+
desc: entry.description,
|
|
158
|
+
hint: entry.hint,
|
|
159
|
+
});
|
|
160
|
+
sections.set(entry.section, shortcuts);
|
|
161
|
+
}
|
|
162
|
+
return Array.from(sections, ([title, shortcuts]) => ({ title, shortcuts }));
|
|
163
|
+
}
|
|
164
|
+
|
|
144
165
|
const planActionShortcuts: ShortcutSection = {
|
|
145
166
|
title: 'Actions',
|
|
146
167
|
shortcuts: [
|
|
@@ -213,6 +234,8 @@ const reviewShortcuts: ShortcutSection[] = [
|
|
|
213
234
|
{ keys: ['Z'], desc: 'Undo collapse', hint: 'Reopens the last collapsed file and scrolls to it' },
|
|
214
235
|
],
|
|
215
236
|
},
|
|
237
|
+
...scopeSections(reviewExplorerShortcuts),
|
|
238
|
+
...scopeSections(reviewMarkdownPreviewShortcuts),
|
|
216
239
|
{
|
|
217
240
|
title: 'Annotations',
|
|
218
241
|
shortcuts: [
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import React, { useEffect, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A part of the page that a code-split build loads as its own chunk (a
|
|
5
|
+
* diagram, an HTML or SVG embed, the diagram popout, the Markdown preview)
|
|
6
|
+
* can fail to arrive: a flaky link, a server that has stopped, or a tab older
|
|
7
|
+
* than the server's build, whose chunk names no longer exist. React.lazy then
|
|
8
|
+
* throws, and without a boundary React unmounts the whole app, taking the
|
|
9
|
+
* user's unsent comments and edits off the screen with it.
|
|
10
|
+
*
|
|
11
|
+
* This boundary keeps the failure in place of that one part. The caller's
|
|
12
|
+
* `fallback` gets a small notice with a Reload action (`LazyLoadNotice`) and
|
|
13
|
+
* draws it beside whatever it can still show, usually the block's source.
|
|
14
|
+
* The rest of the page stays mounted. Nothing reloads by itself.
|
|
15
|
+
*
|
|
16
|
+
* With no error it renders its children and nothing else.
|
|
17
|
+
*/
|
|
18
|
+
export class LazyBlockBoundary extends React.Component<
|
|
19
|
+
{ fallback: (notice: React.ReactNode) => React.ReactNode; children: React.ReactNode },
|
|
20
|
+
{ failed: boolean; error: unknown }
|
|
21
|
+
> {
|
|
22
|
+
state = { failed: false, error: null as unknown };
|
|
23
|
+
|
|
24
|
+
static getDerivedStateFromError(error: unknown) {
|
|
25
|
+
return { failed: true, error };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
componentDidCatch(error: unknown) {
|
|
29
|
+
console.error('[plannotator] A part of the page could not load:', error);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
render() {
|
|
33
|
+
if (this.state.failed) return this.props.fallback(<LazyLoadNotice error={this.state.error} />);
|
|
34
|
+
return this.props.children;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The URL of the chunk a failed dynamic import asked for, when the error names
|
|
40
|
+
* one on this page's origin. Chromium and Firefox put it in the message
|
|
41
|
+
* ("Failed to fetch dynamically imported module: http://…/assets/x.js");
|
|
42
|
+
* Safari does not, and then the notice says the general thing.
|
|
43
|
+
*/
|
|
44
|
+
export function failedChunkUrl(error: unknown): string | null {
|
|
45
|
+
if (typeof window === 'undefined') return null;
|
|
46
|
+
const message = error instanceof Error ? error.message : typeof error === 'string' ? error : '';
|
|
47
|
+
const match = /(https?:\/\/[^\s'"]+|\/assets\/[^\s'"]+)/.exec(message);
|
|
48
|
+
if (!match) return null;
|
|
49
|
+
try {
|
|
50
|
+
const url = new URL(match[1], window.location.href);
|
|
51
|
+
return url.origin === window.location.origin ? url.href : null;
|
|
52
|
+
} catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* One quiet line: why the part is missing, and Reload. A chunk the server
|
|
59
|
+
* answers with 404 is a tab older than the server's build (the server was
|
|
60
|
+
* updated on the same port), so the line says that instead. Finding out costs
|
|
61
|
+
* one HEAD request, made only after a failure.
|
|
62
|
+
*/
|
|
63
|
+
export const LazyLoadNotice: React.FC<{ error: unknown }> = ({ error }) => {
|
|
64
|
+
const [updated, setUpdated] = useState(false);
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
const url = failedChunkUrl(error);
|
|
67
|
+
if (url === null || typeof fetch !== 'function') return;
|
|
68
|
+
let live = true;
|
|
69
|
+
fetch(url, { method: 'HEAD', cache: 'no-store' }).then(
|
|
70
|
+
(response) => {
|
|
71
|
+
if (live && response.status === 404) setUpdated(true);
|
|
72
|
+
},
|
|
73
|
+
() => {},
|
|
74
|
+
);
|
|
75
|
+
return () => {
|
|
76
|
+
live = false;
|
|
77
|
+
};
|
|
78
|
+
}, [error]);
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<div
|
|
82
|
+
role="status"
|
|
83
|
+
data-lazy-load-failed={updated ? 'updated' : ''}
|
|
84
|
+
className="mb-1.5 flex flex-wrap items-center gap-x-1.5 text-xs text-muted-foreground"
|
|
85
|
+
>
|
|
86
|
+
<span>{updated ? 'This page was updated.' : "Couldn't load this view."}</span>
|
|
87
|
+
{/* The hit area is padding cancelled by a negative margin, so the line
|
|
88
|
+
looks the same: 24px tall everywhere, 44px on a touch screen. On
|
|
89
|
+
touch it grows upward into the block's top margin, never down over
|
|
90
|
+
the source under it. */}
|
|
91
|
+
<button
|
|
92
|
+
type="button"
|
|
93
|
+
onClick={() => window.location.reload()}
|
|
94
|
+
className="relative -mx-1 -my-1 px-1 py-1 font-medium text-foreground underline-offset-2 hover:underline [@media(pointer:coarse)]:-mb-1.5 [@media(pointer:coarse)]:-mt-[22px] [@media(pointer:coarse)]:pb-1.5 [@media(pointer:coarse)]:pt-[22px]"
|
|
95
|
+
>
|
|
96
|
+
{updated ? 'Reload to see this' : 'Reload'}
|
|
97
|
+
</button>
|
|
98
|
+
</div>
|
|
99
|
+
);
|
|
100
|
+
};
|
|
@@ -60,17 +60,17 @@ export const ListMarker: React.FC<ListMarkerProps> = ({
|
|
|
60
60
|
aria-checked={interactive ? checked : undefined}
|
|
61
61
|
>
|
|
62
62
|
{showNumeral && (
|
|
63
|
-
<span className="text-primary/60 tabular-nums text-right" style={{ minWidth: '1.5rem' }}>
|
|
63
|
+
<span className="text-primary/60 tabular-nums text-right" style={{ minWidth: '1.5rem' }} data-list-numeral="">
|
|
64
64
|
{orderedIndex}.
|
|
65
65
|
</span>
|
|
66
66
|
)}
|
|
67
67
|
{isCheckbox ? (
|
|
68
68
|
checked ? (
|
|
69
|
-
<svg className="w-4 h-4 text-success mt-[3px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
|
|
69
|
+
<svg data-list-check="" className="w-4 h-4 text-success mt-[3px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
|
|
70
70
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
71
71
|
</svg>
|
|
72
72
|
) : (
|
|
73
|
-
<svg className={`w-4 h-4 text-muted-foreground/50 mt-[3px]${interactive ? ' hover:text-muted-foreground transition-colors' : ''}`} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
|
73
|
+
<svg data-list-check="" className={`w-4 h-4 text-muted-foreground/50 mt-[3px]${interactive ? ' hover:text-muted-foreground transition-colors' : ''}`} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
|
74
74
|
<circle cx="12" cy="12" r="9" />
|
|
75
75
|
</svg>
|
|
76
76
|
)
|
|
@@ -12,6 +12,12 @@ interface LookAndFeelAnnouncementDialogProps {
|
|
|
12
12
|
onToggleGrid: (value: boolean) => void;
|
|
13
13
|
/** Persists the current choice and closes the dialog. */
|
|
14
14
|
onDismiss: () => void;
|
|
15
|
+
/**
|
|
16
|
+
* `first-run` (default): the first-use choice. `moved`: the one-time notice
|
|
17
|
+
* for someone moved from Grid to Clean when Clean became the default; the
|
|
18
|
+
* same chooser, with a line saying what changed.
|
|
19
|
+
*/
|
|
20
|
+
variant?: 'first-run' | 'moved';
|
|
15
21
|
}
|
|
16
22
|
|
|
17
23
|
const LOOK_OPTIONS: {
|
|
@@ -48,6 +54,7 @@ export const LookAndFeelAnnouncementDialog: React.FC<LookAndFeelAnnouncementDial
|
|
|
48
54
|
gridEnabled,
|
|
49
55
|
onToggleGrid,
|
|
50
56
|
onDismiss,
|
|
57
|
+
variant = 'first-run',
|
|
51
58
|
}) => {
|
|
52
59
|
const dialogRef = useRef<HTMLDivElement>(null);
|
|
53
60
|
const continueRef = useRef<HTMLButtonElement>(null);
|
|
@@ -115,6 +122,7 @@ export const LookAndFeelAnnouncementDialog: React.FC<LookAndFeelAnnouncementDial
|
|
|
115
122
|
<div
|
|
116
123
|
ref={dialogRef}
|
|
117
124
|
role="dialog"
|
|
125
|
+
data-plan-look-prompt={variant}
|
|
118
126
|
aria-modal="true"
|
|
119
127
|
aria-labelledby="plan-look-choice-title"
|
|
120
128
|
aria-describedby="plan-look-choice-description"
|
|
@@ -129,13 +137,15 @@ export const LookAndFeelAnnouncementDialog: React.FC<LookAndFeelAnnouncementDial
|
|
|
129
137
|
id="plan-look-choice-title"
|
|
130
138
|
className="text-balance text-xl font-semibold tracking-tight sm:text-2xl"
|
|
131
139
|
>
|
|
132
|
-
Choose how plans look
|
|
140
|
+
{variant === 'moved' ? 'Plans now open in Clean' : 'Choose how plans look'}
|
|
133
141
|
</h2>
|
|
134
142
|
<p
|
|
135
143
|
id="plan-look-choice-description"
|
|
136
144
|
className="mt-1 text-sm leading-relaxed text-muted-foreground"
|
|
137
145
|
>
|
|
138
|
-
|
|
146
|
+
{variant === 'moved'
|
|
147
|
+
? "Grid is one switch away in Settings, or pick it here."
|
|
148
|
+
: "Pick a starting view. You can change it anytime in Settings."}
|
|
139
149
|
</p>
|
|
140
150
|
</header>
|
|
141
151
|
|