@1agh/maude 1.4.5 → 1.5.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 (106) hide show
  1. package/apps/studio/annotations/ai-read.ts +288 -0
  2. package/apps/studio/annotations/ai-write.ts +533 -0
  3. package/apps/studio/annotations/board-io.ts +94 -0
  4. package/apps/studio/annotations/board-text.ts +45 -0
  5. package/apps/studio/annotations/constants.ts +58 -0
  6. package/apps/studio/annotations/elements/_shared.ts +125 -0
  7. package/apps/studio/annotations/elements/arrow.model.ts +196 -0
  8. package/apps/studio/annotations/elements/media.model.ts +79 -0
  9. package/apps/studio/annotations/elements/pen.model.ts +82 -0
  10. package/apps/studio/annotations/elements/section.model.ts +70 -0
  11. package/apps/studio/annotations/elements/shape.model.ts +141 -0
  12. package/apps/studio/annotations/elements/sticky.model.ts +48 -0
  13. package/apps/studio/annotations/elements/text.model.ts +52 -0
  14. package/apps/studio/annotations/fields.ts +347 -0
  15. package/apps/studio/annotations/fractional-index.ts +234 -0
  16. package/apps/studio/annotations/legacy/mini-dom.ts +207 -0
  17. package/apps/studio/annotations/migrate-boot.ts +172 -0
  18. package/apps/studio/annotations/migrate-cli.ts +37 -0
  19. package/apps/studio/annotations/migrate-v1.ts +383 -0
  20. package/apps/studio/annotations/ops.ts +474 -0
  21. package/apps/studio/annotations/registry.ts +159 -0
  22. package/apps/studio/annotations/replica.ts +264 -0
  23. package/apps/studio/annotations/scene.ts +235 -0
  24. package/apps/studio/annotations/schema.ts +188 -0
  25. package/apps/studio/annotations/types.ts +77 -0
  26. package/apps/studio/annotations/ui/board.ts +126 -0
  27. package/apps/studio/annotations/ui/containment.ts +118 -0
  28. package/apps/studio/annotations/ui/edit-actions.ts +551 -0
  29. package/apps/studio/annotations/ui/editor-channel.ts +62 -0
  30. package/apps/studio/annotations/ui/element-node.tsx +993 -0
  31. package/apps/studio/annotations/ui/pipeline-context.ts +17 -0
  32. package/apps/studio/annotations/ui/pointer-pipeline.ts +150 -0
  33. package/apps/studio/annotations/ui/render-model.ts +264 -0
  34. package/apps/studio/annotations/ui/scene.tsx +63 -0
  35. package/apps/studio/annotations/ui/text-editor.tsx +420 -0
  36. package/apps/studio/annotations/ui/text-session.ts +140 -0
  37. package/apps/studio/annotations/ui/text-style.ts +111 -0
  38. package/apps/studio/annotations/ui/world.ts +47 -0
  39. package/apps/studio/annotations/v1-adapter.ts +428 -0
  40. package/apps/studio/annotations-align.ts +21 -6
  41. package/apps/studio/annotations-bindings.ts +2 -0
  42. package/apps/studio/annotations-context-toolbar.tsx +9 -13
  43. package/apps/studio/annotations-groups.ts +3 -0
  44. package/apps/studio/annotations-layer.tsx +923 -1925
  45. package/apps/studio/annotations-model.ts +97 -4
  46. package/apps/studio/annotations-sync.ts +4 -47
  47. package/apps/studio/api.ts +262 -68
  48. package/apps/studio/bin/_import-figma.mjs +22 -12
  49. package/apps/studio/bin/annotate.mjs +331 -838
  50. package/apps/studio/bin/annotate.sh +4 -4
  51. package/apps/studio/bin/perf.sh +21 -7
  52. package/apps/studio/bin/read-annotations.mjs +184 -666
  53. package/apps/studio/bin/read-annotations.sh +9 -5
  54. package/apps/studio/canvas-artifacts.ts +9 -0
  55. package/apps/studio/canvas-comment-mount.tsx +35 -2
  56. package/apps/studio/canvas-lib.tsx +18 -1
  57. package/apps/studio/canvas-shell.tsx +54 -1
  58. package/apps/studio/client/app.jsx +101 -35
  59. package/apps/studio/client/comments-overlay.css +10 -0
  60. package/apps/studio/client/hmr.mjs +1 -1
  61. package/apps/studio/client/panels/git-grouping.js +2 -2
  62. package/apps/studio/client/tree-expansion.js +217 -0
  63. package/apps/studio/collab/index.ts +58 -5
  64. package/apps/studio/collab/persistence.ts +80 -11
  65. package/apps/studio/collab/registry.ts +63 -26
  66. package/apps/studio/commands/annotation-ops-command.ts +72 -0
  67. package/apps/studio/comment-anchor.ts +116 -0
  68. package/apps/studio/comments-overlay.tsx +78 -35
  69. package/apps/studio/context.ts +1 -0
  70. package/apps/studio/cursors-overlay.tsx +337 -109
  71. package/apps/studio/dist/client.bundle.js +850 -850
  72. package/apps/studio/dist/comment-mount.js +2 -2
  73. package/apps/studio/figma/to-strokes.ts +31 -10
  74. package/apps/studio/git/endpoints.ts +1 -1
  75. package/apps/studio/git/service.ts +1 -1
  76. package/apps/studio/git/watch.ts +1 -1
  77. package/apps/studio/http.ts +90 -15
  78. package/apps/studio/server.ts +16 -0
  79. package/apps/studio/sync/accepted-cold-start.ts +72 -8
  80. package/apps/studio/sync/agent.ts +36 -6
  81. package/apps/studio/sync/codec.ts +95 -40
  82. package/apps/studio/sync/comment-ledger.ts +229 -0
  83. package/apps/studio/sync/file-membership.ts +12 -2
  84. package/apps/studio/sync/file-plane.ts +78 -2
  85. package/apps/studio/sync/index.ts +75 -16
  86. package/apps/studio/sync/journal-client.ts +5 -0
  87. package/apps/studio/sync/limits.ts +7 -2
  88. package/apps/studio/sync/migrate-seed.ts +17 -7
  89. package/apps/studio/sync/projection.ts +7 -2
  90. package/apps/studio/sync/remote-docs.ts +34 -0
  91. package/apps/studio/sync/writer-registry.ts +10 -0
  92. package/apps/studio/text-caret.ts +11 -2
  93. package/apps/studio/tree-state.ts +45 -0
  94. package/apps/studio/undo-stack.ts +2 -2
  95. package/apps/studio/use-annotation-resize.tsx +48 -23
  96. package/apps/studio/use-annotation-selection.tsx +9 -2
  97. package/apps/studio/use-collab.tsx +104 -1
  98. package/apps/studio/use-selection-set.tsx +4 -0
  99. package/apps/studio/whats-new.json +63 -0
  100. package/cli/lib/design-link.mjs +5 -1
  101. package/cli/lib/gitignore-block.mjs +1 -1
  102. package/cli/lib/gitignore-drift.mjs +2 -1
  103. package/package.json +9 -8
  104. package/plugins/design/templates/brief-board.tsx.template +1 -1
  105. package/apps/studio/annotation-edit-base.ts +0 -36
  106. package/apps/studio/commands/annotation-strokes-command.ts +0 -137
