@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,82 @@
1
+ /**
2
+ * Remember where a reader was in a rendered document, and put them back there.
3
+ *
4
+ * Raw pixels are a poor bookmark: images that finish loading, highlights that
5
+ * are re-applied, or a sidebar that changes the reading column's width all move
6
+ * the text under a fixed scrollTop. So the anchor is the first block
7
+ * (`data-block-id`) still visible at the top of the scroll viewport, plus how
8
+ * far its top sat from the viewport's top. Restoring scrolls that block back to
9
+ * the same offset; the raw scrollTop is only the fallback when the block is
10
+ * gone.
11
+ */
12
+
13
+ import {
14
+ getScrollViewportRect,
15
+ getScrollViewportTop,
16
+ offsetScrollViewport,
17
+ scrollViewportTo,
18
+ } from '../hooks/useScrollViewport';
19
+
20
+ export interface ScrollAnchor {
21
+ /** The first block visible at the top of the viewport, null when none is. */
22
+ blockId: string | null;
23
+ /** That block's top minus the viewport's top, in CSS pixels (≤ 0 when the
24
+ * block starts above the viewport). */
25
+ offset: number;
26
+ /** The viewport's scrollTop when captured — the fallback. */
27
+ scrollTop: number;
28
+ }
29
+
30
+ function blocksIn(viewport: HTMLElement): HTMLElement[] {
31
+ return Array.from(viewport.querySelectorAll<HTMLElement>('[data-block-id]'));
32
+ }
33
+
34
+ /** Capture where the reader is in `viewport`. */
35
+ export function captureScrollAnchor(viewport: HTMLElement): ScrollAnchor {
36
+ const scrollTop = getScrollViewportTop(viewport);
37
+ const viewportTop = getScrollViewportRect(viewport).top;
38
+ for (const block of blocksIn(viewport)) {
39
+ const rect = block.getBoundingClientRect();
40
+ if (rect.height <= 0 && rect.width <= 0) continue;
41
+ if (rect.bottom > viewportTop + 1) {
42
+ return {
43
+ blockId: block.getAttribute('data-block-id'),
44
+ offset: rect.top - viewportTop,
45
+ scrollTop,
46
+ };
47
+ }
48
+ }
49
+ return { blockId: null, offset: 0, scrollTop };
50
+ }
51
+
52
+ function findBlock(viewport: HTMLElement, blockId: string): HTMLElement | null {
53
+ for (const block of blocksIn(viewport)) {
54
+ if (block.getAttribute('data-block-id') === blockId) return block;
55
+ }
56
+ return null;
57
+ }
58
+
59
+ /**
60
+ * Whether the anchor's block is in the viewport's DOM yet. A document that is
61
+ * still mounting has none of its blocks, so a caller can wait for this.
62
+ */
63
+ export function scrollAnchorReady(viewport: HTMLElement, anchor: ScrollAnchor): boolean {
64
+ if (anchor.blockId === null) return blocksIn(viewport).length > 0 || anchor.scrollTop === 0;
65
+ return findBlock(viewport, anchor.blockId) !== null;
66
+ }
67
+
68
+ /**
69
+ * Put the reader back at `anchor`. Returns the viewport's scrollTop afterwards.
70
+ * When the anchor's block is gone, falls back to the captured scrollTop.
71
+ */
72
+ export function restoreScrollAnchor(viewport: HTMLElement, anchor: ScrollAnchor): number {
73
+ const block = anchor.blockId === null ? null : findBlock(viewport, anchor.blockId);
74
+ if (block) {
75
+ const viewportTop = getScrollViewportRect(viewport).top;
76
+ const delta = block.getBoundingClientRect().top - viewportTop - anchor.offset;
77
+ if (Math.abs(delta) >= 1) offsetScrollViewport(viewport, delta);
78
+ } else if (Math.abs(getScrollViewportTop(viewport) - anchor.scrollTop) >= 1) {
79
+ scrollViewportTo(viewport, { top: anchor.scrollTop, behavior: 'auto' });
80
+ }
81
+ return getScrollViewportTop(viewport);
82
+ }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * `#Heading` references inside annotation text.
3
+ *
4
+ * Typing `#` followed by a heading's exact text links to that heading. Only
5
+ * headings in the current document resolve, so `#123`, `#fff` or a URL
6
+ * fragment stay plain text unless a heading is literally named that way.
7
+ */
8
+
9
+ import { buildHeadingSlugMap } from './slugify';
10
+ import { transformPlainText } from './inlineTransforms';
11
+
12
+ export interface SectionRefSegment {
13
+ /** The heading text, shown as the link label. */
14
+ label: string;
15
+ /** The heading's anchor id in the rendered document. */
16
+ anchor: string;
17
+ }
18
+
19
+ export type SectionRefPart = string | SectionRefSegment;
20
+
21
+ /** A heading's text without its inline markdown syntax. */
22
+ function stripHeadingMarkdown(content: string): string {
23
+ return content
24
+ .replace(/\[\[([^\]]+)\]\]/g, '$1')
25
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
26
+ .replace(/[*_`~]/g, '');
27
+ }
28
+
29
+ /**
30
+ * Heading text → anchor id. Each heading is indexed under its markdown source
31
+ * (`**Don't** ship`), the text the page shows (`Don’t ship`), and that text as
32
+ * typed with straight quotes (`Don't ship`). A title that two different
33
+ * headings share is left out: it could mean either.
34
+ */
35
+ export function buildSectionRefIndex(
36
+ blocks: Array<{ id: string; type: string; content: string }>,
37
+ ): Map<string, string> {
38
+ const slugs = buildHeadingSlugMap(blocks);
39
+ const index = new Map<string, string>();
40
+ const ambiguous = new Set<string>();
41
+
42
+ const register = (title: string, anchor: string) => {
43
+ const key = title.trim();
44
+ if (!key) return;
45
+ const existing = index.get(key);
46
+ if (existing === undefined) index.set(key, anchor);
47
+ else if (existing !== anchor) ambiguous.add(key);
48
+ };
49
+
50
+ for (const block of blocks) {
51
+ if (block.type !== 'heading') continue;
52
+ const anchor = slugs.get(block.id);
53
+ if (!anchor) continue;
54
+ const stripped = stripHeadingMarkdown(block.content);
55
+ register(block.content, anchor);
56
+ register(stripped, anchor);
57
+ register(transformPlainText(stripped), anchor);
58
+ }
59
+
60
+ for (const title of ambiguous) index.delete(title);
61
+ return index;
62
+ }
63
+
64
+ // A reference starts a word: at the start, after whitespace, or after an
65
+ // opening bracket or quote. This keeps `C#`, `issue#4` and `page/#intro` plain.
66
+ const REF_START = /[\s([{"'“‘「]/u;
67
+ const SPACED_WORD = /[\p{Script=Latin}\p{Script=Cyrillic}\p{Script=Greek}\p{N}_]/u;
68
+
69
+ function matchAt(text: string, hash: number, titles: string[]): string | null {
70
+ // `# Title` is a markdown heading, not a reference.
71
+ if (hash + 1 >= text.length || /\s/.test(text[hash + 1]!)) return null;
72
+ // Longest first, so `#3.1 Scope` beats a sibling heading named `3.1`.
73
+ for (const title of titles) {
74
+ if (!text.startsWith(title, hash + 1)) continue;
75
+ // `#Intro` must not link inside `#Introduction`. Only scripts that space
76
+ // their words need the boundary: elsewhere a suffix follows the title directly.
77
+ const end = hash + 1 + title.length;
78
+ const next = text.slice(end, end + 2).match(/^./su)?.[0];
79
+ const last = title.match(/.$/su)?.[0];
80
+ if (next && last && SPACED_WORD.test(next) && SPACED_WORD.test(last)) continue;
81
+ return title;
82
+ }
83
+ return null;
84
+ }
85
+
86
+ /**
87
+ * Split annotation text into plain runs and `#Heading` references. Returns
88
+ * `[text]` when nothing resolves. Text inside backticks is never linked.
89
+ */
90
+ export function parseSectionRefs(text: string, index: Map<string, string>): SectionRefPart[] {
91
+ if (!text || index.size === 0 || !text.includes('#')) return [text];
92
+
93
+ const titles = [...index.keys()].sort((a, b) => b.length - a.length);
94
+ const parts: SectionRefPart[] = [];
95
+ let plain = '';
96
+ let i = 0;
97
+
98
+ while (i < text.length) {
99
+ const ch = text[i]!;
100
+ const close = ch === '`' ? text.indexOf('`', i + 1) : -1;
101
+ if (close !== -1) {
102
+ plain += text.slice(i, close + 1);
103
+ i = close + 1;
104
+ continue;
105
+ }
106
+ const title =
107
+ ch === '#' && (i === 0 || REF_START.test(text[i - 1]!))
108
+ ? matchAt(text, i, titles)
109
+ : null;
110
+ if (!title) {
111
+ plain += ch;
112
+ i += 1;
113
+ continue;
114
+ }
115
+ if (plain) parts.push(plain);
116
+ plain = '';
117
+ parts.push({ label: title, anchor: index.get(title)! });
118
+ i += 1 + title.length;
119
+ }
120
+
121
+ if (plain) parts.push(plain);
122
+ return parts.some((p) => typeof p !== 'string') ? parts : [text];
123
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Boundary trimming for web-highlighter's `Render.SelectedNodes` hook.
3
+ *
4
+ * Lives in its own pure module so it can be unit-tested without a DOM:
5
+ * useAnnotationHighlighter imports @plannotator/web-highlighter, whose UMD
6
+ * bundle reads `window` at module-eval time.
7
+ */
8
+
9
+ /**
10
+ * Minimal structural view of web-highlighter's `SelectedNode`: only the field
11
+ * the trim actually reads. Declared locally rather than imported from the
12
+ * package's `dist/`, which resolves today only because the fork ships no
13
+ * `exports` map; a republish that adds one would break consumers compiling
14
+ * @plannotator/ui from source.
15
+ */
16
+ export interface SelectedNodeLike {
17
+ $node: { textContent: string | null };
18
+ }
19
+
20
+ /**
21
+ * Drop whitespace-only nodes from the leading and trailing ends of the node
22
+ * list web-highlighter is about to wrap. Whitespace-only nodes in the MIDDLE of
23
+ * a genuine multi-node selection are kept, so inter-node spacing stays
24
+ * highlighted.
25
+ */
26
+ export const trimWhitespaceOnlyBoundaryNodes = (
27
+ selectedNodes: SelectedNodeLike[],
28
+ ): SelectedNodeLike[] => {
29
+ let start = 0;
30
+ while (start < selectedNodes.length && !/\S/.test(selectedNodes[start].$node.textContent ?? '')) {
31
+ start += 1;
32
+ }
33
+
34
+ let end = selectedNodes.length;
35
+ while (end > start && !/\S/.test(selectedNodes[end - 1].$node.textContent ?? '')) {
36
+ end -= 1;
37
+ }
38
+
39
+ return selectedNodes.slice(start, end);
40
+ };
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Why a decision POST (send feedback, approve, deny) did not land (#1392).
3
+ *
4
+ * The review pages used to swallow these failures: a POST to a server that
5
+ * had stopped rejected with a network error, the catch only re-enabled the
6
+ * button, and the reviewer could not tell that nothing reached the agent.
7
+ * The handlers now tag the failure so the page can say which one happened
8
+ * (`SendFailureToast`).
9
+ */
10
+
11
+ /** The `code` every plan, annotate and review server (Bun and Pi) puts on its
12
+ * 409 for a decision that arrives after the review was decided or closed
13
+ * (also `packages/shared/host-control.ts`). */
14
+ export const ALREADY_DECIDED_CODE = 'already_decided';
15
+
16
+ /** The `code` of plan review's 409 for a decision on an older revision. */
17
+ export const PLAN_REVISED_CODE = 'plan_revised';
18
+
19
+ export type SendFailure =
20
+ /** fetch rejected: the server is gone (stopped, crashed) or unreachable. */
21
+ | { kind: 'unreachable' }
22
+ /** The server answered with a non-2xx status it has no dedicated UI for. */
23
+ | { kind: 'rejected'; status: number }
24
+ /** The review was decided already (another window sent it, or the session
25
+ * that opened it ended). Sending again cannot work. */
26
+ | { kind: 'decided' }
27
+ /** Anything else thrown before the response settled. */
28
+ | { kind: 'unknown' };
29
+
30
+ export class DecisionSendError extends Error {
31
+ readonly failure: SendFailure;
32
+ constructor(failure: SendFailure, options?: { cause?: unknown }) {
33
+ super(`Decision not sent (${failure.kind})`, options);
34
+ this.name = 'DecisionSendError';
35
+ this.failure = failure;
36
+ }
37
+ }
38
+
39
+ /** `.catch()` for a decision fetch: a rejected fetch means the server is unreachable. */
40
+ export function throwUnreachable(cause: unknown): never {
41
+ throw new DecisionSendError({ kind: 'unreachable' }, { cause });
42
+ }
43
+
44
+ /** The `code` of a refusal body, when it has one. Never consumes `res`. */
45
+ export async function refusalCode(res: Response): Promise<string | undefined> {
46
+ try {
47
+ const code = ((await res.clone().json()) as { code?: unknown } | null)?.code;
48
+ return typeof code === 'string' ? code : undefined;
49
+ } catch {
50
+ return undefined;
51
+ }
52
+ }
53
+
54
+ /** Throws the failure for a response that is not ok: `decided` for the
55
+ * servers' already-decided refusal, `rejected` with the status otherwise. */
56
+ export async function assertSendOk(res: Response): Promise<void> {
57
+ if (res.ok) return;
58
+ if (res.status === 409 && (await refusalCode(res)) === ALREADY_DECIDED_CODE) {
59
+ throw new DecisionSendError({ kind: 'decided' });
60
+ }
61
+ throw new DecisionSendError({ kind: 'rejected', status: res.status });
62
+ }
63
+
64
+ export function sendFailureOf(error: unknown): SendFailure {
65
+ return error instanceof DecisionSendError ? error.failure : { kind: 'unknown' };
66
+ }
67
+
68
+ /** Whether sending the same decision again could work. */
69
+ export function canRetrySend(failure: SendFailure): boolean {
70
+ return failure.kind !== 'decided';
71
+ }
72
+
73
+ /** The one sentence under the toast's title: why nothing reached the agent. */
74
+ export function describeSendFailure(failure: SendFailure): string {
75
+ switch (failure.kind) {
76
+ case 'unreachable':
77
+ return "Plannotator isn't running or can't be reached.";
78
+ case 'rejected':
79
+ return `Plannotator answered with an error (${failure.status}).`;
80
+ case 'decided':
81
+ return 'This review was already decided, in another window or because its session ended.';
82
+ default:
83
+ return 'Something went wrong before it was sent.';
84
+ }
85
+ }
package/utils/sharing.ts CHANGED
@@ -29,7 +29,7 @@ export interface SharePayload {
29
29
  d?: (string | null)[]; // diffContext per annotation, parallel to `a`
30
30
  s?: (string | undefined)[]; // source per annotation (external tool identifier), parallel to `a`
31
31
  h?: string; // raw HTML content (direct HTML rendering mode)
32
- r?: 'html'; // render mode flag (omitted = markdown)
32
+ r?: 'html' | 'asciidoc'; // render mode flag (omitted = markdown)
33
33
  }
34
34
 
35
35
  /**
@@ -192,6 +192,7 @@ export async function generateShareUrl(
192
192
  globalAttachments?: ImageAttachment[],
193
193
  baseUrl: string = DEFAULT_SHARE_BASE,
194
194
  rawHtml?: string,
195
+ renderAs?: DocumentRenderAs,
195
196
  ): Promise<string | null> {
196
197
  // HTML content is too large for URL hashes — force paste service path
197
198
  if (rawHtml) return null;
@@ -203,6 +204,7 @@ export async function generateShareUrl(
203
204
  g: globalAttachments?.length ? toShareableImages(globalAttachments) : undefined,
204
205
  ...(diffContexts ? { d: diffContexts } : {}),
205
206
  ...(sources ? { s: sources } : {}),
207
+ ...(renderAs === 'asciidoc' ? { r: 'asciidoc' as const } : {}),
206
208
  };
207
209
 
208
210
  const hash = await compress(payload);
@@ -274,6 +276,7 @@ export async function createShortShareUrl(
274
276
  shareBaseUrl?: string;
275
277
  },
276
278
  rawHtml?: string,
279
+ renderAs?: DocumentRenderAs,
277
280
  ): Promise<{ shortUrl: string; id: string } | null> {
278
281
  const pasteApi = options?.pasteApiUrl ?? DEFAULT_PASTE_API;
279
282
  const shareBase = options?.shareBaseUrl ?? DEFAULT_SHARE_BASE;
@@ -287,7 +290,7 @@ export async function createShortShareUrl(
287
290
  g: globalAttachments?.length ? toShareableImages(globalAttachments) : undefined,
288
291
  ...(diffContexts ? { d: diffContexts } : {}),
289
292
  ...(sources ? { s: sources } : {}),
290
- ...(rawHtml ? { h: rawHtml, r: 'html' as const } : {}),
293
+ ...(rawHtml ? { h: rawHtml, r: 'html' as const } : renderAs === 'asciidoc' ? { r: 'asciidoc' as const } : {}),
291
294
  };
292
295
 
293
296
  const compressed = await compress(payload);
@@ -31,6 +31,16 @@ export const BUILT_IN_THEMES: ThemeInfo[] = [
31
31
  light: { primary: 'oklch(0.50 0.25 280)', secondary: 'oklch(0.50 0.18 180)', accent: 'oklch(0.60 0.22 50)', background: 'oklch(0.97 0.005 260)', foreground: 'oklch(0.18 0.02 260)' },
32
32
  },
33
33
  },
34
+ {
35
+ id: 'plannotator-graphite',
36
+ name: 'Plannotator Graphite',
37
+ builtIn: true,
38
+ modeSupport: 'both',
39
+ colors: {
40
+ dark: { primary: 'oklch(0.765 0.124 283)', secondary: 'oklch(0.740 0.123 180)', accent: 'oklch(0.765 0.156 60)', background: 'oklch(0.225 0.006 286)', foreground: 'oklch(0.945 0.003 286)' },
41
+ light: { primary: 'oklch(0.50 0.25 280)', secondary: 'oklch(0.50 0.18 180)', accent: 'oklch(0.60 0.22 50)', background: 'oklch(0.97 0.005 260)', foreground: 'oklch(0.18 0.02 260)' },
42
+ },
43
+ },
34
44
  {
35
45
  id: 'simple',
36
46
  name: 'Simple',
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Reading the live theme tokens off the document, for code that derives
3
+ * colours from the palette on screen (Mermaid's theme, the review sidebar's
4
+ * +/- counts). Browser-only, and free of any diagram code, so a bundle that
5
+ * only needs the tokens never pulls a diagram runtime in.
6
+ */
7
+ import { parseCssColor } from './cssColor';
8
+
9
+ /**
10
+ * Read the custom properties `names` off `el` (default: the document element,
11
+ * where `ThemeProvider` puts the `theme-*` / `light` classes). Colours the
12
+ * parser does not understand as written (`color-mix()`, a `var()` chain) are
13
+ * resolved through a throwaway probe element so the engine does the
14
+ * substitution; names in `raw` are returned as written (a font stack).
15
+ * Returns `undefined` outside a browser; a token that does not resolve is
16
+ * left out.
17
+ */
18
+ export function readThemeTokenValues<N extends string>(
19
+ names: readonly N[],
20
+ el?: Element | null,
21
+ raw: readonly N[] = [],
22
+ ): Partial<Record<N, string>> | undefined {
23
+ if (typeof document === 'undefined' || typeof getComputedStyle !== 'function') return undefined;
24
+ const target = el ?? document.documentElement;
25
+ if (!target) return undefined;
26
+ let computed: CSSStyleDeclaration;
27
+ try {
28
+ computed = getComputedStyle(target);
29
+ } catch {
30
+ return undefined;
31
+ }
32
+ const tokens: Partial<Record<N, string>> = {};
33
+ let probe: HTMLElement | null = null;
34
+ try {
35
+ for (const name of names) {
36
+ let value = '';
37
+ try {
38
+ value = computed.getPropertyValue(`--${name}`).trim();
39
+ } catch {
40
+ value = '';
41
+ }
42
+ if (!value) continue;
43
+ if (raw.includes(name)) {
44
+ tokens[name] = value;
45
+ continue;
46
+ }
47
+ if (parseCssColor(value)) {
48
+ tokens[name] = value;
49
+ continue;
50
+ }
51
+ // Unparsable as written: let the engine resolve it to a colour.
52
+ try {
53
+ if (!probe) {
54
+ probe = document.createElement('span');
55
+ probe.setAttribute('aria-hidden', 'true');
56
+ probe.style.position = 'absolute';
57
+ probe.style.width = '0';
58
+ probe.style.height = '0';
59
+ probe.style.overflow = 'hidden';
60
+ probe.style.pointerEvents = 'none';
61
+ target.appendChild(probe);
62
+ }
63
+ probe.style.color = `var(--${name})`;
64
+ const resolved = getComputedStyle(probe).color.trim();
65
+ if (resolved && parseCssColor(resolved)) tokens[name] = resolved;
66
+ } catch {
67
+ // leave the token absent
68
+ }
69
+ }
70
+ } finally {
71
+ probe?.remove();
72
+ }
73
+ return tokens;
74
+ }
@@ -3,10 +3,10 @@
3
3
  * .product/approved/plannotator-updates/ §4.5, owner's answers 1 and 3).
4
4
  *
5
5
  * The server says whether a newer release exists (`updateAvailable` on
6
- * /api/plan and /api/diff, from the machine's once-a-day check): the browser
6
+ * /api/plan and /api/diff, from the machine's own check): the browser
7
7
  * never asks GitHub. This module parses that advert, decides whether the toast
8
- * may show now (once per version, and at most once every three days across
9
- * versions, remembered in a cookie that every localhost port shares), and
8
+ * may show now (once per version, and at most once a day across versions,
9
+ * remembered in a cookie that every localhost port shares), and
10
10
  * words the toast for the host that launched the session.
11
11
  */
12
12
 
@@ -25,8 +25,30 @@ export interface UpdateAdvert {
25
25
  publishedAt?: string;
26
26
  }
27
27
 
28
- /** The toast shows at most this often, across versions. */
29
- export const UPDATE_TOAST_INTERVAL_MS = 3 * 24 * 60 * 60 * 1000;
28
+ /**
29
+ * Checking for updates has failed for hours on this machine
30
+ * (`updateCheckFailing` on /api/plan and /api/diff, never beside
31
+ * `updateAvailable`): the Options menu says so quietly, with the command that
32
+ * updates without the check. No toast, no dot.
33
+ */
34
+ export interface UpdateCheckFailingAdvert {
35
+ since: string;
36
+ error: string;
37
+ command: string;
38
+ }
39
+
40
+ /** The server's failing record, validated (anything malformed is none). */
41
+ export function parseUpdateCheckFailing(value: unknown): UpdateCheckFailingAdvert | null {
42
+ if (!value || typeof value !== 'object') return null;
43
+ const v = value as Record<string, unknown>;
44
+ if (typeof v.since !== 'string' || Number.isNaN(Date.parse(v.since))) return null;
45
+ if (typeof v.error !== 'string' || v.error.length > 200) return null;
46
+ if (typeof v.command !== 'string' || !v.command || v.command.length > 500) return null;
47
+ return { since: v.since, error: v.error, command: v.command };
48
+ }
49
+
50
+ /** The toast shows at most this often, across versions (owner's choice: once a day). */
51
+ export const UPDATE_TOAST_INTERVAL_MS = 24 * 60 * 60 * 1000;
30
52
 
31
53
  const TOAST_KEY = 'plannotator-update-toast';
32
54
  const VERSION = /^\d{1,9}\.\d{1,9}\.\d{1,9}$/;
@@ -82,7 +104,7 @@ export function describeUpdateToast(advert: UpdateAdvert): { title: string; desc
82
104
  case 'script':
83
105
  return {
84
106
  title: `Plannotator ${advert.version} is available`,
85
- description: `You have ${advert.current}. Update from the Inbox, or turn on automatic updates in Settings.`,
107
+ description: `You have ${advert.current}. Update installs it in the background; this review keeps working.`,
86
108
  primary: 'update',
87
109
  };
88
110
  case 'copy':
@@ -94,13 +116,13 @@ export function describeUpdateToast(advert: UpdateAdvert): { title: string; desc
94
116
  case 'pi':
95
117
  return {
96
118
  title: `Plannotator for Pi ${advert.version} is available`,
97
- description: `Run ${advert.command}, then restart Pi.`,
119
+ description: `Run ${advert.command}, then type /reload in Pi.`,
98
120
  primary: 'copy',
99
121
  };
100
122
  case 'opencode':
101
123
  return {
102
124
  title: `Plannotator ${advert.version} is available`,
103
- description: "Run the install script again, then restart OpenCode. It also clears OpenCode's cached plugin.",
125
+ description: "Run the install script again, then run opencode2 reload (OpenCode 1: restart it). It also clears OpenCode's cached plugin.",
104
126
  primary: 'copy',
105
127
  };
106
128
  }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * True inside the VS Code extension's panel (its theme bridge sets
3
+ * `window.__PLANNOTATOR_VSCODE`). There the panel forwards keys to VS Code, so
4
+ * a page key that shadows a VS Code window key (⌘P is Quick Open there, not
5
+ * Print) should step aside.
6
+ */
7
+ export function isVSCodePanel(): boolean {
8
+ return typeof window !== 'undefined' && (window as { __PLANNOTATOR_VSCODE?: unknown }).__PLANNOTATOR_VSCODE === true;
9
+ }