@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
package/theme.css CHANGED
@@ -4,6 +4,7 @@
4
4
 
5
5
  /* Theme files — each defines .theme-{name} (dark) and .theme-{name}.light */
6
6
  @import "./themes/plannotator.css";
7
+ @import "./themes/plannotator-graphite.css";
7
8
  @import "./themes/claude-plus.css";
8
9
  @import "./themes/soft-pop.css";
9
10
  @import "./themes/adwaita.css";
@@ -63,10 +64,17 @@
63
64
  * selection stay on the textarea: the caret takes the foreground token, and
64
65
  * the selection uses a translucent primary wash so the overlay text stays
65
66
  * readable through it. During IME composition `.pn-ref-composing` restores
66
- * the native text so composition underlines render normally. */
67
+ * the native text so composition underlines render normally.
68
+ *
69
+ * No transition: the overlay's `visibility` flips instantly, so the global
70
+ * `*` 150ms color transition would fade the textarea's glyphs in (blank text
71
+ * at every composition start) and out (doubled text at every end). With
72
+ * Korean/Japanese/Chinese IMEs that swap happens on nearly every keystroke,
73
+ * and the whole field flickers. */
67
74
  .pn-ref-input {
68
75
  color: transparent;
69
76
  caret-color: var(--foreground);
77
+ transition: none;
70
78
  }
71
79
  .pn-ref-input::selection {
72
80
  background: color-mix(in oklab, var(--primary) 30%, transparent);
@@ -719,6 +727,14 @@
719
727
  --card-ring: rgba(255, 255, 255, 0.08);
720
728
  --card-shadow:
721
729
  0 0 0 1px var(--card-ring), 0 1px 3px 0 rgba(0, 0, 0, 0.2), 0 2px 8px -2px rgba(0, 0, 0, 0.3);
730
+ /* Floating surfaces with no border (the tooltip): a faint ring in the
731
+ * shadow plus a soft drop. The ink is the background taken toward black in
732
+ * its own hue (the Inbox's Soft float shadow, same formula). Hosts override
733
+ * --float-shadow to restyle every borderless float at once. */
734
+ --float-shadow-ink: var(--background);
735
+ --float-shadow:
736
+ 0 0 0 1px color-mix(in oklch, var(--foreground) 8%, transparent),
737
+ 0 16px 40px -10px color-mix(in oklch, var(--float-shadow-ink) 70%, transparent);
722
738
  }
723
739
 
724
740
  html {
@@ -760,6 +776,16 @@
760
776
  --card-shadow:
761
777
  0 0 0 1px var(--card-ring), 0 1px 3px 0 rgba(0, 0, 0, 0.04),
762
778
  0 2px 8px -2px rgba(0, 0, 0, 0.06);
779
+ --float-shadow-ink: var(--foreground);
780
+ --float-shadow:
781
+ 0 0 0 1px color-mix(in oklch, var(--border) 70%, transparent),
782
+ 0 12px 32px -8px color-mix(in oklch, var(--float-shadow-ink) 22%, transparent);
783
+ }
784
+
785
+ @supports (color: oklch(from red l c h)) {
786
+ :root:not(.light) {
787
+ --float-shadow-ink: oklch(from var(--background) calc(l * 0.2) calc(c * 0.5) h);
788
+ }
763
789
  }
764
790
 
765
791
  * {
@@ -767,6 +793,28 @@
767
793
  }
768
794
  }
769
795
 
