@plannotator/ui 0.30.0 → 0.32.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 (102) hide show
  1. package/README.md +46 -1
  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 +158 -36
  6. package/components/AnnotationToolbar.tsx +50 -30
  7. package/components/AnnotationToolstrip.tsx +9 -0
  8. package/components/CommentPopover.tsx +238 -46
  9. package/components/ConfirmDialog.tsx +42 -28
  10. package/components/GraphvizBlock.tsx +86 -7
  11. package/components/HtmlSurfaceControls.tsx +170 -0
  12. package/components/InlineMarkdown.tsx +25 -4
  13. package/components/KeyboardShortcuts.tsx +9 -0
  14. package/components/Landing.tsx +1 -1
  15. package/components/LookAndFeelAnnouncementDialog.tsx +147 -178
  16. package/components/MarkdownEditor/embedPicker.ts +349 -0
  17. package/components/MarkdownEditor.tsx +12 -0
  18. package/components/MermaidBlock.tsx +60 -26
  19. package/components/ModeToggle.tsx +2 -1
  20. package/components/PermissionModeSetup.tsx +24 -5
  21. package/components/PinpointOverlay.tsx +11 -6
  22. package/components/PlanHeaderMenu.tsx +140 -1
  23. package/components/SearchableSelect.tsx +2 -0
  24. package/components/Settings.tsx +175 -10
  25. package/components/SkillReferenceMenu.tsx +9 -0
  26. package/components/StickyHeaderLane.tsx +9 -2
  27. package/components/TableOfContents.tsx +9 -4
  28. package/components/TextShimmer.tsx +8 -5
  29. package/components/ThemeProvider.tsx +43 -1
  30. package/components/ThemeTab.tsx +52 -1
  31. package/components/Tooltip.tsx +3 -1
  32. package/components/Viewer.tsx +19 -5
  33. package/components/VimTargetReticle.tsx +12 -4
  34. package/components/ai/DocumentAIChatPanel.tsx +1 -0
  35. package/components/blocks/MathBlock.tsx +26 -14
  36. package/components/core/button.tsx +14 -6
  37. package/components/html-viewer/HtmlViewer.tsx +320 -41
  38. package/components/html-viewer/bridge-script.ts +360 -72
  39. package/components/html-viewer/composerYield.ts +1 -51
  40. package/components/html-viewer/hostThreads.ts +37 -0
  41. package/components/html-viewer/index.ts +9 -0
  42. package/components/html-viewer/unanchored.ts +47 -0
  43. package/components/html-viewer/useHtmlAnnotation.ts +240 -61
  44. package/components/plan-diff/PlanCleanDiffView.tsx +1 -0
  45. package/components/sidebar/FileBrowser.tsx +17 -5
  46. package/components/sidebar/SidebarContainer.tsx +124 -28
  47. package/components/ui/button.tsx +10 -8
  48. package/components/ui/dialog.tsx +35 -25
  49. package/config/index.ts +6 -1
  50. package/config/reviewView.ts +42 -9
  51. package/config/settings.ts +141 -0
  52. package/configure.ts +32 -0
  53. package/hooks/useAIProviderConfig.ts +8 -7
  54. package/hooks/useActiveSection.ts +6 -4
  55. package/hooks/useAgentJobs.ts +3 -0
  56. package/hooks/useAnnotationHighlighter.ts +20 -0
  57. package/hooks/useHtmlRefresh.ts +149 -0
  58. package/hooks/useIsMobile.ts +37 -0
  59. package/hooks/useLinkedDoc.ts +7 -0
  60. package/hooks/useMathRenderer.ts +30 -0
  61. package/hooks/useScrollViewport.ts +74 -0
  62. package/hooks/useSharing.ts +31 -5
  63. package/hooks/useViewportEnvironment.ts +350 -0
  64. package/package.json +5 -2
  65. package/shortcuts/index.ts +3 -0
  66. package/shortcuts/plan-review/annotationMode.shortcuts.ts +91 -0
  67. package/shortcuts/plan-review/documentView.shortcuts.ts +26 -0
  68. package/shortcuts/plan-review/htmlAnnotate.shortcuts.ts +24 -0
  69. package/styles.css +1 -1
  70. package/theme.css +229 -0
  71. package/types.ts +35 -0
  72. package/utils/annotateAgentTerminal.ts +36 -5
  73. package/utils/blockTargeting.ts +6 -3
  74. package/utils/composerYield.ts +45 -0
  75. package/utils/generateIdentity.ts +64 -14
  76. package/utils/htmlChrome.ts +20 -16
  77. package/utils/identity-tater.ts +36 -0
  78. package/utils/lookAndFeelAnnouncement.ts +12 -8
  79. package/utils/markdownExtensions.ts +57 -0
  80. package/utils/math-eager.ts +25 -0
  81. package/utils/math.ts +146 -0
  82. package/utils/mermaid-eager.ts +28 -0
  83. package/utils/mermaid.ts +132 -0
  84. package/utils/parser.ts +75 -2
  85. package/utils/quickLabels.ts +13 -0
  86. package/utils/vimNavigation.ts +4 -1
  87. package/utils/vimScroll.ts +9 -4
  88. package/utils/wideMode.ts +20 -0
  89. package/webmcp/activity.ts +46 -0
  90. package/webmcp/changes.ts +227 -0
  91. package/webmcp/index.ts +72 -0
  92. package/webmcp/modelContext.ts +103 -0
  93. package/webmcp/nudges.ts +174 -0
  94. package/webmcp/policy.ts +50 -0
  95. package/webmcp/preference.ts +50 -0
  96. package/webmcp/schema.ts +81 -0
  97. package/webmcp/toolset.ts +337 -0
  98. package/webmcp/useToolset.ts +74 -0
  99. package/components/PlanAIAnnouncementDialog.tsx +0 -187
  100. package/components/VimModeAnnouncementDialog.tsx +0 -557
  101. package/utils/planAIAnnouncement.ts +0 -17
  102. 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,8 @@ 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