@@ -0,0 +1,72 @@
1
+ /**
2
+ * @file commands/annotation-ops-command.ts — undo entry for an annotation op batch
3
+ * @scope apps/studio/commands/annotation-ops-command.ts
4
+ * @purpose DDR-242 AD4 (Task 26): one user gesture is one op batch and one
5
+ * undo record; undo is the batch's INVERSE (from `applyOps`),
6
+ * which is peer-safe at field granularity — it reverts only the
7
+ * fields this action set and only while they still hold its value.
8
+ * Nothing here replays a snapshot of the whole board.
9
+ *
10
+ * Rebuilt per iframe mount from a serializable record (DDR-050), so
11
+ * the stack survives canvas switches; the side effect (apply
12
+ * locally + send) is the layer's `annotationOpsFn` sink.
13
+ */
14
+
15
+ import type { Op } from '../annotations/ops.ts';
16
+ import type { CommandRecord, EditCommand } from '../undo-stack.ts';
17
+ import { registerCommand } from '../undo-stack.ts';
18
+
19
+ export const ANNOTATION_OPS_KIND = 'annotation-ops';
20
+
21
+ export interface AnnotationOpsPayload {
22
+ ops: readonly Op[];
23
+ inverse: readonly Op[];
24
+ /**
25
+ * The canvas the batch belongs to. The stack's records live in origin-wide
26
+ * sessionStorage, so the layer replays only its own canvas's (security
27
+ * review A5 — another canvas must not steer this one's undo).
28
+ */
29
+ file?: string;
30
+ }
31
+
32
+ /** Applies a batch locally and sends it; returns the batch's own inverse. */
33
+ export type AnnotationOpsFn = (ops: readonly Op[], file?: string) => readonly Op[] | void;
34
+
35
+ export function buildAnnotationOpsRecord(opts: {
36
+ ops: readonly Op[];
37
+ inverse: readonly Op[];
38
+ label: string;
39
+ file?: string;
40
+ }): CommandRecord<AnnotationOpsPayload> {
41
+ return {
42
+ kind: ANNOTATION_OPS_KIND,
43
+ label: opts.label,
44
+ payload: {
45
+ ops: structuredClone(opts.ops),
46
+ inverse: structuredClone(opts.inverse),
47
+ ...(opts.file ? { file: opts.file } : {}),
48
+ },
49
+ };
50
+ }
51
+
52
+ export function createAnnotationOpsCommand(
53
+ record: CommandRecord<AnnotationOpsPayload>,
54
+ fn: AnnotationOpsFn
55
+ ): EditCommand {
56
+ return {
57
+ kind: ANNOTATION_OPS_KIND,
58
+ label: record.label,
59
+ async do() {
60
+ fn(record.payload.ops, record.payload.file);
61
+ },
62
+ async undo() {
63
+ fn(record.payload.inverse, record.payload.file);
64
+ },
65
+ };
66
+ }
67
+
68
+ registerCommand<AnnotationOpsPayload>(ANNOTATION_OPS_KIND, (record, sinks) => {
69
+ const fn = sinks.annotationOpsFn as AnnotationOpsFn | undefined;
70
+ if (!fn) return null;
71
+ return createAnnotationOpsCommand(record, fn);
72
+ });
@@ -0,0 +1,116 @@
1
+ /**
2
+ * @file comment-anchor.ts — where a comment lives when it is not on an element
3
+ * @scope apps/studio/comment-anchor.ts
4
+ * @purpose Two anchors besides the `data-cd-id` element selector (#134/#136):
5
+ *
6
+ * - `annotationId` — a comment placed on a sticky, shape, pen stroke, image,
7
+ * link or media card anchors to that annotation's `data-id`. Annotation ids
8
+ * are persisted in `*.annotations.svg` and stable across edits, undo and
9
+ * sync (see `stableAnnotationId` in annotations-model.ts for the one case
10
+ * they were not). The pin follows the annotation; a deleted annotation
11
+ * DETACHES the comment, it never deletes it.
12
+ * - `world` — a comment placed on empty canvas holds a WORLD point, so it stays
13
+ * put through pan and zoom. The legacy screen `bounds` captured at create
14
+ * time only matched the camera of that moment.
15
+ *
16
+ * Runs inside the canvas iframe, in both the canvas bundle and the separate
17
+ * comment-mount bundle — so the camera is read through `window.__maudeViewport`
18
+ * (canvas-lib.tsx), not through a module import that would be another copy.
19
+ * Annotation ids are peer-supplied (DDR-054): every id is validated before it
20
+ * reaches a selector.
21
+ */
22
+
23
+ export interface WorldPoint {
24
+ x: number;
25
+ y: number;
26
+ }
27
+
28
+ interface Viewport {
29
+ x: number;
30
+ y: number;
31
+ zoom: number;
32
+ }
33
+
34
+ /** Shape rule for an annotation id — the `s_…` scheme plus imported ids. */
35
+ const ANNOTATION_ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
36
+ /** World coordinates beyond this are garbage, not a place on the board. */
37
+ const MAX_WORLD = 10_000_000;
38
+
39
+ export function isAnnotationId(v: unknown): v is string {
40
+ return typeof v === 'string' && ANNOTATION_ID_RE.test(v);
41
+ }
42
+
43
+ export function isWorldPoint(v: unknown): v is WorldPoint {
44
+ if (!v || typeof v !== 'object') return false;
45
+ const { x, y } = v as { x?: unknown; y?: unknown };
46
+ return (
47
+ typeof x === 'number' &&
48
+ typeof y === 'number' &&
49
+ Number.isFinite(x) &&
50
+ Number.isFinite(y) &&
51
+ Math.abs(x) <= MAX_WORLD &&
52
+ Math.abs(y) <= MAX_WORLD
53
+ );
54
+ }
55
+
56
+ function liveCamera(): { vp: Viewport; left: number; top: number } | null {
57
+ if (typeof window === 'undefined' || typeof document === 'undefined') return null;
58
+ const vp = window.__maudeViewport?.() ?? null;
59
+ if (!vp || !(vp.zoom > 0)) return null;
60
+ const host = document.querySelector('.dc-canvas');
61
+ if (!host) return null;
62
+ const r = host.getBoundingClientRect();
63
+ return { vp, left: r.left, top: r.top };
64
+ }
65
+
66
+ /** Client (iframe viewport) point → world point, or null with no world plane. */
67
+ export function clientToWorld(clientX: number, clientY: number): WorldPoint | null {
68
+ const cam = liveCamera();
69
+ if (!cam) return null;
70
+ const { vp } = cam;
71
+ return {
72
+ x: (clientX - cam.left - vp.x) / vp.zoom,
73
+ y: (clientY - cam.top - vp.y) / vp.zoom,
74
+ };
75
+ }
76
+
77
+ /** World point → client point, or null with no world plane. */
78
+ export function worldToClient(p: WorldPoint): { x: number; y: number } | null {
79
+ const cam = liveCamera();
80
+ if (!cam) return null;
81
+ const { vp } = cam;
82
+ return { x: cam.left + vp.x + p.x * vp.zoom, y: cam.top + vp.y + p.y * vp.zoom };
83
+ }
84
+
85
+ /** The live element of an annotation, or null. */
86
+ export function annotationElement(id: string): Element | null {
87
+ if (typeof document === 'undefined' || !isAnnotationId(id)) return null;
88
+ // The id is already restricted to [A-Za-z0-9_-]; no escaping needed.
89
+ return document.querySelector(`[data-id="${id}"][data-tool]`);
90
+ }
91
+
92
+ /**
93
+ * The annotation under a client point, if any. Geometric rather than
94
+ * `elementFromPoint`: in comment mode the annotation layer is not the hit
95
+ * target (its pointer events belong to the draw tools), which is exactly why a
96
+ * click on a sticky used to fall through to a floating comment.
97
+ *
98
+ * The smallest containing box wins, so a sticky inside a section anchors to
99
+ * the sticky. Sections are skipped: they frame artboards and other elements,
100
+ * and "a comment somewhere inside this section" is a floating comment.
101
+ */
102
+ export function annotationIdAt(clientX: number, clientY: number): string | null {
103
+ if (typeof document === 'undefined') return null;
104
+ let best: { id: string; area: number } | null = null;
105
+ for (const el of Array.from(document.querySelectorAll('[data-id][data-tool]'))) {
106
+ if (el.getAttribute('data-tool') === 'section') continue;
107
+ const id = el.getAttribute('data-id');
108
+ if (!isAnnotationId(id)) continue;
109
+ const r = el.getBoundingClientRect();
110
+ if (r.width <= 0 && r.height <= 0) continue;
111
+ if (clientX < r.left || clientX > r.right || clientY < r.top || clientY > r.bottom) continue;
112
+ const area = Math.max(1, r.width) * Math.max(1, r.height);
113
+ if (!best || area <= best.area) best = { id, area };
114
+ }
115
+ return best?.id ?? null;
116
+ }
@@ -33,9 +33,13 @@
33
33
  * trade-off), which can silently reanchor a comment to the wrong element or to
