@plannotator/ui 0.53.1 → 0.55.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 (149) hide show
  1. package/HANDOFF.md +274 -14
  2. package/README.md +114 -11
  3. package/components/AgentToolAnnouncementDialog.tsx +1 -1
  4. package/components/AgentsTab.tsx +1 -1
  5. package/components/AnnotationPanel.tsx +142 -24
  6. package/components/AnnotationSidebar.tsx +1 -1
  7. package/components/AnnotationToolbar.tsx +64 -9
  8. package/components/ApproveDropdown.tsx +7 -9
  9. package/components/AskSessionAnnouncementDialog.tsx +1 -1
  10. package/components/AttachmentsButton.tsx +1 -1
  11. package/components/BlockRenderer.tsx +22 -5
  12. package/components/CodeFilePopout.tsx +139 -4
  13. package/components/CodeSnippetPreview.tsx +174 -0
  14. package/components/CommentPopover.tsx +44 -8
  15. package/components/DecisionControl.tsx +31 -5
  16. package/components/DeferredMarkdownContentsRail.tsx +38 -0
  17. package/components/DiagramBlock.tsx +17 -12
  18. package/components/DiffFileTree.tsx +7 -6
  19. package/components/DocumentContents.tsx +74 -0
  20. package/components/EditorAnnotationCard.tsx +1 -1
  21. package/components/ExportModal.tsx +77 -7
  22. package/components/HostTitle.tsx +0 -4
  23. package/components/ImageAnnotator/Toolbar.tsx +25 -1
  24. package/components/ImageAnnotator/index.tsx +24 -4
  25. package/components/ImageLightbox.tsx +722 -126
  26. package/components/ImagePeek.tsx +38 -4
  27. package/components/ImageRegions.tsx +369 -0
  28. package/components/ImageThumbnail.tsx +1 -1
  29. package/components/ImageZoomStage.tsx +796 -0
  30. package/components/InboxAnnouncementDialog.tsx +202 -0
  31. package/components/InlineMarkdown.tsx +167 -174
  32. package/components/KeyboardShortcuts.tsx +23 -0
  33. package/components/LazyBlockBoundary.tsx +100 -0
  34. package/components/ListMarker.tsx +3 -3
  35. package/components/LookAndFeelAnnouncementDialog.tsx +12 -2
  36. package/components/MarkdownContentsRail.tsx +777 -0
  37. package/components/MenuVersionSection.tsx +31 -9
  38. package/components/PlanDecisionMenuExtras.tsx +129 -0
  39. package/components/PlanHeaderMenu.tsx +15 -1
  40. package/components/ProviderIcons.tsx +8 -0
  41. package/components/RefHoverCard.tsx +913 -0
  42. package/components/SendFailureToast.tsx +126 -0
  43. package/components/Settings.tsx +617 -126
  44. package/components/TableOfContents.tsx +20 -6
  45. package/components/TerminalToolsAnnouncementDialog.tsx +1 -1
  46. package/components/ThemeProvider.tsx +14 -1
  47. package/components/ThemeTab.tsx +16 -24
  48. package/components/Tooltip.tsx +49 -18
  49. package/components/Viewer.tsx +288 -43
  50. package/components/ai/DocumentAIChatPanel.tsx +29 -3
  51. package/components/ai/NewChatButton.tsx +74 -0
  52. package/components/blocks/CodeBlock.tsx +1 -1
  53. package/components/blocks/HtmlBlock.tsx +7 -1
  54. package/components/blocks/QuestionBlock.tsx +532 -62
  55. package/components/diagram/DiagramComposer.tsx +5 -1
  56. package/components/diagram/DiagramPending.tsx +12 -2
  57. package/components/diagram/DiagramViewer.tsx +19 -3
  58. package/components/html-embed/HtmlEmbedBlock.tsx +374 -0
  59. package/components/html-embed/SvgFenceBlock.tsx +46 -0
  60. package/components/html-embed/index.ts +3 -0
  61. package/components/html-embed/languages.ts +15 -0
  62. package/components/html-embed/loadGate.ts +107 -0
  63. package/components/html-viewer/HtmlViewer.tsx +82 -6
  64. package/components/html-viewer/bridge-script.asset.js +173 -1
  65. package/components/html-viewer/bridge-script.ts +173 -1
  66. package/components/html-viewer/useHtmlAnnotation.ts +125 -0
  67. package/components/imageZoom.ts +276 -0
  68. package/components/plan-diff/PlanCleanDiffView.tsx +1 -1
  69. package/components/plan-diff/PlanDiffViewer.tsx +13 -2
  70. package/components/plan-diff/PlanRawDiffView.tsx +1 -1
  71. package/components/sidebar/FileBrowser.tsx +16 -2
  72. package/components/sidebar/SidebarTabs.tsx +3 -0
  73. package/config/index.ts +3 -0
  74. package/config/reviewView.ts +41 -1
  75. package/config/settings.ts +155 -4
  76. package/configure.ts +28 -0
  77. package/contents-rail.css +337 -0
  78. package/hooks/pfm/useCodeFilePopout.ts +7 -1
  79. package/hooks/useAIChat.ts +72 -25
  80. package/hooks/useAnnotationDraft.ts +71 -6
  81. package/hooks/useAnnotationHighlighter.ts +57 -3
  82. package/hooks/useCodeAnnotationDraft.ts +113 -17
  83. package/hooks/useCookieHostSetting.ts +96 -0
  84. package/hooks/useDocumentFont.ts +17 -0
  85. package/hooks/useDocumentTextSize.ts +20 -0
  86. package/hooks/useFileBrowser.ts +10 -1
  87. package/hooks/useHeadingScrollSpy.ts +77 -0
  88. package/hooks/useHoverPreview.ts +94 -0
  89. package/hooks/useLinkedDoc.ts +226 -13
  90. package/hooks/usePackageChangesSetting.ts +90 -0
  91. package/hooks/usePlanLookDialog.ts +67 -0
  92. package/hooks/usePrefersReducedMotion.ts +23 -0
  93. package/hooks/useSharing.ts +11 -7
  94. package/hooks/useSwipeStep.ts +597 -0
  95. package/hooks/useThemePaletteHalves.ts +37 -0
  96. package/hooks/useUpdateCheck.ts +22 -4
  97. package/hooks/useUpdateToast.ts +1 -1
  98. package/package.json +26 -23
  99. package/shortcuts/code-review/annotationToolbar.shortcuts.ts +1 -0
  100. package/shortcuts/code-review/explorer.shortcuts.ts +82 -0
  101. package/shortcuts/core.ts +63 -4
  102. package/shortcuts/index.ts +1 -0
  103. package/shortcuts/plan-review/annotationPanel.shortcuts.ts +1 -1
  104. package/shortcuts/plan-review/commentPopover.shortcuts.ts +1 -0
  105. package/shortcuts/runtime.ts +4 -3
  106. package/styles.css +1 -1
  107. package/theme.css +179 -1
  108. package/themes/plannotator-graphite.css +66 -0
  109. package/types.ts +26 -9
  110. package/utils/agentSwitch.ts +41 -20
  111. package/utils/agentToolSetting.ts +1 -1
  112. package/utils/aiChatFormat.ts +23 -7
  113. package/utils/annotationHelpers.ts +32 -4
  114. package/utils/annotationOwnsHighlight.ts +4 -2
  115. package/utils/asciidocParser.ts +570 -0
  116. package/utils/autoUpdateNotice.ts +24 -0
  117. package/utils/belowTitleSlot.ts +174 -0
  118. package/utils/blockParse.ts +45 -0
  119. package/utils/codeLineScroll.ts +100 -0
  120. package/utils/commentSubmitKey.ts +48 -0
  121. package/utils/cssFontFamily.ts +65 -0
  122. package/utils/decisionSpec.ts +190 -2
  123. package/utils/defaultNotesApp.ts +1 -1
  124. package/utils/diffFonts.ts +4 -1
  125. package/utils/documentFont.ts +68 -0
  126. package/utils/documentTextSize.ts +54 -0
  127. package/utils/fontDetect.ts +63 -0
  128. package/utils/headingScrollSpy.ts +92 -0
  129. package/utils/inboxAnnouncement.ts +78 -0
  130. package/utils/lookAndFeelAnnouncement.ts +82 -5
  131. package/utils/markdownExtensions.ts +2 -2
  132. package/utils/mermaid.ts +20 -3
  133. package/utils/mermaidTheme.ts +3 -51
  134. package/utils/notion.ts +58 -0
  135. package/utils/parser.ts +28 -1
  136. package/utils/platform.ts +46 -0
  137. package/utils/refCard.ts +82 -0
  138. package/utils/richClipboard.ts +380 -0
  139. package/utils/richClipboardLists.ts +57 -0
  140. package/utils/richCopyPolicy.ts +22 -0
  141. package/utils/scrollAnchor.ts +82 -0
  142. package/utils/sectionRefs.ts +123 -0
  143. package/utils/selectionBoundary.ts +40 -0
  144. package/utils/sendFailure.ts +85 -0
  145. package/utils/sharing.ts +5 -2
  146. package/utils/themeRegistry.ts +10 -0
  147. package/utils/themeTokens.ts +74 -0
  148. package/utils/updateNotice.ts +30 -8
  149. package/utils/vscodePanel.ts +9 -0