83
+ inReplyTo?: string; // id of the annotation this one replies to; a reply inherits its parent's anchor, renders indented under it in the panel, and exports grouped under it. Additive: annotations without it render and export exactly as before.
82
84
  htmlAnchor?: HtmlElementAnchor; // raw-HTML pinpoint: serialized element anchor for reliable restoration
83
85
  htmlAdditionalTargets?: HtmlAnnotationTarget[]; // raw-HTML shift-click multi-select: extra elements this one comment covers (primary stays htmlAnchor/originalText)
84
86
  // web-highlighter metadata for cross-element selections
@@ -155,6 +157,32 @@ export type CodeAnnotationType = 'comment' | 'suggestion' | 'concern';
155
157
  // must branch on scope, never read those sentinels as a real path or row.
156
158
  export type CodeAnnotationScope = 'line' | 'file' | 'general';
157
159
 
160
+ /**
161
+ * One inferred step selected from the Call Flow analysis surface.
162
+ *
163
+ * Source location is optional because CallDiff can surface structural steps
164
+ * without a concrete line. `CodeAnnotation` uses an in-patch located target
165
+ * as its native inline anchor when one exists; otherwise the annotation is
166
+ * file- or review-scoped while this target remains its durable Call Flow
167
+ * anchor. Raw output selections use a one-based `rawLine` instead of a source
168
+ * location. This lets every rendered Call Flow row and raw line participate in
169
+ * feedback without pretending an out-of-hunk or diagnostic line can be posted
170
+ * as an inline source comment.
171
+ */
172
+ interface CallFlowAnnotationTargetBase {
173
+ treePath: string;
174
+ entry: string;
175
+ label: string;
176
+ side: 'old' | 'new';
177
+ }
178
+
179
+ export type CallFlowAnnotationTarget = CallFlowAnnotationTargetBase & (
180
+ | { filePath: string; lineStart: number; lineEnd: number; rawLine?: undefined }
181
+ | { filePath: string; lineStart?: undefined; lineEnd?: undefined; rawLine?: undefined }
182
+ | { rawLine: number; filePath?: undefined; lineStart?: undefined; lineEnd?: undefined }
183
+ | { rawLine?: undefined; filePath?: undefined; lineStart?: undefined; lineEnd?: undefined }
184
+ );
185
+
158
186
  /** Conventional Comments label — see https://conventionalcomments.org */
