@plannotator/ui 0.30.0 → 0.31.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 (74) hide show
  1. package/README.md +1 -0
  2. package/components/ActionMenu.tsx +6 -1
  3. package/components/AgentsTab.tsx +8 -9
  4. package/components/AnalysisLayerToggle.tsx +48 -0
  5. package/components/AnnotationPanel.tsx +62 -32
  6. package/components/AnnotationToolbar.tsx +28 -8
  7. package/components/AnnotationToolstrip.tsx +9 -0
  8. package/components/CommentPopover.tsx +212 -46
  9. package/components/ConfirmDialog.tsx +42 -28
  10. package/components/InlineMarkdown.tsx +3 -2
  11. package/components/KeyboardShortcuts.tsx +9 -0
  12. package/components/Landing.tsx +1 -1
  13. package/components/LookAndFeelAnnouncementDialog.tsx +147 -178
  14. package/components/MarkdownEditor/embedPicker.ts +349 -0
  15. package/components/MarkdownEditor.tsx +12 -0
  16. package/components/ModeToggle.tsx +2 -1
  17. package/components/PermissionModeSetup.tsx +24 -5
  18. package/components/PinpointOverlay.tsx +11 -6
  19. package/components/PlanHeaderMenu.tsx +140 -1
  20. package/components/SearchableSelect.tsx +2 -0
  21. package/components/Settings.tsx +136 -10
  22. package/components/SkillReferenceMenu.tsx +9 -0
  23. package/components/StickyHeaderLane.tsx +9 -2
  24. package/components/TableOfContents.tsx +9 -4
  25. package/components/TextShimmer.tsx +8 -5
  26. package/components/ThemeProvider.tsx +43 -1
  27. package/components/ThemeTab.tsx +52 -1
  28. package/components/Tooltip.tsx +3 -1
  29. package/components/Viewer.tsx +19 -5
  30. package/components/VimTargetReticle.tsx +12 -4
  31. package/components/ai/DocumentAIChatPanel.tsx +1 -0
  32. package/components/core/button.tsx +14 -6
  33. package/components/html-viewer/HtmlViewer.tsx +208 -39
  34. package/components/html-viewer/bridge-script.ts +319 -65
  35. package/components/html-viewer/composerYield.ts +1 -51
  36. package/components/html-viewer/useHtmlAnnotation.ts +146 -57
  37. package/components/plan-diff/PlanCleanDiffView.tsx +1 -0
  38. package/components/sidebar/FileBrowser.tsx +17 -5
  39. package/components/sidebar/SidebarContainer.tsx +124 -28
  40. package/components/ui/button.tsx +10 -8
  41. package/components/ui/dialog.tsx +35 -25
  42. package/config/index.ts +6 -1
  43. package/config/reviewView.ts +42 -9
  44. package/config/settings.ts +141 -0
  45. package/hooks/useAIProviderConfig.ts +8 -7
  46. package/hooks/useActiveSection.ts +6 -4
  47. package/hooks/useAgentJobs.ts +3 -0
  48. package/hooks/useAnnotationHighlighter.ts +20 -0
  49. package/hooks/useIsMobile.ts +37 -0
  50. package/hooks/useLinkedDoc.ts +7 -0
  51. package/hooks/useScrollViewport.ts +74 -0
  52. package/hooks/useViewportEnvironment.ts +350 -0
  53. package/package.json +3 -2
  54. package/shortcuts/index.ts +3 -0
  55. package/shortcuts/plan-review/annotationMode.shortcuts.ts +91 -0
  56. package/shortcuts/plan-review/documentView.shortcuts.ts +26 -0
  57. package/shortcuts/plan-review/htmlAnnotate.shortcuts.ts +24 -0
  58. package/styles.css +1 -1
  59. package/theme.css +229 -0
  60. package/types.ts +34 -0
  61. package/utils/annotateAgentTerminal.ts +36 -5
  62. package/utils/blockTargeting.ts +6 -3
  63. package/utils/composerYield.ts +45 -0
  64. package/utils/htmlChrome.ts +20 -16
  65. package/utils/lookAndFeelAnnouncement.ts +12 -8
  66. package/utils/markdownExtensions.ts +57 -0
  67. package/utils/parser.ts +37 -2
  68. package/utils/vimNavigation.ts +4 -1
  69. package/utils/vimScroll.ts +9 -4
  70. package/utils/wideMode.ts +20 -0
  71. package/components/PlanAIAnnouncementDialog.tsx +0 -187
  72. package/components/VimModeAnnouncementDialog.tsx +0 -557
  73. package/utils/planAIAnnouncement.ts +0 -17
  74. package/utils/vimModeAnnouncement.ts +0 -23