@@ -0,0 +1,68 @@
1
+ /**
2
+ * The document's font in plan review and annotate (Settings → Display →
3
+ * Document font, #1657): the family the document's text is drawn in. Empty by
4
+ * default, which keeps the theme's font (the document as it has always been
5
+ * drawn).
6
+ *
7
+ * The value is one of the generic presets below or the family name of a font
8
+ * installed on the machine, as for the code font (#851). A name always goes
9
+ * through `quoteFontFamily`, and the theme's sans font follows it, so a name
10
+ * that is not installed falls back to today's look.
11
+ *
12
+ * The app puts it on the document element (`applyDocumentFont`, through
13
+ * `hooks/useDocumentFont`) and packages/ui/theme.css hands it to the blocks of
14
+ * a document container (`data-doc-text-root`), the same scope as Text size.
15
+ * Code (inline code, code blocks, the raw diff) keeps the monospace font, and
16
+ * diagrams and the table / diagram popouts are left out.
17
+ */
18
+
19
+ import { normalizeFontFamilyName, quoteFontFamily } from './cssFontFamily';
20
+
21
+ /** Generic stacks Settings offers by name. Every one always resolves. */
22
+ export const DOCUMENT_FONT_PRESETS = {
23
+ serif: 'ui-serif, Georgia, Cambria, "Times New Roman", Times, serif',
24
+ 'sans-serif': 'ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif',
25
+ monospace: 'var(--font-mono), ui-monospace, monospace',
26
+ } as const;
27
+
28
+ /** The options of the Settings select, Theme Default first. */
29
+ export const DOCUMENT_FONT_OPTIONS = [
30
+ { value: '', label: 'Theme Default' },
31
+ { value: 'serif', label: 'Serif' },
32
+ { value: 'sans-serif', label: 'Sans Serif' },
33
+ { value: 'monospace', label: 'Monospace' },
34
+ ] as const;
35
+
36
+ /** The attribute theme.css keys on; present only away from the default. */
37
+ export const DOCUMENT_FONT_ATTRIBUTE = 'data-doc-font';
38
+
39
+ /** The `font-family` value theme.css hands to the document's blocks. */
40
+ export const DOCUMENT_FONT_PROPERTY = '--doc-font-family';
41
+
42
+ /**
43
+ * The `font-family` value for a stored setting: a preset's stack, or the
44
+ * quoted name followed by the theme's sans font. Empty for the default.
45
+ */
46
+ export function documentFontStack(value: string | undefined): string {
47
+ const name = value ? normalizeFontFamilyName(value) : '';
48
+ if (!name) return '';
49
+ if (Object.hasOwn(DOCUMENT_FONT_PRESETS, name)) {
50
+ return DOCUMENT_FONT_PRESETS[name as keyof typeof DOCUMENT_FONT_PRESETS];
51
+ }
52
+ return `${quoteFontFamily(name)}, var(--font-sans), sans-serif`;
53
+ }
54
+
55
+ /**
56
+ * Put `value` on `root` (the document element). At the default both the
57
+ * attribute and the property are removed, so nothing in theme.css applies.
58
+ */
59
+ export function applyDocumentFont(root: HTMLElement, value: string | undefined): void {
60
+ const stack = documentFontStack(value);
61
+ if (!stack) {
62
+ root.removeAttribute(DOCUMENT_FONT_ATTRIBUTE);
63
+ root.style.removeProperty(DOCUMENT_FONT_PROPERTY);
64
+ return;
65
+ }
66
+ root.setAttribute(DOCUMENT_FONT_ATTRIBUTE, '');
67
+ root.style.setProperty(DOCUMENT_FONT_PROPERTY, stack);
68
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * The document's text size in plan review and annotate (Settings → Display →
3
+ * Text size, #2066): the body text size in px, 15 by default (the document as
4
+ * it has always been drawn). Headings, lists, code, tables and question cards
5
+ * keep their proportions to it.
6
+ *
7
+ * The app puts it on the document element (`applyDocumentTextSize`, through
8
+ * `hooks/useDocumentTextSize`) and packages/ui/theme.css scales the blocks of
9
+ * a document container (`data-doc-text-root`) from `--doc-text-scale`. At the
10
+ * default nothing is set, so the page is exactly today's. Only the document's
11
+ * blocks scale: the header, panels, badges, menus and popouts keep their size.
12
+ * Raw HTML pages and diagrams draw themselves and do not follow it.
13
+ *
14
+ * Deliberately free of imports so the settings registry can read it on every
15
+ * surface (the guides.show viewer included).
16
+ */
17
+
18
+ /** The steps Settings offers, in px of body text. */
19
+ export const DOCUMENT_TEXT_SIZE_OPTIONS = [13, 15, 17, 19, 21] as const;
20
+
21
+ export type DocumentTextSize = (typeof DOCUMENT_TEXT_SIZE_OPTIONS)[number];
22
+
23
+ /** Today's body text size: paragraphs are `text-[15px]`. */
24
+ export const DEFAULT_DOCUMENT_TEXT_SIZE: DocumentTextSize = 15;
25
+
26
+ /** The attribute index.css keys on; present only away from the default. */
27
+ export const DOCUMENT_TEXT_SIZE_ATTRIBUTE = 'data-doc-text-size';
28
+
29
+ /** The multiplier index.css scales by (size / 15). */
30
+ export const DOCUMENT_TEXT_SCALE_PROPERTY = '--doc-text-scale';
31
+
32
+ /** Only an offered step is a size; anything else reads as the default. */
33
+ export function isDocumentTextSize(value: unknown): value is DocumentTextSize {
34
+ return (DOCUMENT_TEXT_SIZE_OPTIONS as readonly unknown[]).includes(value);
35
+ }
36
+
37
+ /** The scale a size draws the document at, relative to today's. */
38
+ export function documentTextScale(size: DocumentTextSize): number {
39
+ return size / DEFAULT_DOCUMENT_TEXT_SIZE;
40
+ }
41
+
42
+ /**
43
+ * Put `size` on `root` (the document element). At the default both the
44
+ * attribute and the property are removed, so nothing in index.css applies.
45
+ */
46
+ export function applyDocumentTextSize(root: HTMLElement, size: number): void {
47
+ if (!isDocumentTextSize(size) || size === DEFAULT_DOCUMENT_TEXT_SIZE) {
48
+ root.removeAttribute(DOCUMENT_TEXT_SIZE_ATTRIBUTE);
49
+ root.style.removeProperty(DOCUMENT_TEXT_SCALE_PROPERTY);
50
+ return;
51
+ }
52
+ root.setAttribute(DOCUMENT_TEXT_SIZE_ATTRIBUTE, String(size));
53
+ root.style.setProperty(DOCUMENT_TEXT_SCALE_PROPERTY, String(documentTextScale(size)));
54
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Local font detection via width measurement.
3
+ *
4
+ * Renders a glyph-diverse sample string with the candidate family in front of
5
+ * each generic fallback (monospace, serif, sans-serif) and compares measured
6
+ * widths against the fallback alone. If any pair differs, the browser resolved
7
+ * the candidate font — it is installed (or already loaded as a webfont).
8
+ *
9
+ * This observes actual rendering rather than querying font-availability APIs,
10
+ * so it is deterministic for a given machine + browser + installed-font set.
11
+ * `document.fonts.check()` is NOT used: its spec answers "would this render
12
+ * without a font load," which browsers answer inconsistently for families the
13
+ * page never registered.
14
+ *
15
+ * Known limits:
16
+ * - The name must be the family name as the OS reports it ("Iosevka Custom",
17
+ * not a file name).
18
+ * - A font installed mid-session is picked up on the next call (no event).
19
+ */
20
+
21
+ import { quoteFontFamily } from "./cssFontFamily";
22
+
23
+ const SAMPLE = "mmmmmmmmmmlli0O1Il@#WQ";
24
+ const SIZE = "72px";
25
+ const GENERICS = ["monospace", "serif", "sans-serif"] as const;
26
+
27
+ let ctx: CanvasRenderingContext2D | null | undefined;
28
+
29
+ function getContext(): CanvasRenderingContext2D | null {
30
+ if (ctx !== undefined) return ctx;
31
+ try {
32
+ ctx = document.createElement("canvas").getContext("2d");
33
+ } catch {
34
+ ctx = null;
35
+ }
36
+ return ctx;
37
+ }
38
+
39
+ /**
40
+ * Check whether a font family resolves on this machine.
41
+ *
42
+ * Returns `true` / `false` from width measurement, or `null` when measurement
43
+ * is unavailable (no canvas) — callers should treat `null` as "unknown" and
44
+ * skip any installed/missing hint.
45
+ */
46
+ export function isFontInstalled(
47
+ family: string,
48
+ context: Pick<CanvasRenderingContext2D, "font" | "measureText"> | null = getContext(),
49
+ ): boolean | null {
50
+ const quoted = quoteFontFamily(family);
51
+ if (!quoted) return false;
52
+
53
+ const c = context;
54
+ if (!c) return null;
55
+
56
+ for (const generic of GENERICS) {
57
+ c.font = `${SIZE} ${generic}`;
58
+ const base = c.measureText(SAMPLE).width;
59
+ c.font = `${SIZE} ${quoted}, ${generic}`;
60
+ if (c.measureText(SAMPLE).width !== base) return true;
61
+ }
62
+ return false;
63
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * The contents rail's scroll-spy: which heading is being read, from the
3
+ * headings' cached tops and the scroll position, on every scroll frame.
4
+ *
5
+ * Moved as it was from the Workspaces app's
6
+ * `apps/web/src/plannotator/MarkdownContentsRail.tsx` (approved 2026-10-03):
7
+ * `topInsideViewport`, `currentBinding` (the binary search) and the rule that
8
+ * the last heading is the current one at the document's end. One change: the
9
+ * activation line is 80 px below the top (it was 52), plan review's
10
+ * `TableOfContents` scroll offset, so a pick lands where plan review's does.
11
+ *
12
+ * Plan review's own list keeps `hooks/useActiveSection` (an
13
+ * IntersectionObserver band). One mechanism per surface: the rail, and a
14
+ * Contents list on the rail's surface (`DocumentContents`), use this one.
15
+ */
16
+
17
+ /** How far below the scroll element's top a heading becomes the current one. */
18
+ export const CONTENTS_ACTIVATION_OFFSET = 80;
19
+
20
+ /** A heading drawn in the scroll element, with its top inside the scrolled content. */
21
+ export interface HeadingBinding {
22
+ /** The heading's index in the caller's own list. */
23
+ readonly itemIndex: number;
24
+ readonly node: HTMLElement;
25
+ readonly top: number;
26
+ }
27
+
28
+ export function topInsideViewport(node: HTMLElement, viewport: HTMLElement): number {
29
+ const viewportRect = viewport.getBoundingClientRect();
30
+ return node.getBoundingClientRect().top - viewportRect.top + viewport.scrollTop;
31
+ }
32
+
33
+ /**
34
+ * Bind each listed heading id to its drawn heading (`[data-block-type="heading"]`
35
+ * with that `data-block-id`), sorted by top. Only the caller's own ids are
36
+ * bound, so a heading of an embedded document (its ids are namespaced) is never
37
+ * read; when an id is drawn twice, the first one in document order is.
38
+ */
39
+ export function bindHeadings(
40
+ viewport: HTMLElement,
41
+ blockIds: readonly string[],
42
+ ): HeadingBinding[] {
43
+ const nodesByBlockId = new Map<string, HTMLElement>();
44
+ for (const node of viewport.querySelectorAll<HTMLElement>('[data-block-type="heading"]')) {
45
+ const id = node.dataset.blockId ?? '';
46
+ if (!nodesByBlockId.has(id)) nodesByBlockId.set(id, node);
47
+ }
48
+ const bindings = blockIds.flatMap<HeadingBinding>((blockId, itemIndex) => {
49
+ const node = nodesByBlockId.get(blockId);
50
+ return node === undefined ? [] : [{ itemIndex, node, top: topInsideViewport(node, viewport) }];
51
+ });
52
+ bindings.sort((left, right) => left.top - right.top);
53
+ return bindings;
54
+ }
55
+
56
+ /** The last binding whose top is at or above `activationTop`, else the first. */
57
+ export function currentBinding(
58
+ bindings: readonly HeadingBinding[],
59
+ activationTop: number,
60
+ ): HeadingBinding {
61
+ let low = 0;
62
+ let high = bindings.length - 1;
63
+ let result = bindings[0]!;
64
+ while (low <= high) {
65
+ const middle = (low + high) >> 1;
66
+ const candidate = bindings[middle]!;
67
+ if (candidate.top <= activationTop) {
68
+ result = candidate;
69
+ low = middle + 1;
70
+ } else {
71
+ high = middle - 1;
72
+ }
73
+ }
74
+ return result;
75
+ }
76
+
77
+ /**
78
+ * The heading being read: the last one at or above the activation line, or
79
+ * the last heading once the document is scrolled to its end. `null` with no
80
+ * bindings.
81
+ */
82
+ export function activeHeadingBinding(
83
+ bindings: readonly HeadingBinding[],
84
+ viewport: HTMLElement,
85
+ ): HeadingBinding | null {
86
+ if (bindings.length === 0) return null;
87
+ const maxScrollTop = Math.max(0, viewport.scrollHeight - viewport.clientHeight);
88
+ const atDocumentEnd = maxScrollTop > 0 && viewport.scrollTop >= maxScrollTop - 1;
89
+ return atDocumentEnd
90
+ ? bindings.at(-1)!
91
+ : currentBinding(bindings, viewport.scrollTop + CONTENTS_ACTIVATION_OFFSET);
92
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * One-time gate for the Plannotator Inbox announcement. Cookie-backed like the
3
+ * other announcement gates, so a dismissal survives Plannotator's random
4
+ * localhost ports, and shared by the plan editor, the annotate surfaces and
5
+ * the code review editor: dismissing it anywhere retires it everywhere.
6
+ *
7
+ * A plain storage key rather than a settings-registry entry for the same reason
8
+ * as terminalToolsAnnouncement.ts: configStore seeds every registry default
9
+ * into a cookie on first access, so a registry flag could not tell "never seen"
10
+ * from "seeded default".
11
+ */
12
+
13
+ import { storage } from './storage';
14
+
15
+ const STORAGE_KEY = 'plannotator-announce-inbox-seen';
16
+ // Bump to re-announce after a meaningful revision.
17
+ const CURRENT_VERSION = '1';
18
+
19
+ export function needsInboxAnnouncement(): boolean {
20
+ return storage.getItem(STORAGE_KEY) !== CURRENT_VERSION;
21
+ }
22
+
23
+ export function markInboxAnnouncementSeen(): void {
24
+ storage.setItem(STORAGE_KEY, CURRENT_VERSION);
25
+ }
26
+
27
+ /**
28
+ * The page runs inside the VS Code extension's webview, whose theme bridge sets
29
+ * `window.__PLANNOTATOR_VSCODE` before the app script runs (the same flag
30
+ * useEditorAnnotations reads). Read per call, not at module load, so a test can
31
+ * set it.
32
+ */
33
+ export function isVSCodeWebview(): boolean {
34
+ return typeof window !== 'undefined'
35
+ && (window as { __PLANNOTATOR_VSCODE?: unknown }).__PLANNOTATOR_VSCODE === true;
36
+ }
37
+
38
+ export interface InboxAnnouncementGateState {
39
+ /** Latched at mount from needsInboxAnnouncement(). */
40
+ readonly announcementPending: boolean;
41
+ /** The app has not finished loading its initial payload. */
42
+ readonly isLoading: boolean;
43
+ /**
44
+ * Archive browsing, a read-only shared plan, or no Plannotator server (the
45
+ * share portal and its demo). Deferred, not consumed.
46
+ */
47
+ readonly readOnlySession: boolean;
48
+ /** Inside the VS Code extension's webview (isVSCodeWebview). Deferred, not consumed. */
49
+ readonly vscodeWebview: boolean;
50
+ /** Any other first-run dialog is on screen. The chain dialogs never stack. */
51
+ readonly otherFirstRunDialogVisible: boolean;
52
+ /**
53
+ * The terminal-tools announcement takes this load: it is pending and can
54
+ * show on this surface, or it was shown on this load. Then this one waits
55
+ * for the next load, so a reader never gets two announcements in a row.
56
+ */
57
+ readonly earlierAnnouncementTakesThisLoad: boolean;
58
+ }
59
+
60
+ /**
61
+ * Whether the Inbox announcement may open now. After the terminal-tools
62
+ * announcement (never on the same load) and before "Ask this session" and the
63
+ * agent-tool offer, which wait while this one takes the load. Unlike its
64
+ * siblings it is not deferred on the compact touch shell: the panel is a video
65
+ * and one link, and the Inbox is read on a phone too. The Apps pass this
66
+ * through useFirstRunAnnouncementWindow, so it only opens before the reader
67
+ * has started working.
68
+ */
69
+ export function inboxAnnouncementEligible(state: InboxAnnouncementGateState): boolean {
70
+ return (
71
+ state.announcementPending &&
72
+ !state.isLoading &&
73
+ !state.readOnlySession &&
74
+ !state.vscodeWebview &&
75
+ !state.otherFirstRunDialogVisible &&
76
+ !state.earlierAnnouncementTakesThisLoad
77
+ );
78
+ }
@@ -2,21 +2,98 @@
2
2
  * Tracks whether the user has explicitly resolved the Grid/Clean plan choice.
3
3
  * This is separate from `gridEnabled`: ConfigStore seeds that preference with
4
4
  * its default on first access, which is not evidence that the user chose it.
5
+ * So `gridEnabled` trusts its stored value only once this decision is resolved
6
+ * (see `config/settings.ts`); before that, everyone gets the default, Clean.
7
+ *
8
+ * Two generations of marker:
9
+ * - v1: `plannotator-plan-look-choice-resolved=true`, or the 0.20.0 chooser's
10
+ * `plannotator-look-feel-announcement-seen=2`. Both choosers opened with
11
+ * Grid preselected, so a v1 marker next to a stored Grid does not tell a
12
+ * real Grid choice from a Continue on the preselected default.
13
+ * - v2: `plannotator-plan-look-v2` = `resolved` (a choice made since Clean
14
+ * became the default: the chooser's Continue or Escape, or the Settings
15
+ * switch) or `notice` (moved to Clean once, the notice not yet seen).
16
+ *
17
+ * The owner's decision (2026-10-27): everyone holding only a v1 marker with
18
+ * Grid stored moves to Clean once and sees a one-time notice (the chooser,
19
+ * Clean preselected); Clean users are marked v2 silently. v2 lives under its
20
+ * own key, and resolving still writes the v1 key, so an older Plannotator
21
+ * build sharing the cookie jar never re-asks and can never write a v1 marker
22
+ * over a v2 choice (which would move that person again).
5
23
  */
6
24
 
7
25
  import { storage } from './storage';
8
26
 
9
27
  const CHOICE_RESOLVED_KEY = 'plannotator-plan-look-choice-resolved';
10
28
  const LEGACY_ANNOUNCEMENT_KEY = 'plannotator-look-feel-announcement-seen';
29
+ const V2_KEY = 'plannotator-plan-look-v2';
30
+ const GRID_KEY = 'plannotator-grid-enabled';
31
+
32
+ /** Which plan-look dialog this person needs: the first-run chooser, the one-time "moved to Clean" notice, or none. */
33
+ export type PlanLookPrompt = 'first-run' | 'moved' | null;
34
+
35
+ function hasV1Marker(): boolean {
36
+ return storage.getItem(CHOICE_RESOLVED_KEY) === 'true'
37
+ || storage.getItem(LEGACY_ANNOUNCEMENT_KEY) === '2';
38
+ }
39
+
40
+ /**
41
+ * Upgrade a v1 marker to v2, once. Grid stored → Clean, notice pending;
42
+ * anything else → resolved, silently. Idempotent: does nothing once v2 is set
43
+ * or when there is no marker at all (a first run).
44
+ */
45
+ export function migratePlanLookChoice(): void {
46
+ if (storage.getItem(V2_KEY) !== null) return;
47
+ if (!hasV1Marker()) return;
48
+ if (storage.getItem(GRID_KEY) === 'true') {
49
+ storage.setItem(GRID_KEY, 'false');
50
+ storage.setItem(V2_KEY, 'notice');
51
+ } else {
52
+ storage.setItem(V2_KEY, 'resolved');
53
+ }
54
+ }
55
+
56
+ /**
57
+ * The v2 marker after migration. A `resolved` read is written again: cookies
58
+ * last a year from their last write, and an older build can keep the v1
59
+ * marker and a stored Grid alive. If v2 expired first, the next load would
60
+ * move the person to Clean a second time.
61
+ */
62
+ function readV2(): string | null {
63
+ migratePlanLookChoice();
64
+ const v2 = storage.getItem(V2_KEY);
65
+ if (v2 === 'resolved') storage.setItem(V2_KEY, 'resolved');
66
+ return v2;
67
+ }
68
+
69
+ /** True once the person picked Grid or Clean under the Clean default (chooser, notice or Settings). */
70
+ export function isLookChoiceResolved(): boolean {
71
+ return readV2() === 'resolved';
72
+ }
73
+
74
+ export function planLookPrompt(): PlanLookPrompt {
75
+ const v2 = readV2();
76
+ if (v2 === 'resolved') return null;
77
+ return v2 === 'notice' ? 'moved' : 'first-run';
78
+ }
79
+
80
+ /**
81
+ * The look another tab already chose, or null while the choice is still open.
82
+ * Two open tabs both show the chooser or notice; when one is answered, the
83
+ * other must adopt that answer, not write its own preselected Clean over a
84
+ * Grid picked in the first tab.
85
+ */
86
+ export function planLookAnsweredElsewhere(): boolean | null {
87
+ if (storage.getItem(V2_KEY) !== 'resolved') return null;
88
+ return storage.getItem(GRID_KEY) === 'true';
89
+ }
11
90
 
12
91
  export function needsLookAndFeelAnnouncement(): boolean {
13
- if (storage.getItem(CHOICE_RESOLVED_KEY) === 'true') return false;
14
- // v2 was the old Grid/Clean chooser wrapped in the 0.20.0 announcement.
15
- // A dismissal there resolved the same decision; migrate it without showing
16
- // another dialog merely because the release framing was removed.
17
- return storage.getItem(LEGACY_ANNOUNCEMENT_KEY) !== '2';
92
+ return planLookPrompt() !== null;
18
93
  }
19
94
 
20
95
  export function markLookAndFeelChoiceResolved(): void {
96
+ storage.setItem(V2_KEY, 'resolved');
97
+ // Older builds read only this key; without it they would ask again.
21
98
  storage.setItem(CHOICE_RESOLVED_KEY, 'true');
22
99
  }
@@ -13,14 +13,14 @@
13
13
  * default, so nothing changes for a user with no config.
14
14
  *
15
15
  * The built-in set here is deliberately NARROWER than the annotatable set on
16
- * the server (`.md`/`.mdx`/`.txt`/`.html`/`.htm` only) — widening it is a
16
+ * the server (`.md`/`.mdx`/`.txt`/`.adoc`/`.asciidoc`/`.html`/`.htm` only) — widening it is a
17
17
  * separate decision. Extras are added on top of it.
18
18
  */
19
19
 
20
20
  import { normalizeMarkdownExtensions } from "@plannotator/core/annotatable";
21
21
 
22
22
  /** Built-in extensions the renderer treats as openable local documents. */
23
- const BUILTIN_LINKED_DOC_REGEX = /\.(mdx?|txt|html?)$/i;
23
+ const BUILTIN_LINKED_DOC_REGEX = /\.(mdx?|txt|adoc|asciidoc|html?)$/i;
24
24
 
25
25
  let extraExtensions: string[] = [];
26
26
 
package/utils/mermaid.ts CHANGED
@@ -11,9 +11,8 @@
11
11
  * the lazy path IS Plannotator's own path: `packages/editor/App.tsx` no longer
12
12
  * imports `./mermaid-eager`, so a plan with no diagram never downloads the
13
13
  * runtime in a chunked build (the share portal, any host that bundles by
14
- * route). The single-file builds inline the `import('mermaid')` target through
15
- * `inlineDynamicImports`, so there the lazy import resolves from the bundle
16
- * itself and nothing is fetched. A host that wants the runtime registered at
14
+ * route), nor in the single-file builds, which carry it as a packed chunk
15
+ * their server serves on request (build/pack-app.ts). A host that wants the runtime registered at
17
16
  * startup imports `./mermaid-eager`, which fills the slot at module
18
17
  * evaluation; the slot then short-circuits this loader.
19
18
  *
@@ -22,6 +21,16 @@
22
21
  */
23
22
  import type { Mermaid, MermaidConfig } from 'mermaid';
24
23
 
24
+ /**
25
+ * Width in px at which node labels wrap (#2064). Mermaid 12 lowered the
26
+ * default from 200 to 120, and at Plannotator's 16 px label text that wraps
27
+ * after about eight CJK characters and splits short Latin labels. 140 is the
28
+ * owner-approved middle: long labels still wrap, short ones stay on one line.
29
+ * The themed path (`buildMermaidConfig`) spreads this config, so both the
30
+ * themed and the host-fallback `initialize` carry it.
31
+ */
32
+ export const MERMAID_LABEL_WRAP_WIDTH = 140;
33
+
25
34
  /**
26
35
  * Hoisted verbatim from the former module-scope `mermaid.initialize(...)` in
27
36
  * MermaidBlock. Nothing in it reads a CSS token or the resolved mode.
@@ -61,6 +70,14 @@ export const MERMAID_CONFIG: MermaidConfig = {
61
70
  flowchart: {
62
71
  htmlLabels: true,
63
72
  curve: 'basis',
73
+ wrappingWidth: MERMAID_LABEL_WRAP_WIDTH,
74
+ },
75
+ /**
76
+ * Mermaid 11 drew state labels with the flowchart width; Mermaid 12 gives
77
+ * state diagrams their own `wrappingWidth`, also 120, so it is pinned too.
78
+ */
79
+ state: {
80
+ wrappingWidth: MERMAID_LABEL_WRAP_WIDTH,
64
81
  },
65
82
  };