796
+ /* Inter's stylistic sets: ss01 (open digits), ss02 (disambiguation: I, l, 0)
797
+ * and cv01 (alternate one), set on body with font-feature-settings. That
798
+ * property is inherited by every font on the page, and feature tags mean
799
+ * different things in each font. On macOS, iOS and Windows that is the look
800
+ * users know (SF Pro, the system-ui themes' font, draws an open 4 and
801
+ * straight 6 and 9 with them), so it stays. On desktop Linux, Ubuntu Mono, the
802
+ * monospace fallback that code review diffs and gutters reach, draws ss02 as
803
+ * superscript digits (#1961). There (`pn-os-linux`, put on <html> before the
804
+ * first paint by ThemeProvider: see markRootPlatform in utils/platform.ts) the
805
+ * sets are named for Inter's families and applied with
806
+ * font-variant-alternates instead: a named value applies only to text a listed
807
+ * family renders, so monospace text, Pierre's diffs (inside their shadow
808
+ * roots), system-ui themes and any fallback font get none of them. */
809
+ @font-feature-values "Inter Variable", "Inter", "InterVariable" {
810
+ @styleset {
811
+ pn-inter: 1 2;
812
+ }
813
+ @character-variant {
814
+ pn-inter-one: 1;
815
+ }
816
+ }
817
+
770
818
  body {
771
819
  font-family: var(--font-sans);
772
820
  background: var(--background);
@@ -774,6 +822,11 @@ body {
774
822
  font-feature-settings: "ss01", "ss02", "cv01";
775
823
  }
776
824
 
825
+ :root.pn-os-linux body {
826
+ font-feature-settings: normal;
827
+ font-variant-alternates: styleset(pn-inter) character-variant(pn-inter-one);
828
+ }
829
+
777
830
  /* Safari extends the page canvas beneath its floating browser controls. The
778
831
  * plan document is a card-colored nested scroller, so leaving the outer canvas
779
832
  * on `--background` produces an opaque dark band around those controls. Match
@@ -1654,6 +1707,31 @@ body:has([data-popout="true"]) [data-sidebar-tabs="true"] {
1654
1707
  padding-left: 0.6rem;
1655
1708
  color: var(--muted-foreground);
1656
1709
  }
1710
+ /* GFM tables in Ask AI answers. The chat panel is narrow, so a wide table
1711
+ scrolls sideways inside itself instead of widening the message. */
1712
+ .ai-markdown table {
1713
+ display: block;
1714
+ max-width: 100%;
1715
+ overflow-x: auto;
1716
+ border-collapse: collapse;
1717
+ margin: 0.5rem 0;
1718
+ font-size: 0.95em;
1719
+ }
1720
+ .ai-markdown th,
1721
+ .ai-markdown td {
1722
+ border: 1px solid var(--border);
1723
+ padding: 0.25rem 0.5rem;
1724
+ text-align: left;
1725
+ vertical-align: top;
1726
+ }
1727
+ .ai-markdown th {
1728
+ background: color-mix(in oklab, var(--muted) 55%, transparent);
1729
+ font-weight: 600;
1730
+ }
1731
+ .ai-markdown th[align="center"],
1732
+ .ai-markdown td[align="center"] { text-align: center; }
1733
+ .ai-markdown th[align="right"],
1734
+ .ai-markdown td[align="right"] { text-align: right; }
1657
1735
 
1658
1736
  /* Annotation highlights — shared by the plan editor and the code-review
1659
1737
  description annotations. (Moved here from packages/editor/index.css so both
@@ -1714,6 +1792,106 @@ body:has([data-popout="true"]) [data-sidebar-tabs="true"] {
1714
1792
  cursor: pointer;
1715
1793
  }
1716
1794
 
1795
+ /* Document text size (Settings → Display → Text size, #2066; see
1796
+ utils/documentTextSize). The app sets data-doc-text-size and
1797
+ --doc-text-scale (size / 15) on the document element only away from the
1798
+ default, so at the default none of this applies and the document draws
1799
+ exactly as it always has. Scoped to the blocks of a document container
1800
+ (data-doc-text-root: the Viewer's article and the plan diff's): every block
1801
+ carries data-block-id, the plan diff's blocks data-diff-block-index, the
1802
+ frontmatter card and the raw diff data-doc-text. So the header, panels,
1803
+ badges, menus and popovers keep their size; diagrams draw themselves and the
1804
+ table / diagram popouts are app chrome, so both are left out. Named sizes
1805
+ scale through Tailwind's type scale, the inherited size through each block
1806
+ root, the renderers' fixed sizes one by one (sizes under 12 px, the card's
1807
+ pills and the diff gutters, stay as they are), and the list markers,
1808
+ checkboxes and the question card's controls with the text they sit beside.
1809
+ Unlayered, so it outranks Tailwind's utilities, except the inherited size,
1810
+ which sits in the base layer so a block's own size class still wins. */
1811
+ [data-doc-text-size] [data-doc-text-root] :is([data-block-id], [data-diff-block-index], [data-doc-text]):not([data-diagram-block], [data-diagram-block] *, [data-popout], [data-popout] *) {
1812
+ --text-xs: max(11px, calc(0.75rem * var(--doc-text-scale, 1)));
1813
+ --text-sm: calc(0.875rem * var(--doc-text-scale, 1));
1814
+ --text-base: calc(1rem * var(--doc-text-scale, 1));
1815
+ --text-lg: calc(1.0625rem * var(--doc-text-scale, 1));
1816
+ --text-xl: calc(1.1875rem * var(--doc-text-scale, 1));
1817
+ --text-2xl: calc(1.375rem * var(--doc-text-scale, 1));
1818
+ --text-3xl: calc(1.625rem * var(--doc-text-scale, 1));
1819
+ --text-4xl: calc(1.875rem * var(--doc-text-scale, 1));
1820
+ --text-5xl: calc(2rem * var(--doc-text-scale, 1));
1821
+ }
1822
+ @layer base {
1823
+ /* The size a block inherits (a list row and its marker, an alert's title):
1824
+ only on the outermost block, so a nested one never compounds it. */
1825
+ [data-doc-text-size] [data-doc-text-root] :is([data-block-id], [data-diff-block-index], [data-doc-text]):not([data-diagram-block], [data-diagram-block] *, [data-popout], [data-popout] *):not(:is([data-block-id], [data-diff-block-index], [data-doc-text]) *) {
1826
+ font-size: calc(1em * var(--doc-text-scale, 1));
1827
+ }
1828
+ }
1829
+ [data-doc-text-size] [data-doc-text-root] :is([data-block-id], [data-diff-block-index], [data-doc-text], [data-block-id] *, [data-diff-block-index] *, [data-doc-text] *):not([data-diagram-block], [data-diagram-block] *, [data-popout], [data-popout] *).text-\[15px\] {
1830
+ font-size: calc(15px * var(--doc-text-scale, 1));
1831
+ }
1832
+ [data-doc-text-size] [data-doc-text-root] :is([data-block-id], [data-diff-block-index], [data-doc-text], [data-block-id] *, [data-diff-block-index] *, [data-doc-text] *):not([data-diagram-block], [data-diagram-block] *, [data-popout], [data-popout] *).text-\[13\.5px\] {
1833
+ font-size: calc(13.5px * var(--doc-text-scale, 1));
1834
+ }
1835
+ [data-doc-text-size] [data-doc-text-root] :is([data-block-id], [data-diff-block-index], [data-doc-text], [data-block-id] *, [data-diff-block-index] *, [data-doc-text] *):not([data-diagram-block], [data-diagram-block] *, [data-popout], [data-popout] *).text-\[13px\] {
1836
+ font-size: calc(13px * var(--doc-text-scale, 1));
1837
+ }
1838
+ [data-doc-text-size] [data-doc-text-root] :is([data-block-id], [data-diff-block-index], [data-doc-text], [data-block-id] *, [data-diff-block-index] *, [data-doc-text] *):not([data-diagram-block], [data-diagram-block] *, [data-popout], [data-popout] *).text-\[12\.5px\] {
1839
+ font-size: calc(12.5px * var(--doc-text-scale, 1));
1840
+ }
1841
+ /* Fenced code (`pre code.pn-code`, 0.8125rem in the app's stylesheet). */
1842
+ [data-doc-text-size] [data-doc-text-root] :is([data-block-id], [data-diff-block-index], [data-doc-text]):not([data-diagram-block], [data-diagram-block] *, [data-popout], [data-popout] *) pre code.pn-code {
1843
+ font-size: calc(0.8125rem * var(--doc-text-scale, 1));
1844
+ }
1845
+ /* A question card's context (QuestionContext's own stylesheet). */
1846
+ [data-doc-text-size] [data-doc-text-root] [data-block-id] .question-context :is(p, .text-sm) {
1847
+ font-size: calc(13px * var(--doc-text-scale, 1));
1848
+ }
1849
+ [data-doc-text-size] [data-doc-text-root] [data-block-id] .question-context :is(h1, h2) {
1850
+ font-size: calc(14px * var(--doc-text-scale, 1));
1851
+ }
1852
+ [data-doc-text-size] [data-doc-text-root] [data-block-id] .question-context h3 {
1853
+ font-size: calc(13.5px * var(--doc-text-scale, 1));
1854
+ }
1855
+ /* List markers (ListMarker): the numeral's column and the task circle. */
1856
+ [data-doc-text-size] [data-doc-text-root] [data-list-numeral] {
1857
+ min-width: calc(1.5rem * var(--doc-text-scale, 1)) !important;
1858
+ }
1859
+ [data-doc-text-size] [data-doc-text-root] [data-list-check] {
1860
+ width: calc(1rem * var(--doc-text-scale, 1));
1861
+ height: calc(1rem * var(--doc-text-scale, 1));
1862
+ margin-top: calc(3px * var(--doc-text-scale, 1));
1863
+ }
1864
+ /* A question card's choice controls (QuestionBlock), beside 13.5 px text. */
1865
+ [data-doc-text-size] [data-doc-text-root] [data-question-control] {
1866
+ width: calc(15px * var(--doc-text-scale, 1));
1867
+ height: calc(15px * var(--doc-text-scale, 1));
1868
+ }
1869
+ [data-doc-text-size] [data-doc-text-root] [data-question-control]:is(input) {
1870
+ margin-top: calc(3px * var(--doc-text-scale, 1));
1871
+ }
1872
+ [data-doc-text-size] [data-doc-text-root] [data-question-control]:not(input) {
1873
+ font-size: calc(11px * var(--doc-text-scale, 1));
1874
+ }
1875
+
1876
+ /* Document font (Settings → Display → Document font, #1657; see
1877
+ utils/documentFont). The app sets data-doc-font and --doc-font-family on the
1878
+ document element only away from the default, so at the default none of this
1879
+ applies. Same scope as Text size: the outermost blocks of a document
1880
+ container, leaving out diagrams and the popouts. Headings, lists, tables and
1881
+ question cards inherit it; code keeps its monospace font because Tailwind's
1882
+ preflight sets it on code / pre and font-mono marks inline code, the raw
1883
+ diff and the gutters. In the base layer so a block's own font utility
1884
+ (font-mono on the raw diff) still wins. */
1885
+ @layer base {
1886
+ [data-doc-font] [data-doc-text-root] :is([data-block-id], [data-diff-block-index], [data-doc-text]):not([data-diagram-block], [data-diagram-block] *, [data-popout], [data-popout] *):not(:is([data-block-id], [data-diff-block-index], [data-doc-text]) *) {
1887
+ font-family: var(--doc-font-family);
1888
+ }
1889
+ /* The HTML embed's and SVG fence's captions and Show more are controls. */
1890
+ [data-doc-font] [data-doc-text-root] :is(figcaption, [data-html-embed-more]) {
1891
+ font-family: var(--font-sans);
1892
+ }
1893
+ }
1894
+
1717
1895
  /* Reduced motion: Base UI popups (dialogs, menus, popovers, tooltips) animate
1718
1896
  via [data-starting-style]/[data-ending-style] transitions — those attributes
1719
1897
  only exist during the enter/exit phases, so zeroing transitions here makes
@@ -0,0 +1,66 @@
1
+ /* Plannotator Graphite. Dark half: a near-neutral graphite tuned to stay readable
2
+ in daylight (lifted ground, larger surface steps, secondary text at APCA Lc 60+,
3
+ the purple primary kept inside sRGB). Light half: Plannotator's light, unchanged.
4
+ Approved 2026-10-24 (.product/approved/plannotator-graphite/). */
5
+ .theme-plannotator-graphite {
6
+ --background: oklch(0.225 0.006 286);
7
+ --foreground: oklch(0.945 0.003 286);
8
+ --card: oklch(0.295 0.006 286);
9
+ --card-foreground: oklch(0.945 0.003 286);
10
+ --popover: oklch(0.375 0.006 286);
11
+ --popover-foreground: oklch(0.945 0.003 286);
12
+ --primary: oklch(0.765 0.124 283);
13
+ --primary-foreground: oklch(0.20 0.01 286);
14
+ --secondary: oklch(0.740 0.123 180);
15
+ --secondary-foreground: oklch(0.20 0.01 286);
16
+ --muted: oklch(0.355 0.006 286);
17
+ --muted-foreground: oklch(0.820 0.007 286);
18
+ --accent: oklch(0.765 0.156 60);
19
+ --accent-foreground: oklch(0.20 0.01 286);
20
+ --destructive: oklch(0.770 0.124 25);
21
+ --destructive-foreground: oklch(0.20 0.01 286);
22
+ --border: oklch(0.435 0.006 286);
23
+ --input: oklch(0.355 0.006 286);
24
+ --ring: oklch(0.765 0.124 283);
25
+ --success: oklch(0.735 0.170 150);
26
+ --success-foreground: oklch(0.20 0.01 286);
27
+ --warning: oklch(0.760 0.144 85);
28
+ --warning-foreground: oklch(0.20 0.01 286);
29
+
30
+ --font-sans: 'Inter Variable', 'Inter', system-ui, sans-serif;
31
+ --font-mono: 'Geist Mono Variable', 'Geist Mono', 'JetBrains Mono', 'Fira Code', ui-monospace, monospace;
32
+ --radius: 0.625rem;
33
+
34
+ --code-bg: oklch(0.355 0.006 286);
35
+ --focus-highlight: oklch(0.745 0.116 200);
36
+ }
37
+
38
+ .theme-plannotator-graphite.light {
39
+ /* Plannotator light inherits this from its own dark block; keep the same value. */
40
+ --focus-highlight: oklch(0.70 0.20 200);
41
+ --background: oklch(0.97 0.005 260);
42
+ --foreground: oklch(0.18 0.02 260);
43
+ --card: oklch(1 0 0);
44
+ --card-foreground: oklch(0.18 0.02 260);
45
+ --popover: oklch(1 0 0);
46
+ --popover-foreground: oklch(0.18 0.02 260);
47
+ --primary: oklch(0.50 0.25 280);
48
+ --primary-foreground: oklch(1 0 0);
49
+ --secondary: oklch(0.50 0.18 180);
50
+ --secondary-foreground: oklch(1 0 0);
51
+ --muted: oklch(0.92 0.01 260);
52
+ --muted-foreground: oklch(0.40 0.02 260);
53
+ --accent: oklch(0.60 0.22 50);
54
+ --accent-foreground: oklch(0.18 0.02 260);
55
+ --destructive: oklch(0.50 0.25 25);
56
+ --destructive-foreground: oklch(1 0 0);
57
+ --border: oklch(0.88 0.01 260);
58
+ --input: oklch(0.92 0.01 260);
59
+ --ring: oklch(0.50 0.25 280);
60
+ --success: oklch(0.45 0.20 150);
61
+ --success-foreground: oklch(1 0 0);
62
+ --warning: oklch(0.55 0.18 85);
63
+ --warning-foreground: oklch(0.18 0.02 260);
64
+
65
+ --code-bg: oklch(0.92 0.01 260);
66
+ }
package/types.ts CHANGED
@@ -1,18 +1,21 @@
1
1
  import type { DiagramAnchor } from '@plannotator/core/diagram-anchor';
2
- import type { DiagramRenderKind } from '@plannotator/core/annotatable';
2
+ import type { ImageRegion } from '@plannotator/core/image-region';
3
+ import type { DocumentRenderKind } from '@plannotator/core/annotatable';
3
4
  import type { QuestionAnswer } from '@plannotator/core/question-block';
4
5
 
5
6
  export type { DiagramAnchor } from '@plannotator/core/diagram-anchor';
7
+ export type { ImageRegion, ImageRegionBox } from '@plannotator/core/image-region';
6
8
  export type { QuestionAnswer } from '@plannotator/core/question-block';
7
- export type { DiagramRenderKind } from '@plannotator/core/annotatable';
9
+ export type { DiagramRenderKind, DocumentRenderKind } from '@plannotator/core/annotatable';
8
10
 
9
11
  /**
10
12
  * How a document's body is rendered. `markdown` and `html` are the original
11
13
  * pair; the two diagram kinds are whole-file diagram sources (.mmd/.mermaid,
12
14
  * .dot/.gv) that render as ONE diagram through the same engine a ```mermaid
13
- * fence uses — see diagramDocumentBlocks in utils/parser.
15
+ * fence uses — see diagramDocumentBlocks in utils/parser. `asciidoc` is an
16
+ * .adoc source parsed by utils/asciidocParser into the same block model.
14
17
  */
15
- export type DocumentRenderAs = 'markdown' | 'html' | DiagramRenderKind;
18
+ export type DocumentRenderAs = 'markdown' | 'html' | DocumentRenderKind;
16
19
 
17
20
  export enum AnnotationType {
18
21
  DELETION = 'DELETION',
@@ -102,6 +105,7 @@ export interface Annotation {
102
105
  elementContext?: HtmlElementContext; // raw-HTML / live-app pinpoint: bounded agent-facing description of the primary element (never used by restore)
103
106
  htmlAdditionalTargets?: HtmlAnnotationTarget[]; // raw-HTML shift-click multi-select: extra elements this one comment covers (primary stays htmlAnchor/originalText)
104
107
  diagramAnchor?: DiagramAnchor; // a comment on a rendered diagram part (Mermaid / Graphviz fence): the part's own id, label and document source line; the highlighter skips it and the diagram overlay restores it (see @plannotator/core/diagram-anchor)
108
+ imageRegion?: ImageRegion; // a comment on part of an image in a markdown document (#2100): the image's src as written, its document line, and the box in the image's own pixels; drawn by the image viewer, skipped by the highlighter, exported as `Image:` / `Region:` lines (see @plannotator/core/image-region)
105
109
  questionAnswer?: QuestionAnswer; // the reviewer's answer to a `:::question` block (id `ann-question-<key>`): the Viewer draws the answer from it, the highlighter skips it, and the export prints it in the "Answers to your questions" section instead of as a numbered comment (see @plannotator/core/question-block)
106
110
  // web-highlighter metadata for cross-element selections
107
111
  startMeta?: AnnotationTextMeta;
@@ -355,8 +359,10 @@ export interface CodeAnnotation {
355
359
  gitButlerBase?: string;
356
360
  /** Exact server snapshot that supplied the GitButler line coordinates. */
357
361
  gitButlerSnapshotId?: string;
362
+ /** Local Git worktree + branch + comparison identity supplied by the server. */
363
+ localReviewTarget?: string;
358
364
  /**
359
- * PR reviews only (#1590): the text of the diff lines this line comment
365
+ * PR and local Git reviews (#1590): the text of the diff lines this line comment
360
366
  * was anchored to at creation (lineStart..lineEnd on `side`, joined with
361
367
  * "\n"). When a saved draft is restored against a different patch, the
362
368
  * comment keeps its position only if these lines still read the same;
@@ -365,7 +371,7 @@ export interface CodeAnnotation {
365
371
  */
366
372
  anchorText?: string;
367
373
  /**
368
- * PR reviews only (#1590): the lines around the anchor on the same side
374
+ * PR and local Git reviews (#1590): the lines around the anchor on the same side
369
375
  * (up to two before and two after; `null` where a line is outside the patch
370
376
  * hunks) and the hunk header's function context (`hunk`, when git printed
371
377
  * one), so a comment on a common line (`}`, `return null;`) only keeps its
@@ -373,19 +379,30 @@ export interface CodeAnnotation {
373
379
  */
374
380
  anchorContext?: { before: (string | null)[]; after: (string | null)[]; hunk?: string };
375
381
  /**
376
- * PR reviews only (#1590): the review snapshot id of the diff whose line
382
+ * PR and local Git reviews (#1590): the review snapshot id of the diff whose line
377
383
  * coordinates this comment uses. Re-stamped when a later diff passes the
378
384
  * anchor check; a line comment is only posted inline when this matches the
379
385
  * diff currently known for its PR.
380
386
  */
381
387
  anchorSnapshot?: string;
382
388
  /**
383
- * Set when a restored PR draft's comment no longer matches the code it was
384
- * written on (the PR changed between sessions). Its line numbers refer to
389
+ * Set when a comment no longer matches the code it was written on.
390
+ * Its line numbers refer to
385
391
  * the earlier version: it is listed and exported (labelled) but never drawn
386
392
  * inline on the current diff and never posted as an inline PR comment.
387
393
  */
388
394
  outdated?: boolean;
395
+ /**
396
+ * Code review only: a comment on lines outside the diff — a file the review
397
+ * does not change (opened from All files), or lines of a changed file
398
+ * outside its hunks (its Whole file view). Line-scoped on the new side,
399
+ * with the commented code in `anchorText`. The agent gets it with the file,
400
+ * lines and code; a PR review posts it in the review body with the quoted
401
+ * code and a permalink, never inline (no platform accepts an inline comment
402
+ * outside the PR's diff). Never marked outdated by a diff re-check.
403
+ * Additive: annotations without it behave exactly as before.
404
+ */
405
+ outsideDiff?: boolean;
389
406
  /**
390
407
  * Code review only: a comment made by selecting text in a Markdown file's
391
408
  * rendered preview. Always file-scoped (`scope: 'file'`); `lineStart` /
@@ -9,14 +9,19 @@
9
9
  * stored is surface-specific: plan approval keeps its historical hand-off to the
10
10
  * build agent, while review feedback stays on the current agent.
11
11
  *
12
- * Uses cookies (not localStorage) because each hook invocation runs on a
13
- * random port, and localStorage is scoped by origin including port.
12
+ * Stored through the settings registry (`agentSwitch` in config/settings.ts):
13
+ * cookies (not localStorage, because each hook invocation runs on a random
14
+ * port) plus `agentSwitch` in ~/.plannotator/config.json, which wins over the
15
+ * cookie (#1390: the Glimpse webview keeps no cookies between runs).
14
16
  */
15
17
 
16
- import { storage } from './storage';
18
+ import { configStore } from '../config/configStore';
17
19
 
18
- const STORAGE_KEY = 'plannotator-agent-switch';
19
- const CUSTOM_NAME_KEY = 'plannotator-agent-custom';
20
+ /** The two configStore calls this module makes; tests pass a fresh store. */
21
+ interface AgentSwitchStore {
22
+ get(key: 'agentSwitch'): { switchTo: string; customName?: string } | null;
23
+ set(key: 'agentSwitch', value: { switchTo: string; customName?: string } | null): void;
24
+ }
20
25
 
21
26
  // AgentSwitchOption is now a string to support dynamic agent names from OpenCode
22
27
  export type AgentSwitchOption = string;
@@ -58,25 +63,26 @@ export function getAgentSwitchDefaults(surface: AgentSwitchSurface = 'plan'): Ag
58
63
  * An explicit user choice applies to every surface; only the unset default
59
64
  * varies by surface.
60
65
  */
61
- export function getAgentSwitchSettings(surface: AgentSwitchSurface = 'plan'): AgentSwitchSettings {
62
- const stored = storage.getItem(STORAGE_KEY);
63
- const customName = storage.getItem(CUSTOM_NAME_KEY) || undefined;
64
-
65
- // Accept any non-empty string (supports dynamic agent names from OpenCode)
66
- if (stored) {
67
- return { switchTo: stored, customName };
68
- }
69
- return getAgentSwitchDefaults(surface);
66
+ export function getAgentSwitchSettings(
67
+ surface: AgentSwitchSurface = 'plan',
68
+ store: AgentSwitchStore = configStore,
69
+ ): AgentSwitchSettings {
70
+ const stored = store.get('agentSwitch');
71
+ return stored ? { ...stored } : getAgentSwitchDefaults(surface);
70
72
  }
71
73
 
72
74
  /**
73
- * Save agent switch settings to storage
75
+ * Save agent switch settings: cookie at once, config.json through the
76
+ * registry's debounced POST /api/config.
74
77
  */
75
- export function saveAgentSwitchSettings(settings: AgentSwitchSettings): void {
76
- storage.setItem(STORAGE_KEY, settings.switchTo);
77
- if (settings.customName) {
78
- storage.setItem(CUSTOM_NAME_KEY, settings.customName);
79
- }
78
+ export function saveAgentSwitchSettings(
79
+ settings: AgentSwitchSettings,
80
+ store: AgentSwitchStore = configStore,
81
+ ): void {
82
+ store.set('agentSwitch', {
83
+ switchTo: settings.switchTo,
84
+ ...(settings.customName && { customName: settings.customName }),
85
+ });
80
86
  }
81
87
 
82
88
  /**
@@ -95,3 +101,18 @@ export function getEffectiveAgentName(settings: AgentSwitchSettings): string | u
95
101
  }
96
102
  return settings.switchTo;
97
103
  }
104
+
105
+ /**
106
+ * The agent name an approval hands off to, for display ("Approve → build"):
107
+ * a discovered agent's display name, the custom name, or the stored id when
108
+ * it matches nothing discovered. Null when switching is off.
109
+ */
110
+ export function agentSwitchTargetLabel(
111
+ setting: AgentSwitchSettings,
112
+ agents: ReadonlyArray<{ id: string; name: string }>,
113
+ ): string | null {
114
+ if (setting.switchTo === 'disabled') return null;
115
+ if (setting.switchTo === 'custom') return setting.customName || null;
116
+ const match = agents.find((a) => a.id.toLowerCase() === setting.switchTo.toLowerCase());
117
+ return match?.name ?? setting.switchTo;
118
+ }
@@ -111,5 +111,5 @@ export function agentToolAppliesWhen(host: AgentToolHost): string {
111
111
  export function agentToolCostNote(host: AgentToolHost): string {
112
112
  if (host === 'claude-code') return 'Claude Code loads it only when needed.';
113
113
  if (host === 'oh-my-pi') return 'Oh My Pi lists it in one short line (about 50 tokens) and loads the rest only when needed.';
114
- return 'Adds about 780 tokens to every request.';
114
+ return 'Adds about 800 tokens to every request.';
115
115
  }
@@ -2,14 +2,30 @@ import { createElement, type ReactNode } from 'react';
2
2
  import { marked } from 'marked';
3
3
  import DOMPurify from 'dompurify';
4
4
 
5
+ /**
6
+ * What DOMPurify keeps of an Ask AI answer. Every tag and attribute marked
7
+ * writes for the markdown an answer uses must be listed here, or DOMPurify
8
+ * drops the element and keeps only its text (#2069: tables ran together).
9
+ */
10
+ export const CHAT_SANITIZE_CONFIG = {
11
+ ALLOWED_TAGS: [
12
+ 'p', 'br', 'strong', 'em', 'code', 'pre', 'ul', 'ol', 'li',
13
+ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'a', 'blockquote',
14
+ 'table', 'thead', 'tbody', 'tr', 'th', 'td',
15
+ ],
16
+ // `align` is what marked writes on th/td for aligned GFM columns.
17
+ ALLOWED_ATTR: ['href', 'rel', 'class', 'align'],
18
+ };
19
+
20
+ /** The markdown step of {@link renderChatMarkdown}, before sanitizing. */
21
+ export function chatMarkdownToHtml(text: string): string {
22
+ return marked.parse(text, { async: false, breaks: true }) as string;
23
+ }
24
+
5
25
  export function renderChatMarkdown(text: string): ReactNode {
6
- const html = marked.parse(text, { async: false, breaks: true }) as string;
7
- const clean = DOMPurify.sanitize(html, {
8
- ALLOWED_TAGS: [
9
- 'p', 'br', 'strong', 'em', 'code', 'pre', 'ul', 'ol', 'li',
10
- 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'a', 'blockquote',
11
- ],
12
- ALLOWED_ATTR: ['href', 'rel', 'class'],
26
+ const clean = DOMPurify.sanitize(chatMarkdownToHtml(text), {
27
+ ALLOWED_TAGS: [...CHAT_SANITIZE_CONFIG.ALLOWED_TAGS],
28
+ ALLOWED_ATTR: [...CHAT_SANITIZE_CONFIG.ALLOWED_ATTR],
13
29
  });
14
30
 
15
31
  return createElement('div', {
@@ -60,13 +60,41 @@ export interface TocItem {
60
60
  annotationCount: number;
61
61
  }
62
62
 
63
+ /**
64
+ * The headings a Contents list shows, in document order: levels 1 to
65
+ * `maxLevel` (3 by default, plan review's list). The one place that picks
66
+ * them: `TableOfContents`, plan review's "has entries" check and the contents
67
+ * rail (`MarkdownContentsRail`, levels 1 to 4) all read it.
68
+ */
69
+ export function tocHeadings(blocks: readonly Block[], maxLevel = 3): Block[] {
70
+ return blocks
71
+ .filter(b => b.type === 'heading' && (b.level ?? 0) <= maxLevel)
72
+ .sort((a, b) => a.order - b.order);
73
+ }
74
+
75
+ /** The contents rail lists headings of levels 1 to this (`MarkdownContentsRail`). */
76
+ export const CONTENTS_RAIL_MAX_LEVEL = 4;
77
+ /** The contents rail shows for a document with at least this many of them. */
78
+ export const CONTENTS_RAIL_MIN_HEADINGS = 4;
79
+ /** The contents rail shows in a scroll element at least this wide (and with a 68 px gutter). */
80
+ export const CONTENTS_RAIL_MIN_PANE_WIDTH = 480;
81
+
82
+ /**
83
+ * Whether a document gets the contents rail: at least
84
+ * `CONTENTS_RAIL_MIN_HEADINGS` headings of levels 1 to
85
+ * `CONTENTS_RAIL_MAX_LEVEL`. Kept out of the rail's own module, so a host can
86
+ * decide without loading the (lazy) rail.
87
+ */
88
+ export function hasContentsRailHeadings(blocks: readonly Block[]): boolean {
89
+ return tocHeadings(blocks, CONTENTS_RAIL_MAX_LEVEL).length >= CONTENTS_RAIL_MIN_HEADINGS;
90
+ }
91
+
63
92
  export function buildTocHierarchy(
64
93
  blocks: Block[],
65
- annotationCounts: Map<string, number>
94
+ annotationCounts: Map<string, number>,
95
+ maxLevel = 3
66
96
  ): TocItem[] {
67
- const headings = blocks
68
- .filter(b => b.type === 'heading' && (b.level ?? 0) <= 3)
69
- .sort((a, b) => a.order - b.order);
97
+ const headings = tocHeadings(blocks, maxLevel);
70
98
 
71
99
  const root: TocItem[] = [];
72
100
  const stack: TocItem[] = [];
@@ -10,18 +10,20 @@ export const CHECKBOX_ANNOTATION_PREFIX = 'ann-checkbox-';
10
10
  *
11
11
  * Not: comments made in the plan diff view (`diffContext`, `blockId`
12
12
  * `diff-block-N`, drawn by the diff view), general comments, checkbox
13
- * toggles, and answers to `:::question` blocks (drawn by the question card).
13
+ * toggles, answers to `:::question` blocks (drawn by the question card), and
14
+ * comments on part of an image (`imageRegion`, drawn by the image viewer).
14
15
  * Restoring any of those through the highlighter finds no text, so it would
15
16
  * be reported unanchored and its `blockId` rewritten. Every caller that
16
17
  * re-applies a stored list filters with this, and the highlighter skips the
17
18
  * same rows itself.
18
19
  */
19
20
  export function annotationOwnsHighlight(
20
- annotation: Pick<Annotation, 'id' | 'type' | 'diffContext' | 'questionAnswer'>,
21
+ annotation: Pick<Annotation, 'id' | 'type' | 'diffContext' | 'questionAnswer'> & Partial<Pick<Annotation, 'imageRegion'>>,
21
22
  ): boolean {
22
23
  return !annotation.diffContext
23
24
  && annotation.type !== AnnotationType.GLOBAL_COMMENT
24
25
  && !annotation.id.startsWith(CHECKBOX_ANNOTATION_PREFIX)
25
26
  && annotation.questionAnswer == null
27
+ && annotation.imageRegion == null
26
28
  && !annotation.id.startsWith(QUESTION_ANSWER_ANNOTATION_PREFIX);
27
29
  }