34
34
  * nothing. `resolveCommentTarget` tries the stored selector first, falls back
35
35
  * to a structural match via `resolveByDomPath` (dom-selection.ts) when the
36
- * direct hit is missing or looks like the wrong element (tag mismatch), and —
37
- * per DDR-034's deferred future-work item — `CommentPin` auto-deletes a
38
- * comment whose target stays unresolvable past a short grace window.
36
+ * direct hit is missing or looks like the wrong element (tag mismatch). A
37
+ * comment whose target stays unresolvable past a short grace window is
38
+ * DETACHED — kept, shown at its last known place, and marked — never deleted:
39
+ * software deleting a comment is how #134/#136 lost every comment placed on a
40
+ * sticky or on empty canvas, and how one peer's missing element deleted a
41
+ * comment for everyone. Only a person deletes a comment. Comments on
42
+ * annotations and on empty canvas anchor through `comment-anchor.ts`.
39
43
  *
40
44
  * Popup placement — `CommentComposer` / `CommentThread` pick a side
41
45
  * (left/right, above/below the anchor) that actually fits the viewport via
@@ -49,6 +53,13 @@
49
53
 
50
54
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
51
55
 
56
+ import {
57
+ annotationElement,
58
+ isAnnotationId,
59
+ isWorldPoint,
60
+ type WorldPoint,
61
+ worldToClient,
62
+ } from './comment-anchor.ts';
52
63
  import { resolveByDomPath } from './dom-selection.ts';