66
83
 
@@ -70,6 +70,7 @@
70
70
  */
71
71
  import type { Mermaid, MermaidConfig } from 'mermaid';
72
72
  import { MERMAID_CONFIG } from './mermaid';
73
+ import { readThemeTokenValues } from './themeTokens';
73
74
  import {
74
75
  compositeOver,
75
76
  contrastRatio,
@@ -183,57 +184,8 @@ const CANVAS_MUTED_ALPHA = 0.3;
183
184
  * is the signal to keep the static config.
184
185
  */
185
186
  export function readThemeTokens(el?: Element | null): MermaidThemeTokens | undefined {
186
- if (typeof document === 'undefined' || typeof getComputedStyle !== 'function') return undefined;
187
- const target = el ?? document.documentElement;
188
- if (!target) return undefined;
189
- let computed: CSSStyleDeclaration;
190
- try {
191
- computed = getComputedStyle(target);
192
- } catch {
193
- return undefined;
194
- }
195
- const tokens: MermaidThemeTokens = {};
196
- let probe: HTMLElement | null = null;
197
- try {
198
- for (const name of MERMAID_THEME_TOKEN_NAMES) {
199
- let raw = '';
200
- try {
201
- raw = computed.getPropertyValue(`--${name}`).trim();
202
- } catch {
203
- raw = '';
204
- }
205
- if (!raw) continue;
206
- if (name === 'font-sans') {
207
- tokens[name] = raw;
208
- continue;
209
- }
210
- if (parseCssColor(raw)) {
211
- tokens[name] = raw;
212
- continue;
213
- }
214
- // Unparsable as written: let the engine resolve it to a colour.
215
- try {
216
- if (!probe) {
217
- probe = document.createElement('span');
218
- probe.setAttribute('aria-hidden', 'true');
219
- probe.style.position = 'absolute';
220
- probe.style.width = '0';
221
- probe.style.height = '0';
222
- probe.style.overflow = 'hidden';
223
- probe.style.pointerEvents = 'none';
224
- target.appendChild(probe);
225
- }
226
- probe.style.color = `var(--${name})`;
227
- const resolved = getComputedStyle(probe).color.trim();
228
- if (resolved && parseCssColor(resolved)) tokens[name] = resolved;
229
- } catch {
230
- // leave the token absent
231
- }
232
- }
233
- } finally {
234
- probe?.remove();
235
- }
236
- if (!tokens.background || !tokens.foreground) return undefined;
187
+ const tokens: MermaidThemeTokens | undefined = readThemeTokenValues(MERMAID_THEME_TOKEN_NAMES, el, ['font-sans']);
188
+ if (!tokens?.background || !tokens.foreground) return undefined;
237
189
  return tokens;
238
190
  }
239
191
 
@@ -0,0 +1,58 @@
1
+ import { storage } from './storage';
2
+
3
+ const STORAGE_KEY_ENABLED = 'plannotator-notion-enabled';
4
+ const STORAGE_KEY_PARENT_PAGE_ID = 'plannotator-notion-parent-page-id';
5
+ const STORAGE_KEY_AUTOSAVE = 'plannotator-notion-autosave';
6
+
7
+ export interface NotionSettings {
8
+ enabled: boolean;
9
+ parentPageId: string;
10
+ autoSave: boolean;
11
+ }
12
+
13
+ /**
14
+ * Whether this server offers Notion (`notion: true` from /api/plan): it has
15
+ * NOTION_TOKEN and the session is only on this computer. Off until the app
16
+ * hears otherwise, so the option stays hidden everywhere else.
17
+ */
18
+ let available = false;
19
+
20
+ export function setNotionAvailable(value: boolean): void {
21
+ available = value;
22
+ }
23
+
24
+ export function isNotionAvailable(): boolean {
25
+ return available;
26
+ }
27
+
28
+ /** Extract and normalize a Notion page UUID from either an ID or a page URL. */
29
+ export function normalizeNotionPageId(value: string): string | null {
30
+ const match = value.trim().match(/[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}/i);
31
+ if (!match) return null;
32
+ const compact = match[0].replace(/-/g, '').toLowerCase();
33
+ return `${compact.slice(0, 8)}-${compact.slice(8, 12)}-${compact.slice(12, 16)}-${compact.slice(16, 20)}-${compact.slice(20)}`;
34
+ }
35
+
36
+ export function getNotionSettings(): NotionSettings {
37
+ return {
38
+ enabled: storage.getItem(STORAGE_KEY_ENABLED) === 'true',
39
+ parentPageId: storage.getItem(STORAGE_KEY_PARENT_PAGE_ID) ?? '',
40
+ autoSave: storage.getItem(STORAGE_KEY_AUTOSAVE) === 'true',
41
+ };
42
+ }
43
+
44
+ export function saveNotionSettings(settings: NotionSettings): void {
45
+ storage.setItem(STORAGE_KEY_ENABLED, String(settings.enabled));
46
+ storage.setItem(STORAGE_KEY_PARENT_PAGE_ID, settings.parentPageId);
47
+ storage.setItem(STORAGE_KEY_AUTOSAVE, String(settings.autoSave));
48
+ }
49
+
50
+ /** The parent page to save under, or null when Notion is not offered, off, or has no valid page. */
51
+ export function getNotionParentPageId(settings: NotionSettings = getNotionSettings()): string | null {
52
+ if (!available || !settings.enabled) return null;
53
+ return normalizeNotionPageId(settings.parentPageId);
54
+ }
55
+
56
+ export function isNotionConfigured(): boolean {
57
+ return getNotionParentPageId() !== null;
58
+ }