159
187
  export type ConventionalLabel =
160
188
  | 'praise'
@@ -214,6 +242,13 @@ export interface CodeAnnotation {
214
242
  * line anchor maps to the pristine lines those edits replace, so it is
215
243
  * approximate and the export labels it as such. */
216
244
  selectedTextFromEdits?: boolean;
245
+ /**
246
+ * Complete Call Flow selection for an annotation authored from that
247
+ * surface. When any target maps to the patch, one target also supplies this
248
+ * annotation's primary inline anchor; otherwise the annotation is file- or
249
+ * review-scoped. Target order always preserves the user's selection order.
250
+ */
251
+ callFlowTargets?: CallFlowAnnotationTarget[];
217
252
  createdAt: number;
218
253
  author?: string;
219
254
  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
+ }
@@ -1,12 +1,73 @@
1
1
  /**
2
- * Tater name generator — pure function, no storage dependencies.
2
+ * Tater name generator: pure function, no storage dependencies.
3
3
  *
4
4
  * Extracted to its own module to avoid circular imports:
5
5
  * settings.ts needs this for the default value, and identity.ts
6
6
  * needs configStore (which imports settings.ts).
7
+ *
8
+ * The full `unique-username-generator` dictionary is NOT imported here. It is
9
+ * registered into the slot below by `./identity-tater`, which every
10
+ * Plannotator entry imports eagerly, so Plannotator mints names from the
11
+ * full dictionary exactly as before. A host that provides its own
12
+ * `identityProvider` never calls the generator and, with the static import
13
+ * gone, no longer ships the word lists.
14
+ *
15
+ * The slot is SYNCHRONOUS on purpose: `configStore.ensureLoaded()` evaluates
16
+ * this default during the first settings read (a render-time read) and
17
+ * persists the result to the identity cookie at once, so a name that arrived
18
+ * later would be a visible identity change. The built-in fallback below keeps
19
+ * the same `{adjective}-{noun}-tater` shape from a small inline pool.
7
20
  */
8
21
 
9
- import { uniqueUsernameGenerator, adjectives, nouns } from 'unique-username-generator';
22
+ export type IdentityGenerator = () => string;
23
+
24
+ const FALLBACK_ADJECTIVES = [
25
+ 'swift', 'gentle', 'brave', 'calm', 'clever', 'bright', 'quiet', 'bold',
26
+ 'eager', 'kind', 'lucky', 'merry', 'nimble', 'proud', 'sunny', 'witty',
27
+ ] as const;
28
+
29
+ const FALLBACK_NOUNS = [
30
+ 'falcon', 'crystal', 'river', 'meadow', 'harbor', 'comet', 'maple', 'otter',
31
+ 'summit', 'lantern', 'willow', 'ember', 'pebble', 'breeze', 'orchid', 'canyon',
32
+ ] as const;
33
+
34
+ /** Pool words: exported for tests only. */
35
+ export const FALLBACK_IDENTITY_POOL: {
36
+ readonly adjectives: readonly string[];
37
+ readonly nouns: readonly string[];
38
+ } = {
39
+ adjectives: FALLBACK_ADJECTIVES,
40
+ nouns: FALLBACK_NOUNS,
41
+ };
42
+
43
+ function pick<T>(list: readonly T[]): T {
44
+ return list[Math.floor(Math.random() * list.length)]!;
45
+ }
46
+
47
+ /** Built-in generator: same shape as the dictionary one, from a 16 x 16 pool. */
48
+ export const fallbackIdentityGenerator: IdentityGenerator = () =>
49
+ `${pick(FALLBACK_ADJECTIVES)}-${pick(FALLBACK_NOUNS)}-tater`;
50
+
51
+ let generator: IdentityGenerator = fallbackIdentityGenerator;
52
+
53
+ /**
54
+ * Register the generator `generateIdentity()` delegates to. Must return a
55
+ * string synchronously. `./identity-tater` registers the full dictionary;
56
+ * a host may register its own via `configurePlannotatorUI({ identityGenerator })`.
57
+ */
58
+ export function setIdentityGenerator(next: IdentityGenerator): void {
59
+ generator = next;
60
+ }
61
+
62
+ /** The active generator. Exported so a test can assert which one is registered. */
63
+ export function getIdentityGenerator(): IdentityGenerator {
64
+ return generator;
65
+ }
66
+
67
+ /** Reset to the built-in fallback pool. Mainly for tests. */
68
+ export function resetIdentityGenerator(): void {
69
+ generator = fallbackIdentityGenerator;
70
+ }
10
71
 