53
64
  import { isReadOnlyCanvas } from './read-only-mode.ts';
54
65
  import { useCollab } from './use-collab.tsx';
@@ -84,6 +95,10 @@ interface ComposeSelection {
84
95
  dom_path: string[];
85
96
  bounds: OverlayBounds | null;
86
97
  html: string;
98
+ /** Set when the click landed on an annotation (comment-anchor.ts). */
99
+ annotationId?: string;
100
+ /** World point of the click, when the canvas has a world plane. */
101
+ world?: WorldPoint;
87
102
  }
88
103
 
89
104
  interface ComposerState {
@@ -121,6 +136,11 @@ export interface OverlayComment {
121
136
  tag?: string;
122
137
  classes?: string;
123
138
  dom_path?: string[];
139
+ /** Anchor on an annotation (sticky, shape, stroke…) — see comment-anchor.ts. */
140
+ annotationId?: string;
141
+ /** World point of the comment — the anchor of a floating comment, and the
142
+ * last known place of an anchored one whose target is gone. */
143
+ world?: WorldPoint;
124
144
  // html_excerpt unused at overlay layer; kept off the type to keep the
125
145
  // surface tight.
126
146
  }
@@ -176,6 +196,7 @@ export interface TargetRef {
176
196
  tag?: string;
177
197
  classes?: string;
178
198
  dom_path?: string[];
199
+ annotationId?: string;
179
200
  }
180
201
 
181
202
  /**
@@ -187,6 +208,9 @@ export interface TargetRef {
187
208
  * match via `resolveByDomPath`.
188
209
  */
189
210
  export function resolveCommentTarget(target: TargetRef): HTMLElement | null {
211
+ if (isAnnotationId(target.annotationId)) {
212
+ return annotationElement(target.annotationId) as HTMLElement | null;
213
+ }
190
214
  if (!target.selector) return null;
191
215
  let el: HTMLElement | null = null;
192
216
  try {
@@ -426,6 +450,8 @@ export function CommentsOverlay(): React.ReactNode {
426
450
  classes: sel.classes,
427
451
  bounds: sel.bounds,
428
452
  html_excerpt: sel.html,
453
+ ...(sel.annotationId ? { annotationId: sel.annotationId } : {}),
454
+ ...(sel.world ? { world: sel.world } : {}),
429
455
  text,
430
456
  };
431
457
  if (typeof window === 'undefined') return;
@@ -547,7 +573,6 @@ export function CommentsOverlay(): React.ReactNode {
547
573
  sequence={n}
548
574
  focused={focusedId === c.id}
549
575
  onClick={handlePinClick}
550
- onOrphaned={handleDelete}
551
576
  />
552
577
  );
553
578
  })}