package/theme.css CHANGED
@@ -627,6 +627,14 @@
627
627
  * the surface/sidebar-aware components expect. Themes that pin explicit
628
628
  * values (e.g. "simple") override these. */
629
629
  :root {
630
+ /* Mobile browser environment. Safe areas are CSS-owned because they are
631
+ * available before React mounts; the observed viewport values are written
632
+ * by useViewportEnvironment and fall back cleanly in the utilities below. */
633
+ --pn-safe-top: env(safe-area-inset-top, 0px);
634
+ --pn-safe-right: env(safe-area-inset-right, 0px);
635
+ --pn-safe-bottom: env(safe-area-inset-bottom, 0px);
636
+ --pn-safe-left: env(safe-area-inset-left, 0px);
637
+ --pn-touch-target: 2.75rem;
630
638
  --surface-0: color-mix(in oklch, var(--muted) 40%, var(--background));
631
639
  --surface-1: var(--muted);
632
640
  --surface-2: color-mix(in oklch, var(--muted) 70%, var(--foreground));
@@ -704,6 +712,227 @@ body {
704
712
  font-feature-settings: "ss01", "ss02", "cv01";
705
713
  }
706
714
 
715
+ /* Safari extends the page canvas beneath its floating browser controls. The
716
+ * plan document is a card-colored nested scroller, so leaving the outer canvas
717
+ * on `--background` produces an opaque dark band around those controls. Match
718
+ * the browser canvas to the active surface while preserving the incumbent
719
+ * background for grid, HTML, Review, loading, and non-editor consumers. */
720
+ html:has([data-pn-browser-canvas="card"]),
721
+ body:has([data-pn-browser-canvas="card"]) {
722
+ background-color: var(--card);
723
+ }
724
+
725
+ html:has([data-pn-browser-canvas="background"]),
726
+ body:has([data-pn-browser-canvas="background"]) {
727
+ background-color: var(--background);
728
+ }
729
+
730
+ /* Mobile Safari only collapses its browser controls for page scrolling; it
731
+ * deliberately leaves them expanded when a gesture scrolls a nested element.
732
+ * Plan opts compact coarse-pointer layouts into document scrolling while the
733
+ * fixed application-stage architecture remains unchanged everywhere else. */
734
+ html:has([data-pn-document-scroll="true"]),
735
+ body:has([data-pn-document-scroll="true"]) {
736
+ height: auto;
737
+ overflow-x: hidden;
738
+ overflow-y: auto;
739
+ overscroll-behavior: auto;
740
+ }
741
+
742
+ body:has([data-pn-document-scroll="true"]) > #root {
743
+ height: auto;
744
+ overflow: visible;
745
+ }
746
+
747
+ .pn-app-viewport[data-pn-document-scroll="true"] {
748
+ height: auto;
749
+ min-height: 100vh;
750
+ min-height: 100dvh;
751
+ }
752
+
753
+ /* The application stage owns the top and lateral safe areas. Bottom-edge
754
+ * surfaces opt into `.pn-safe-bottom` themselves so the home-indicator inset
755
+ * is never counted twice. The ordered height declarations preserve desktop
756
+ * behavior and old-browser support before the observer publishes a value. */
757
+ .pn-app-viewport {
758
+ box-sizing: border-box;
759
+ height: 100vh;
760
+ height: 100dvh;
761
+ height: var(--pn-viewport-height, 100vh);
762
+ padding-top: var(--pn-safe-top);
763
+ padding-right: var(--pn-safe-right);
764
+ padding-left: var(--pn-safe-left);
765
+ }
766
+
767
+ .pn-safe-top {
768
+ padding-top: var(--pn-safe-top);
769
+ }
770
+
771
+ .pn-safe-inline {
772
+ padding-right: var(--pn-safe-right);
773
+ padding-left: var(--pn-safe-left);
774
+ }
775
+
776
+ .pn-safe-bottom {
777
+ padding-bottom: var(--pn-safe-bottom);
778
+ }
779
+
780
+ .pn-viewport-bounded {
781
+ max-height: calc(
782
+ var(--pn-viewport-height, 100vh) - var(--pn-safe-top) - var(--pn-safe-bottom)
783
+ );
784
+ }
785
+
786
+ /* Portaled overlays cannot inherit the application root's safe-area padding.
787
+ * This utility gives them the same observed visible stage without assuming
788
+ * that the software keyboard is a rectangular bottom inset. */
789
+ .pn-visible-viewport-overlay {
790
+ position: fixed;
791
+ top: var(--pn-viewport-offset-top, 0px);
792
+ left: var(--pn-viewport-offset-left, 0px);
793
+ box-sizing: border-box;
794
+ width: var(--pn-viewport-width, 100vw);
795
+ height: var(--pn-viewport-height, 100vh);
796
+ padding-top: max(1rem, var(--pn-safe-top));
797
+ padding-right: max(1rem, var(--pn-safe-right));
798
+ padding-bottom: max(1rem, var(--pn-safe-bottom));
799
+ padding-left: max(1rem, var(--pn-safe-left));
800
+ }
801
+
802
+ /* Full-stage compact surfaces (for example the Plan navigator) use the same
803
+ * observed visual viewport as portaled dialogs without inheriting their 1rem
804
+ * ornamental margin. The stage owns its safe areas and contains scroll-chain
805
+ * gestures so the document beneath it does not move at a list boundary. */
806
+ .pn-visible-viewport-stage {
807
+ position: fixed;
808
+ top: var(--pn-viewport-offset-top, 0px);
809
+ left: var(--pn-viewport-offset-left, 0px);
810
+ box-sizing: border-box;
811
+ width: var(--pn-viewport-width, 100vw);
812
+ height: var(--pn-viewport-height, 100vh);
813
+ padding-top: var(--pn-safe-top);
814
+ padding-right: var(--pn-safe-right);
815
+ padding-bottom: var(--pn-safe-bottom);
816
+ padding-left: var(--pn-safe-left);
817
+ overscroll-behavior: contain;
818
+ }
819
+
820
+ /* The navigator reuses information-dense desktop browser content, but its
821
+ * interactive rows become physical controls on touch. Scope both the 44px
822
+ * target and the TOC's legible type lift to the compact stage only. */
823
+ [data-pn-plan-navigator="true"] button {
824
+ min-block-size: var(--pn-touch-target);
825
+ touch-action: manipulation;
826
+ }
827
+
828
+ [data-pn-plan-navigator="true"] nav button {
829
+ font-size: 0.8125rem;
830
+ }
831
+
832
+ [data-pn-plan-navigator="true"] .file-browser-filter-field {
833
+ height: var(--pn-touch-target);
834
+ }
835
+
836
+ [data-pn-plan-navigator="true"] .file-browser-filter-clear {
837
+ min-inline-size: var(--pn-touch-target);
838
+ }
839
+
840
+ /* Compact Plan auxiliary stages reuse dense desktop panel contents, but every
841
+ * nested action remains a physical touch control. This selector is stage-only,
842
+ * so the incumbent desktop panel geometry stays pixel-identical. */
843
+ [data-pn-compact-plan-stage="true"] button {
844
+ min-block-size: var(--pn-touch-target);
845
+ touch-action: manipulation;
846
+ }
847
+
848
+ /* Existing expanded composers keep their 85dvh desktop proportion. Compact
849
+ * and touch layouts use the full safe visible stage so the keyboard cannot
850
+ * strand the footer below an ornamental desktop margin. */
851
+ .pn-responsive-composer-dialog {
852
+ width: 100%;
853
+ height: min(36rem, 85vh);
854
+ height: min(36rem, 85dvh);
855
+ max-width: 42rem;
856
+ max-height: 100%;
857
+ }
858
+
859
+ @media (pointer: coarse), (max-width: 639px), (max-height: 419px) {
860
+ .pn-responsive-composer-dialog {
861
+ height: 100%;
862
+ }
863
+
864
+ /* Code review often promotes a line selection straight into the expanded
865
+ * composer. Keep its idle surface proportional to the content instead of
866
+ * covering the whole phone; once the keyboard reduces the visible viewport,
867
+ * the same cap naturally yields to all available space. Plan's explicit
868
+ * expanded-composer treatment remains unchanged. */
869
+ .pn-responsive-composer-dialog.pn-review-composer-dialog {
870
+ height: min(28rem, 100%);
871
+ }
872
+ }
873
+
874
+ /* iOS zooms focused text controls below 16px. Scope the correction to
875
+ * user-authored primary inputs so labels, counters, and diff text stay at the
876
+ * established desktop scale. The mirror selector keeps skill-token glyphs
877
+ * metrically identical to the transparent textarea above it. */
878
+ @media (pointer: coarse), (max-width: 639px) {
879
+ [data-pn-mobile-editable],
880
+ [data-pn-mobile-editable-mirror] {
881
+ font-size: 1rem !important;
882
+ }
883
+ }
884
+
885
+ /* Shared controls keep their incumbent visual density but gain a physical
886
+ * 44px target inside the canonical compact touch shell. The root marker comes
887
+ * from the same JS media query that owns scroll and shell composition, so a
888
+ * secondary touchscreen cannot reclassify a fine-primary-pointer desktop.
889
+ * Selecting from `html` also reaches portaled controls outside the app root. */
890
+ html:has([data-pn-compact-touch-layout='true']) [data-pn-touch-target] {
891
+ min-block-size: var(--pn-touch-target);
892
+ touch-action: manipulation;
893
+ }
894
+
895
+ html:has([data-pn-compact-touch-layout='true'])
896
+ [data-pn-touch-target][data-pn-touch-target-icon] {
897
+ min-inline-size: var(--pn-touch-target);
898
+ }
899
+
900
+ html:has([data-pn-compact-touch-layout='true'])
901
+ [data-pn-compact-diff-options]
902
+ button {
903
+ min-block-size: var(--pn-touch-target);
904
+ touch-action: manipulation;
905
+ }
906
+
907
+ /* The selection toolbar packs its controls at desktop density (2px gaps), so
908
+ * 44px targets alone would still leave adjacent destructive and comment
909
+ * actions a mis-tap apart. Space them only inside the compact scope. */
910
+ html:has([data-pn-compact-touch-layout='true'])
911
+ [data-pn-annotation-toolbar-row] {
912
+ gap: 0.5rem;
913
+ }
914
+
915
+ html:has([data-pn-compact-touch-layout='true'])
916
+ [data-pn-touch-target]:not(:disabled):not([aria-disabled='true']):active {
917
+ filter: brightness(0.92);
918
+ transition: filter 100ms ease-out;
919
+ }
920
+
921
+ @media (prefers-reduced-motion: reduce) {
922
+ html:has([data-pn-compact-touch-layout='true'])
923
+ [data-pn-touch-target]:not(:disabled):not([aria-disabled='true']):active {
924
+ filter: brightness(0.9);
925
+ transition: none;
926
+ }
927
+ }
928
+
929
+ @media (hover: hover) and (pointer: fine) {
930
+ .plan-look-choice-option[aria-pressed='false']:hover {
931
+ border-color: color-mix(in srgb, var(--muted-foreground) 40%, transparent);
932
+ background: color-mix(in srgb, var(--muted) 35%, transparent);
933
+ }
934
+ }
935
+
707
936
  .math-inline {
708
937
  display: inline-flex;
709
938
  align-items: center;
package/types.ts CHANGED
@@ -79,6 +79,7 @@ export interface Annotation {
79
79
  displayMode: boolean;
80
80
  }>; // math elements covered by a mixed text+formula selection
81
81
  prUrl?: string; // code-review PR mode: the PR this note belongs to, so it isn't shown/exported against another PR after an in-place switch
82
+ pageUrl?: string; // set only by live app annotate sessions: the page (pathname + search) the annotation was made on; restore filters to the current page and export groups by page
82
83
  htmlAnchor?: HtmlElementAnchor; // raw-HTML pinpoint: serialized element anchor for reliable restoration
83
84
  htmlAdditionalTargets?: HtmlAnnotationTarget[]; // raw-HTML shift-click multi-select: extra elements this one comment covers (primary stays htmlAnchor/originalText)
84
85
  // web-highlighter metadata for cross-element selections
@@ -155,6 +156,32 @@ export type CodeAnnotationType = 'comment' | 'suggestion' | 'concern';
155
156
  // must branch on scope, never read those sentinels as a real path or row.
156
157
  export type CodeAnnotationScope = 'line' | 'file' | 'general';
157
158
 
159
+ /**
160
+ * One inferred step selected from the Call Flow analysis surface.
161
+ *
162
+ * Source location is optional because CallDiff can surface structural steps
163
+ * without a concrete line. `CodeAnnotation` uses an in-patch located target
164
+ * as its native inline anchor when one exists; otherwise the annotation is
165
+ * file- or review-scoped while this target remains its durable Call Flow
166
+ * anchor. Raw output selections use a one-based `rawLine` instead of a source
167
+ * location. This lets every rendered Call Flow row and raw line participate in
168
+ * feedback without pretending an out-of-hunk or diagnostic line can be posted
169
+ * as an inline source comment.
170
+ */
171
+ interface CallFlowAnnotationTargetBase {
172
+ treePath: string;
173
+ entry: string;
174
+ label: string;
175
+ side: 'old' | 'new';
176
+ }
177
+
178
+ export type CallFlowAnnotationTarget = CallFlowAnnotationTargetBase & (
179
+ | { filePath: string; lineStart: number; lineEnd: number; rawLine?: undefined }
180
+ | { filePath: string; lineStart?: undefined; lineEnd?: undefined; rawLine?: undefined }
181
+ | { rawLine: number; filePath?: undefined; lineStart?: undefined; lineEnd?: undefined }
182
+ | { rawLine?: undefined; filePath?: undefined; lineStart?: undefined; lineEnd?: undefined }
183
+ );
184
+
158
185
  /** Conventional Comments label — see https://conventionalcomments.org */
159
186
  export type ConventionalLabel =
160
187
  | 'praise'
@@ -214,6 +241,13 @@ export interface CodeAnnotation {
214
241
  * line anchor maps to the pristine lines those edits replace, so it is
215
242
  * approximate and the export labels it as such. */
216
243
  selectedTextFromEdits?: boolean;
244
+ /**
245
+ * Complete Call Flow selection for an annotation authored from that
246
+ * surface. When any target maps to the patch, one target also supplies this
247
+ * annotation's primary inline anchor; otherwise the annotation is file- or
248
+ * review-scoped. Target order always preserves the user's selection order.
249
+ */
250
+ callFlowTargets?: CallFlowAnnotationTarget[];
217
251
  createdAt: number;
218
252
  author?: string;
219
253
  source?: string; // External tool identifier (e.g., "eslint") — set when annotation comes from external API
@@ -1,14 +1,45 @@
1
- import type { AgentTerminalAgent } from "@plannotator/core/agent-terminal";
2
- import { storage } from "./storage";
1
+ import type {
2
+ AgentTerminalAgent,
3
+ AnnotateAgentTerminalSide,
4
+ } from "@plannotator/core/agent-terminal";
5
+ import { configStore } from "../config";
3
6
 
4
- const DEFAULT_AGENT_KEY = "plannotator-annotate-agent-terminal-default";
7
+ // The side/placement vocabulary lives in @plannotator/core so the settings
8
+ // registry can reach it without importing this module (which would close a
9
+ // cycle through ConfigStore). Re-exported here because this is the seam the
10
+ // editor package imports from.
11
+ export {
12
+ ANNOTATE_AGENT_TERMINAL_SIDES,
13
+ isAnnotateAgentTerminalSide,
14
+ resolveAnnotateAgentTerminalPlacement,
15
+ resolveAnnotateAgentTerminalSide,
16
+ } from "@plannotator/core/agent-terminal";
17
+ export type {
18
+ AnnotateAgentTerminalPlacement,
19
+ AnnotateAgentTerminalSide,
20
+ } from "@plannotator/core/agent-terminal";
5
21
 
22
+ /**
23
+ * Both Agent TUI preferences resolve through ConfigStore (server config file >
24
+ * cookie > default) rather than reading cookies directly. Annotate sessions run
25
+ * on a fresh random port every time, so a cookie is scoped to a single session;
26
+ * the server round-trip through ~/.plannotator/config.json is what makes these
27
+ * choices durable. Same seam identity.ts uses for `displayName`.
28
+ */
6
29
  export function getSavedAnnotateAgentId(): string | null {
7
- return storage.getItem(DEFAULT_AGENT_KEY);
30
+ return configStore.get("agentTerminalDefaultAgent") || null;
8
31
  }
9
32
 
10
33
  export function saveAnnotateAgentId(agentId: string): void {
11
- storage.setItem(DEFAULT_AGENT_KEY, agentId);
34
+ configStore.set("agentTerminalDefaultAgent", agentId);
35
+ }
36
+
37
+ export function getSavedAnnotateAgentTerminalSide(): AnnotateAgentTerminalSide {
38
+ return configStore.get("agentTerminalSide");
39
+ }
40
+
41
+ export function saveAnnotateAgentTerminalSide(side: AnnotateAgentTerminalSide): void {
42
+ configStore.set("agentTerminalSide", side);
12
43
  }
13
44
 
14
45
  export function resolveAnnotateAgentId(
@@ -1,11 +1,12 @@
1
+ import { getScrollViewportRect } from '../hooks/useScrollViewport';
2
+ import { createTextRange } from './domSelection';
3
+
1
4
  /**
2
5
  * Semantic document targeting shared by pointer Pinpoint and Vim navigation.
3
6
  *
4
7
  * The graph is rebuilt from the live rendered document whenever a consumer
5
8
  * needs it. Callers persist stable keys, never DOM nodes, across renders.
6
9
  */
7
- import { createTextRange } from './domSelection';
8
-
9
10
  /** Elements that never participate in document targeting. */
10
11
  const SKIP_SELECTORS = [
11
12
  '.annotation-toolbar',
@@ -362,7 +363,9 @@ export function findInitialSemanticTarget(
362
363
  graph: SemanticTargetGraph,
363
364
  scrollViewport?: HTMLElement | null,
364
365
  ): SemanticTarget | null {
365
- const viewportRect = (scrollViewport ?? graph.container).getBoundingClientRect();
366
+ const viewportRect = scrollViewport
367
+ ? getScrollViewportRect(scrollViewport)
368
+ : graph.container.getBoundingClientRect();
366
369
  const centerY = viewportRect.top + viewportRect.height / 2;
367
370
  return graph.blockKeys
368
371
  .map((key) => resolveSemanticTarget(graph, key))
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Composer-yield state machine for shift-click multi-select.
3
+ *
4
+ * While Shift is held during a multi-target draft, the comment composer gets
5
+ * out of the pointer's way: it fades as the pointer approaches and becomes
6
+ * click-through when the pointer is over it. Restoration uses hysteresis so
7
+ * the composer does not flicker at its boundary.
8
+ */
9
+
10
+ export type ComposerYieldState = 'none' | 'near' | 'over';
11
+
12
+ /** Distance (px from the composer rect) at which the composer starts fading. */
13
+ export const COMPOSER_YIELD_NEAR_ENTER_PX = 80;
14
+ /** Distance the pointer must exceed before a faded composer fully restores. */
15
+ export const COMPOSER_YIELD_NEAR_EXIT_PX = 96;
16
+ /** Distance the pointer must clear a click-through composer by to restore. */
17
+ export const COMPOSER_YIELD_OVER_EXIT_PX = 48;
18
+
19
+ /** Shortest distance from a point to a rect's edge; <= 0 means inside. */
20
+ export function distanceToRect(
21
+ x: number,
22
+ y: number,
23
+ rect: { left: number; top: number; right: number; bottom: number },
24
+ ): number {
25
+ const dx = Math.max(rect.left - x, 0, x - rect.right);
26
+ const dy = Math.max(rect.top - y, 0, y - rect.bottom);
27
+ if (dx === 0 && dy === 0) return 0;
28
+ return Math.hypot(dx, dy);
29
+ }
30
+
31
+ /** Next yield state for a pointer at `distance` px from the composer rect. */
32
+ export function computeComposerYield(
33
+ previous: ComposerYieldState,
34
+ distance: number,
35
+ ): ComposerYieldState {
36
+ if (distance <= 0) return 'over';
37
+ if (previous === 'over') {
38
+ if (distance <= COMPOSER_YIELD_OVER_EXIT_PX) return 'over';
39
+ return distance <= COMPOSER_YIELD_NEAR_EXIT_PX ? 'near' : 'none';
40
+ }
41
+ if (previous === 'near') {
42
+ return distance <= COMPOSER_YIELD_NEAR_EXIT_PX ? 'near' : 'none';
43
+ }
44
+ return distance <= COMPOSER_YIELD_NEAR_ENTER_PX ? 'near' : 'none';
45
+ }
@@ -2,37 +2,41 @@ import { storage } from './storage';
2
2
  import { isStalePreference } from './preferenceTtl';
3
3
 
4
4
  /**
5
- * Cross-session chrome visibility for raw-HTML annotate sessions.
5
+ * Cross-session sidebar/panel state for raw-HTML annotate sessions.
6
6
  *
7
- * A raw-HTML session should open as close to "just the page" as possible, so
8
- * the default is minimal paint: tools hidden, sidebar closed, annotations
9
- * drawer closed. An explicit change the user makes (showing tools, opening the
10
- * drawer) persists for later HTML sessions, but only while they keep using
11
- * HTML annotate: state not refreshed within the staleness TTL (explicit
12
- * changes or annotation activity re-stamp it) expires back to the minimal
7
+ * A raw-HTML session opens with both side surfaces closed so the page gets
8
+ * the viewport; an explicit change the user makes (opening the sidebar or the
9
+ * annotations drawer) persists for later HTML sessions, but only while they
10
+ * keep using HTML annotate: state not refreshed within the staleness TTL
11
+ * (explicit changes or annotation activity re-stamp it) expires back to the
13
12
  * defaults. Persisted as a cookie (like every other cross-session UI pref;
14
13
  * hook servers run on random ports, and cookies are scoped by domain, not
15
14
  * port). Markdown sessions are untouched. A legacy record without a timestamp
16
- * has an unknowable age and is treated as expired, which one-time resets
17
- * everyone to the minimal defaults.
15
+ * has an unknowable age and is treated as expired.
16
+ *
17
+ * `toolsHidden` is the header "Hide tools" toggle: while true, ALL floating
18
+ * chrome over the page (sidebar tongue tabs + the comment/attachments
19
+ * cluster) is removed from the DOM. Restoring it hidden can never strand a
20
+ * user: the header button that flips it back is part of the header, not the
21
+ * hidden chrome.
18
22
  */
19
23
 
20
24
  const STORAGE_KEY = 'plannotator-html-chrome';
21
25
 
22
26
  export interface HtmlChromeState {
23
- /** The header "Hide tools" toggle — true hides all annotation chrome. */
24
- toolsHidden: boolean;
25
27
  /** Whether the left sidebar was open when the user last left. */
26
28
  sidebarOpen: boolean;
27
29
  /** Whether the right annotations drawer was open when the user last left. */
28
30
  panelOpen: boolean;
31
+ /** Whether ALL floating tools over the page were hidden when the user left. */
32
+ toolsHidden: boolean;
29
33
  }
30
34
 
31
- /** Default: minimal paint — everything hidden, both side surfaces closed. */
35
+ /** Default: both side surfaces closed — the page gets the viewport. */
32
36
  export const DEFAULT_HTML_CHROME_STATE: HtmlChromeState = {
33
- toolsHidden: true,
34
37
  sidebarOpen: false,
35
38
  panelOpen: false,
39
+ toolsHidden: false,
36
40
  };
37
41
 
38
42
  /** Pure resolution logic (exported for tests): raw cookie value → state. */
@@ -49,15 +53,15 @@ export function resolveHtmlChromeState(
49
53
  const record = parsed as Record<string, unknown>;
50
54
  if (isStalePreference(record.savedAt, now)) return DEFAULT_HTML_CHROME_STATE;
51
55
  return {
52
- toolsHidden: typeof record.toolsHidden === 'boolean'
53
- ? record.toolsHidden
54
- : DEFAULT_HTML_CHROME_STATE.toolsHidden,
55
56
  sidebarOpen: typeof record.sidebarOpen === 'boolean'
56
57
  ? record.sidebarOpen
57
58
  : DEFAULT_HTML_CHROME_STATE.sidebarOpen,
58
59
  panelOpen: typeof record.panelOpen === 'boolean'
59
60
  ? record.panelOpen
60
61
  : DEFAULT_HTML_CHROME_STATE.panelOpen,
62
+ toolsHidden: typeof record.toolsHidden === 'boolean'
63
+ ? record.toolsHidden
64
+ : DEFAULT_HTML_CHROME_STATE.toolsHidden,
61
65
  };
62
66
  } catch {
63
67
  return DEFAULT_HTML_CHROME_STATE;
@@ -1,18 +1,22 @@
1
1
  /**
2
- * Tracks whether the user has seen the UI 2.0 "look & feel" refresh announcement.
3
- * Uses cookies so the dismissal survives Plannotator's random localhost ports.
2
+ * Tracks whether the user has explicitly resolved the Grid/Clean plan choice.
3
+ * This is separate from `gridEnabled`: ConfigStore seeds that preference with
4
+ * its default on first access, which is not evidence that the user chose it.
4
5
  */
5
6
 
6
7
  import { storage } from './storage';
7
8
 
8
- const STORAGE_KEY = 'plannotator-look-feel-announcement-seen';
9
- // v2: grid is the default again; the dialog became a grid-vs-clean image chooser.
10
- const CURRENT_VERSION = '2';
9
+ const CHOICE_RESOLVED_KEY = 'plannotator-plan-look-choice-resolved';
10
+ const LEGACY_ANNOUNCEMENT_KEY = 'plannotator-look-feel-announcement-seen';
11
11
 
12
12
  export function needsLookAndFeelAnnouncement(): boolean {
13
- return storage.getItem(STORAGE_KEY) !== CURRENT_VERSION;
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';
14
18
  }
15
19
 
16
- export function markLookAndFeelAnnouncementSeen(): void {
17
- storage.setItem(STORAGE_KEY, CURRENT_VERSION);
20
+ export function markLookAndFeelChoiceResolved(): void {
21
+ storage.setItem(CHOICE_RESOLVED_KEY, 'true');
18
22
  }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Extra markdown extensions, renderer side (#1307).
3
+ *
4
+ * The server resolves `markdownExtensions` from `~/.plannotator/config.json`
5
+ * and ships the normalized list with the annotate payload. The renderer needs
6
+ * it for one job: deciding whether a relative link or a wiki-link target names
7
+ * a local document it should open in the linked-doc overlay (`/api/doc`) or a
8
+ * plain external link. Without it, `[notes](notes.livemd)` renders as a dead
9
+ * external link even though the server would happily serve it.
10
+ *
11
+ * Module-level registry seam, like `skillReferences.ts`: a host (or the app's
12
+ * own boot code) registers the list once, everything else reads it. Empty by
13
+ * default, so nothing changes for a user with no config.
14
+ *
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
17
+ * separate decision. Extras are added on top of it.
18
+ */
19
+
20
+ import { normalizeMarkdownExtensions } from "@plannotator/core/annotatable";
21
+
22
+ /** Built-in extensions the renderer treats as openable local documents. */
23
+ const BUILTIN_LINKED_DOC_REGEX = /\.(mdx?|txt|html?)$/i;
24
+
25
+ let extraExtensions: string[] = [];
26
+
27
+ /**
28
+ * Register the extra markdown extensions for this page. Values are normalized
29
+ * with the same rules the server applies (dot-led, lowercased, `.env` denied),
30
+ * so a hostile or malformed payload cannot inject regex or path fragments.
31
+ */
32
+ export function setExtraMarkdownExtensions(value: unknown): void {
33
+ extraExtensions = normalizeMarkdownExtensions(value);
34
+ }
35
+
36
+ /** The registered extra extensions (normalized, possibly empty). */
37
+ export function getExtraMarkdownExtensions(): string[] {
38
+ return extraExtensions;
39
+ }
40
+
41
+ /**
42
+ * Does this link target name a local document the linked-doc overlay can open?
43
+ *
44
+ * `allowFragment` mirrors the two call sites this replaced: markdown links
45
+ * accept a trailing `#fragment` (stripped by the caller before navigating),
46
+ * wiki-link targets do not.
47
+ */
48
+ export function hasLinkedDocExtension(
49
+ target: string,
50
+ options?: { allowFragment?: boolean },
51
+ ): boolean {
52
+ const trimmed = target.trim();
53
+ const path = options?.allowFragment ? trimmed.replace(/#.*$/, "") : trimmed;
54
+ if (BUILTIN_LINKED_DOC_REGEX.test(path)) return true;
55
+ const lower = path.toLowerCase();
56
+ return extraExtensions.some((ext) => lower.endsWith(ext));
57
+ }
package/utils/parser.ts CHANGED
@@ -1178,8 +1178,43 @@ export const exportAnnotations = (
1178
1178
  output += `I've reviewed this ${subject} and have ${annotations.length} piece${annotations.length > 1 ? 's' : ''} of feedback:\n\n`;
1179
1179
  }
1180
1180
 
1181
- sortedAnns.forEach((ann, index) => {
1182
- output += `## ${index + 1}. `;
1181
+ // Live app sessions stamp annotations with the page they were made on.
1182
+ // When any exported annotation carries a pageUrl, entries are grouped under
1183
+ // per-page `## Page:` headings in order of first appearance and every entry
1184
+ // demotes to `###` so it nests BELOW its page header (a `### Page:` header
1185
+ // over `##` entries would invert the hierarchy); annotations without a page
1186
+ // (e.g. globals) come first under no heading, at the same `###` level so
1187
+ // entries render uniformly. Numbers stay GLOBAL: each entry keeps the
1188
+ // number of its position in the ungrouped order, matching the on-page
1189
+ // marker numbering, so grouped sections may show non-contiguous numbers.
1190
+ // With no pageUrl anywhere the output is byte-identical to the ungrouped
1191
+ // export (`## N.` entries, no page headers).
1192
+ const hasPageGroups = sortedAnns.some(
1193
+ (a: any) => typeof a.pageUrl === 'string' && a.pageUrl.length > 0,
1194
+ );
1195
+ const annotationNumbers = new Map<any, number>(
1196
+ sortedAnns.map((ann, index) => [ann, index + 1]),
1197
+ );
1198
+ let emitOrder = sortedAnns;
1199
+ if (hasPageGroups) {
1200
+ const unpaged = sortedAnns.filter((a: any) => !a.pageUrl);
1201
+ const pageOrder: string[] = [];
1202
+ for (const ann of sortedAnns) {
1203
+ if (ann.pageUrl && !pageOrder.includes(ann.pageUrl)) pageOrder.push(ann.pageUrl);
1204
+ }
1205
+ emitOrder = [
1206
+ ...unpaged,
1207
+ ...pageOrder.flatMap((page) => sortedAnns.filter((a: any) => a.pageUrl === page)),
1208
+ ];
1209
+ }
1210
+
1211
+ let lastEmittedPage: string | null = null;
1212
+ emitOrder.forEach((ann) => {
1213
+ if (hasPageGroups && ann.pageUrl && ann.pageUrl !== lastEmittedPage) {
1214
+ output += `## Page: ${ann.pageUrl}\n\n`;
1215
+ lastEmittedPage = ann.pageUrl;
1216
+ }
1217
+ output += `${hasPageGroups ? '###' : '##'} ${annotationNumbers.get(ann)}. `;
1183
1218
 
1184
1219
  // Add diff context label if annotation was created in diff view
1185
1220
  if (ann.diffContext) {
@@ -1,3 +1,4 @@
1
+ import { getScrollViewportRect } from '../hooks/useScrollViewport';
1
2
  import { getAnnotatableTextNodes } from './domSelection';
2
3
 
3
4
  /** A durable cursor location measured within one rendered Markdown block. */
@@ -132,7 +133,9 @@ function getViewportCenterY(
132
133
  container: HTMLElement,
133
134
  scrollViewport?: HTMLElement | null,
134
135
  ): number {
135
- const rect = (scrollViewport ?? container).getBoundingClientRect();
136
+ const rect = scrollViewport
137
+ ? getScrollViewportRect(scrollViewport)
138
+ : container.getBoundingClientRect();
136
139
  return rect.top + rect.height / 2;
137
140
  }
138
141