@plannotator/ui 0.38.2 → 0.40.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.
package/theme.css CHANGED
@@ -243,6 +243,68 @@
243
243
  animation: vim-announcement-dialog-in 200ms cubic-bezier(0.22, 1, 0.36, 1) both;
244
244
  }
245
245
 
246
+ /* First-run terminal-tools announcement: same entrance as the Vim one, and
247
+ * under reduced motion the panel still fades in, it just does not travel. */
248
+ .terminal-tools-announcement-backdrop {
249
+ animation: vim-announcement-backdrop-in 160ms ease-out both;
250
+ }
251
+
252
+ .terminal-tools-announcement-dialog {
253
+ animation: vim-announcement-dialog-in 220ms cubic-bezier(0.22, 1, 0.36, 1) both;
254
+ }
255
+
256
+ @media (prefers-reduced-motion: reduce) {
257
+ .terminal-tools-announcement-dialog {
258
+ animation: vim-announcement-backdrop-in 160ms ease-out both;
259
+ }
260
+ }
261
+
262
+ /* The "New · Watch:" corner block on the announcement footage, flush to the
263
+ * frame's top-left edge. Colors come from the dialog's own --announce-brand*
264
+ * variables (Herdr Annotate's badge palette, deliberately not app tokens); the
265
+ * periwinkle edge runs only along the two sides that meet the footage. The
266
+ * sheen is a slow diagonal highlight across the whole block every 4s, never a
267
+ * blink; the dialog drops the --sheen class under reduced motion and the media
268
+ * query below is the backstop. */
269
+ .terminal-tools-announcement-tag {
270
+ position: absolute;
271
+ overflow: hidden;
272
+ background: var(--announce-brand, #312b52);
273
+ color: var(--announce-brand-text, #c9c6f1);
274
+ border-right: 1px solid color-mix(in srgb, var(--announce-brand-accent, #B9C0FF) 55%, transparent);
275
+ border-bottom: 1px solid color-mix(in srgb, var(--announce-brand-accent, #B9C0FF) 55%, transparent);
276
+ box-shadow:
277
+ 2px 2px 0 rgba(0, 0, 0, 0.18),
278
+ 4px 6px 18px rgba(0, 0, 0, 0.32);
279
+ }
280
+
281
+ .terminal-tools-announcement-tag--sheen::after {
282
+ content: '';
283
+ position: absolute;
284
+ inset: -1px;
285
+ pointer-events: none;
286
+ background: linear-gradient(
287
+ 115deg,
288
+ transparent 38%,
289
+ color-mix(in srgb, var(--announce-brand-accent, #B9C0FF) 34%, transparent) 50%,
290
+ transparent 62%
291
+ );
292
+ transform: translateX(-100%);
293
+ animation: terminal-tools-announcement-sheen 4s ease-in-out infinite;
294
+ }
295
+
296
+ @keyframes terminal-tools-announcement-sheen {
297
+ 0% { transform: translateX(-100%); }
298
+ 45%, 100% { transform: translateX(100%); }
299
+ }
300
+
301
+ @media (prefers-reduced-motion: reduce) {
302
+ .terminal-tools-announcement-tag--sheen::after {
303
+ animation: none;
304
+ display: none;
305
+ }
306
+ }
307
+
246
308
  .vim-announcement-switch {
247
309
  transition:
248
310
  border-color 140ms ease,
package/types.ts CHANGED
@@ -82,6 +82,7 @@ export interface Annotation {
82
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
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.
84
84
  htmlAnchor?: HtmlElementAnchor; // raw-HTML pinpoint: serialized element anchor for reliable restoration
85
+ elementContext?: HtmlElementContext; // raw-HTML / live-app pinpoint: bounded agent-facing description of the primary element (never used by restore)
85
86
  htmlAdditionalTargets?: HtmlAnnotationTarget[]; // raw-HTML shift-click multi-select: extra elements this one comment covers (primary stays htmlAnchor/originalText)
86
87
  // web-highlighter metadata for cross-element selections
87
88
  startMeta?: AnnotationTextMeta;
@@ -124,6 +125,50 @@ export interface HtmlAnnotationTarget {
124
125
  text: string;
125
126
  /** Element anchor for restoration; absent when the bridge failed closed. */
126
127
  anchor?: HtmlElementAnchor;
128
+ /** Agent-facing element description (smaller budget than the primary's). */
129
+ context?: HtmlElementContext;
130
+ }
131
+
132
+ /**
133
+ * A bounded, agent-facing description of a pinpointed element, captured by the
134
+ * bridge at annotation time (only it can see the DOM). Purely descriptive:
135
+ * restore never reads it (that is `HtmlElementAnchor`'s job). It exists so the
136
+ * exported feedback can tell an agent working in the app's SOURCE which
137
+ * element the comment is about — identity (what it is), location (where it
138
+ * sits), and hooks (what to grep for) — without dumping the page. Every field
139
+ * is page-controlled and re-validated at the parent trust boundary
140
+ * (`parseHtmlElementContext`). Additive: annotations without one export
141
+ * exactly as before, and share links never carry it.
142
+ */
143
+ export interface HtmlElementContext {
144
+ tag: string;
145
+ id?: string;
146
+ /** Author classes, generated/hashed ones skipped; may end in "+N more". */
147
+ classes?: string[];
148
+ /** Ancestor path, e.g. `body > div#root > header.site-header > nav#site-nav`. */
149
+ path?: string;
150
+ /** Explicit `role` or the tag's implicit ARIA role. */
151
+ role?: string;
152
+ /** Accessible name: aria-label, aria-labelledby, alt, title, <label for>, own short text. */
153
+ name?: string;
154
+ /** Allowlisted attributes in a fixed order (href/src scrubbed of query and fragment). */
155
+ attrs?: Array<[string, string]>;
156
+ /** Rendered text (innerText), whitespace-collapsed, word-boundary truncated. */
157
+ text?: string;
158
+ /** Collapsed HTML skeleton: opening tag with allowlisted attributes, then children as bare tags. */
159
+ outline?: string;
160
+ /** Number of element children (after skipping script/style/template and viewer overlays). */
161
+ children?: number;
162
+ /** Viewport-relative bounding box plus the viewport it was seen at. */
163
+ rect?: { x: number; y: number; w: number; h: number; vw: number; vh: number };
164
+ /** Nearest enclosing landmark/region, e.g. `header.site-header "Primary"`. */
165
+ landmark?: string;
166
+ /** Nearest preceding heading, e.g. `h2 "Usage"`. */
167
+ heading?: string;
168
+ /** Nearest author component marker, e.g. `data-component=AppNav`. */
169
+ component?: string;
170
+ /** Live-app sessions only: the route the element was seen on and the page title. */
171
+ page?: { url: string; title?: string };
127
172
  }
128
173
 
129
174
  export type AlertKind = 'note' | 'tip' | 'warning' | 'caution' | 'important';
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Cross-file annotation scope (multi-document annotate sessions).
3
+ *
4
+ * A folder session spreads one body of feedback across many documents, but the
5
+ * annotations panel only ever showed the open file's comments. This module owns
6
+ * the pure half of the "All files" view: which documents have feedback, how
7
+ * they are ordered and labelled, and which scope the panel opens on.
8
+ *
9
+ * Everything here is pure except the two preference accessors, which use the
10
+ * shared storage backend (cookies by default) like the other panel prefs.
11
+ */
12
+
13
+ import { normalizeBrowserPath, pathIsInsideDir } from '@plannotator/core/browser-paths';
14
+ import type { Annotation } from '../types';
15
+ import { getItem, setItem } from './storage';
16
+
17
+ /** `current` = only the open document (the incumbent view). `all` = every document. */
18
+ export type AnnotationScope = 'current' | 'all';
19
+
20
+ const ANNOTATION_SCOPE_KEY = 'plannotator-annotation-scope';
21
+
22
+ /** The user's explicit last choice, or null when they have never chosen. */
23
+ export function getAnnotationScopePreference(): AnnotationScope | null {
24
+ const raw = getItem(ANNOTATION_SCOPE_KEY);
25
+ return raw === 'all' || raw === 'current' ? raw : null;
26
+ }
27
+
28
+ export function setAnnotationScopePreference(scope: AnnotationScope): void {
29
+ setItem(ANNOTATION_SCOPE_KEY, scope);
30
+ }
31
+
32
+ /**
33
+ * Group key for the open document when it has no path of its own — plan review,
34
+ * where the plan is the document and `sourceFilePath` is annotate-only. Without
35
+ * a key the root document forms no group at all, so the "All files" view hid the
36
+ * plan's own comments while listing every linked document's. It contains no path
37
+ * separator, so `normalizeBrowserPath` leaves it alone, and the angle brackets
38
+ * keep it from colliding with a real file path (illegal in Windows paths, and
39
+ * absent from the absolute paths these groups carry).
40
+ */
41
+ export const ROOT_DOCUMENT_GROUP_KEY = '<plannotator-root-document>';
42
+
43
+ export interface AnnotationDocumentInput {
44
+ /** Absolute path of the document. */
45
+ path: string;
46
+ annotations: readonly Annotation[];
47
+ /** Display name override. Only the pathless root document needs one: every
48
+ * other group derives its label from its path. */
49
+ label?: string;
50
+ }
51
+
52
+ export interface AnnotationDocumentGroup {
53
+ path: string;
54
+ /** Path relative to the nearest session root, else the bare file name. */
55
+ label: string;
56
+ annotations: Annotation[];
57
+ isCurrent: boolean;
58
+ }
59
+
60
+ /**
61
+ * Display name for a document: its path relative to the deepest session root
62
+ * that contains it (the same `${dir}/${node.path}` shape the file browser
63
+ * renders), falling back to the bare file name when no root matches.
64
+ */
65
+ export function documentLabel(path: string, roots: readonly string[] = []): string {
66
+ const normalized = normalizeBrowserPath(path);
67
+ let best = '';
68
+ for (const root of roots) {
69
+ if (!root) continue;
70
+ const normalizedRoot = normalizeBrowserPath(root);
71
+ if (!pathIsInsideDir(normalized, normalizedRoot)) continue;
72
+ if (normalizedRoot.length > best.length) best = normalizedRoot;
73
+ }
74
+ if (best && normalized.length > best.length) {
75
+ return normalized.slice(best.endsWith('/') ? best.length : best.length + 1);
76
+ }
77
+ const lastSlash = normalized.lastIndexOf('/');
78
+ return lastSlash >= 0 ? normalized.slice(lastSlash + 1) : normalized;
79
+ }
80
+
81
+ /**
82
+ * One group per document that actually carries feedback, the open document
83
+ * first and the rest ordered by path. Documents without annotations are
84
+ * dropped: the view answers "where is my feedback", not "what files exist".
85
+ */
86
+ export function groupAnnotationsByDocument(
87
+ documents: Iterable<AnnotationDocumentInput>,
88
+ currentPath: string | null,
89
+ roots: readonly string[] = [],
90
+ ): AnnotationDocumentGroup[] {
91
+ const normalizedCurrent = currentPath ? normalizeBrowserPath(currentPath) : null;
92
+ const groups: AnnotationDocumentGroup[] = [];
93
+ const seen = new Set<string>();
94
+ for (const doc of documents) {
95
+ const path = normalizeBrowserPath(doc.path);
96
+ if (!path || seen.has(path) || doc.annotations.length === 0) continue;
97
+ seen.add(path);
98
+ groups.push({
99
+ path,
100
+ label: doc.label ?? documentLabel(path, roots),
101
+ annotations: [...doc.annotations],
102
+ isCurrent: path === normalizedCurrent,
103
+ });
104
+ }
105
+ groups.sort((a, b) => {
106
+ if (a.isCurrent !== b.isCurrent) return a.isCurrent ? -1 : 1;
107
+ return a.path.localeCompare(b.path);
108
+ });
109
+ return groups;
110
+ }
111
+
112
+ /**
113
+ * The panel's groups for a session: every cached document plus the OPEN one,
114
+ * whose live list (externals included) is the same set its "This file" timeline
115
+ * renders — the cache copy behind it can be stale.
116
+ *
117
+ * The open document is always contributed, under `current.key`. In plan review
118
+ * that key is {@link ROOT_DOCUMENT_GROUP_KEY}: the plan has no path, and keying
119
+ * groups by path alone dropped it from the list, so the "All files" view hid
120
+ * the reviewer's own comments on the document in front of them.
121
+ */
122
+ export function buildAnnotationDocumentGroups(input: {
123
+ /** Documents held in the linked-doc cache, keyed by path. */
124
+ cached: Iterable<readonly [string, readonly Annotation[]]>;
125
+ current: { key: string; label?: string; annotations: readonly Annotation[] };
126
+ roots?: readonly string[];
127
+ }): AnnotationDocumentGroup[] {
128
+ const byPath = new Map<string, readonly Annotation[]>();
129
+ for (const [path, annotations] of input.cached) byPath.set(path, annotations);
130
+ byPath.set(input.current.key, input.current.annotations);
131
+ return groupAnnotationsByDocument(
132
+ Array.from(byPath, ([path, annotations]) => ({
133
+ path,
134
+ annotations,
135
+ label: path === input.current.key ? input.current.label : undefined,
136
+ })),
137
+ input.current.key,
138
+ input.roots ?? [],
139
+ );
140
+ }
141
+
142
+ /**
143
+ * Which scope the panel opens on for the document that just became active.
144
+ *
145
+ * The saved preference wins, with one exception that is the whole point of the
146
+ * feature: landing on a file with no feedback while feedback exists elsewhere
147
+ * would otherwise show "No annotations yet" next to a session full of comments.
148
+ */
149
+ export function resolveInitialAnnotationScope(input: {
150
+ saved: AnnotationScope | null;
151
+ /** Annotations on the document that is open now. */
152
+ currentCount: number;
153
+ /** Annotations on every other document in the session. */
154
+ otherCount: number;
155
+ }): AnnotationScope {
156
+ if (input.saved === 'all') return 'all';
157
+ if (input.currentCount === 0 && input.otherCount > 0) return 'all';
158
+ return 'current';
159
+ }