@@ -836,28 +861,54 @@ function MentionAwareTextarea({
836
861
  // reflow, font load). Falls back to the stored `bounds` when the target is
837
862
  // gone from the DOM.
838
863
 
839
- // How long a pin is allowed to stay unresolvable (no live target AND no
840
- // structural-fallback match) before its comment is presumed orphaned and
841
- // auto-deleted. Long enough to ride out a canvas HMR remount; short enough
842
- // that a genuinely deleted element's comment doesn't linger.
843
- const ORPHAN_GRACE_MS = 3000;
864
+ // How long a pin may stay unresolvable before it is shown as DETACHED. Long
865
+ // enough to ride out a canvas HMR remount. Detached is a view of this client's
866
+ // DOM, never a fact written back: another peer may still resolve the target.
867
+ const DETACH_GRACE_MS = 3000;
868
+
869
+ /** The pin box for a client point — the pin centres on the point. */
870
+ function pointBox(p: { x: number; y: number }): OverlayBounds {
871
+ return { x: p.x - 12, y: p.y - 12, w: 24, h: 24 };
872
+ }
873
+
874
+ /**
875
+ * Where a comment's pin goes this frame, and whether its target is missing.
876
+ *
877
+ * - anchored (element selector or annotation id) and resolved → on the target;
878
+ * - anchored but unresolved → last known place (world point, else the
879
+ * create-time screen bounds), `missing: true`;
880
+ * - floating (no anchor) → its world point, else legacy screen bounds.
881
+ */
882
+ export function locateComment(comment: OverlayComment): {
883
+ pos: OverlayBounds | null;
884
+ missing: boolean;
885
+ } {
886
+ const anchored = !!comment.selector || isAnnotationId(comment.annotationId);
887
+ if (anchored) {
888
+ const live = screenRectFor(comment);
889
+ if (live) return { pos: live, missing: false };
890
+ }
891
+ const at = isWorldPoint(comment.world) ? worldToClient(comment.world) : null;
892
+ const pos = at ? pointBox(at) : (comment.bounds ?? null);
893
+ return { pos, missing: anchored };
894
+ }
844
895
 
845
896
  function CommentPin({
846
897
  comment,
847
898
  sequence,
848
899
  focused,
849
900
  onClick,
850
- onOrphaned,
851
901
  }: {
852
902
  comment: OverlayComment;
853
903
  sequence: number;
854
904
  focused: boolean;
855
905
  onClick: (id: string) => void;
856
- onOrphaned: (id: string) => void;
857
906
  }) {
858
907
  const ref = useRef<HTMLButtonElement | null>(null);
859
908
  const rafRef = useRef<number | null>(null);
860
- const unresolvedSinceRef = useRef<number | null>(null);
909
+ const missingSinceRef = useRef<number | null>(null);
910
+ const [detached, setDetached] = useState(false);
911
+ const detachedRef = useRef(false);
861
912
 
862
913
  useEffect(() => {
863
914
  const tick = () => {
@@ -865,27 +916,18 @@ function CommentPin({
865
916
  const pin = ref.current;
866
917
  if (!pin) return;
867
918
 
868
- // Live screen-coord lookup mirrors SelectionHalos in canvas-shell.tsx
869
- // (resolveCommentTarget tries the stored selector, then a structural
870
- // fallback). Falls back to stored bounds (a screen-coord capture at
871
- // create time) when neither resolves — better than vanishing entirely.
872
- let pos = screenRectFor(comment);
873
- if (pos) {
874
- unresolvedSinceRef.current = null;
919
+ // Live screen-coord lookup mirrors SelectionHalos in canvas-shell.tsx.
920
+ const { pos, missing } = locateComment(comment);
921
+ let nowDetached = false;
922
+ if (missing) {
923
+ if (missingSinceRef.current == null) missingSinceRef.current = Date.now();
924
+ nowDetached = Date.now() - missingSinceRef.current > DETACH_GRACE_MS;
875
925
  } else {
876
- if (unresolvedSinceRef.current == null) {
877
- unresolvedSinceRef.current = Date.now();
878
- } else if (Date.now() - unresolvedSinceRef.current > ORPHAN_GRACE_MS) {
879
- onOrphaned(comment.id);
880
- }
881
- if (comment.bounds) {
882
- pos = {
883
- x: comment.bounds.x,
884
- y: comment.bounds.y,
885
- w: comment.bounds.w,
886
- h: comment.bounds.h,
887
- };
888
- }
926
+ missingSinceRef.current = null;
927
+ }
928
+ if (nowDetached !== detachedRef.current) {
929
+ detachedRef.current = nowDetached;
930
+ setDetached(nowDetached);
889
931
  }
890
932
  if (!pos) {
891
933
  pin.style.display = 'none';
@@ -905,10 +947,10 @@ function CommentPin({
905
947
  return () => {
906
948
  if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
907
949
  };
908
- }, [comment, onOrphaned]);
950
+ }, [comment]);
909
951
 
910
952
  const author = comment.author?.trim() || 'unknown';
911
- const label = `Comment ${sequence} by ${author}`;
953
+ const label = `Comment ${sequence} by ${author}${detached ? ' (detached: its target is gone)' : ''}`;
912
954
 
913
955
  return (
914
956
  <button
@@ -917,6 +959,7 @@ function CommentPin({
917
959
  className="cm-pin"
918
960
  data-resolved={comment.status === 'resolved' ? 'true' : 'false'}
919
961
  data-focused={focused ? 'true' : 'false'}
962
+ data-detached={detached ? 'true' : 'false'}
920
963
  data-comment-pin={comment.id}
921
964
  aria-label={label}
922
965
  aria-expanded={focused}
@@ -1002,7 +1045,7 @@ function CommentComposer({
1002
1045
  // tight inside the 300px card.
1003
1046
  const selectorChip = useMemo(() => {
1004
1047
  const s = state.selection.selector || '';
1005
- if (!s) return state.selection.tag || 'canvas';
1048
+ if (!s) return state.selection.tag || (state.selection.annotationId ? 'annotation' : 'canvas');
1006
1049
  // [data-cd-id="…"] → cd:<id> · keeps the chip readable when stable ids
1007
1050
  // are present.
1008
1051
  const cd = s.match(/data-cd-id="([^"]+)"/);
@@ -274,6 +274,7 @@ export interface Context {
274
274
  | { op: 'dir.move'; from: string; to: string }
275
275
  ): Promise<{ status: 'accepted' | 'rejected'; code?: string; queued?: boolean }> | null;
276
276
  acceptedMode?(): boolean;
277
+ commentsConfirmedOnHub?(slug: string): boolean;
277
278
  acceptedHistory?(q: {
278
279
  limit?: number;
279
280
  before?: number | null;