11
72
  /**
12
73
  * Generate a new random tater identity.
@@ -14,16 +75,5 @@ import { uniqueUsernameGenerator, adjectives, nouns } from 'unique-username-gene
14
75
  * Examples: "swift-falcon-tater", "gentle-crystal-tater"
15
76
  */
16
77
  export function generateIdentity(): string {
17
- // Use a unique separator to split adjective from noun, avoiding issues
18
- // with compound words that contain hyphens (e.g., "behind-the-scenes")
19
- const generated = uniqueUsernameGenerator({
20
- dictionaries: [adjectives, nouns],
21
- separator: '|||',
22
- style: 'lowerCase',
23
- randomDigits: 0,
24
- length: 50, // Prevent word truncation (default is too short)
25
- });
26
-
27
- const [adjective, noun] = generated.split('|||');
28
- return `${adjective}-${noun}-tater`;
78
+ return generator();
29
79
  }
@@ -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;
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Eager identity registration: installs the full `unique-username-generator`
3
+ * dictionary into the generator slot in `./generateIdentity` at module
4
+ * evaluation, before any settings read can mint a name.
5
+ *
6
+ * Every Plannotator entry (`packages/editor/App.tsx`, `packages/review-editor/App.tsx`)
7
+ * imports this module for its side effect, which keeps Plannotator's tater
8
+ * names byte-identical to before: same library, same config, same call. A host
9
+ * that wants the full dictionary without its own identity provider imports it
10
+ * too:
11
+ *
12
+ * import '@plannotator/ui/utils/identity-tater';
13
+ *
14
+ * A host that provides `identityProvider` never calls the generator and should
15
+ * NOT import this, so the word lists stay out of its bundle.
16
+ */
17
+ import { uniqueUsernameGenerator, adjectives, nouns } from 'unique-username-generator';
18
+ import { setIdentityGenerator, type IdentityGenerator } from './generateIdentity';
19
+
20
+ /** The dictionary generator Plannotator has always used. */
21
+ export const generateTaterIdentity: IdentityGenerator = () => {
22
+ // Use a unique separator to split adjective from noun, avoiding issues
23
+ // with compound words that contain hyphens (e.g., "behind-the-scenes")
24
+ const generated = uniqueUsernameGenerator({
25
+ dictionaries: [adjectives, nouns],
26
+ separator: '|||',
27
+ style: 'lowerCase',
28
+ randomDigits: 0,
29
+ length: 50, // Prevent word truncation (default is too short)
30
+ });
31
+
32
+ const [adjective, noun] = generated.split('|||');
33
+ return `${adjective}-${noun}-tater`;
34
+ };
35
+
36
+ setIdentityGenerator(generateTaterIdentity);
@@ -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
  }