@erdemtuna/doc-review 0.7.0 → 0.8.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/src/chrome.html CHANGED
@@ -8,6 +8,27 @@
8
8
  </head>
9
9
  <body data-session="__SESSION_ID__" data-token="__TOKEN__">
10
10
  <div class="app">
11
+ <header class="toolbar" aria-label="Review controls">
12
+ <div class="toolbar-product" aria-hidden="true"></div>
13
+ <div class="mode-control">
14
+ <button type="button" id="modeButton" class="toolbar-btn" aria-haspopup="menu" aria-expanded="false">
15
+ <span id="modeIcon"></span><span id="modeLabel">View</span><span id="modeChevron"></span>
16
+ </button>
17
+ <div id="modeMenu" class="mode-menu" role="menu" hidden>
18
+ <button type="button" role="menuitemradio" aria-checked="true" data-mode="view">
19
+ <span data-icon="eye"></span><span><strong>View</strong><small>Editing off, comments enabled</small></span><span class="menu-check"></span>
20
+ </button>
21
+ <button type="button" role="menuitemradio" aria-checked="false" data-mode="edit">
22
+ <span data-icon="pencil"></span><span><strong>Edit</strong><small>Direct editing on</small></span><span class="menu-check"></span>
23
+ </button>
24
+ </div>
25
+ </div>
26
+ <div class="toolbar-actions">
27
+ <button type="button" id="commentsButton" class="toolbar-btn" aria-controls="drawer" aria-label="Comments" aria-expanded="false">
28
+ <span data-icon="messages"></span><span class="toolbar-label">Comments</span><span id="toolbarCount" class="count-badge">0</span>
29
+ </button>
30
+ </div>
31
+ </header>
11
32
  <main class="stage">
12
33
  <iframe
13
34
  id="frame"
@@ -15,34 +36,50 @@
15
36
  sandbox="allow-scripts allow-forms allow-modals"></iframe>
16
37
  </main>
17
38
 
18
- <button type="button" id="handle" class="handle" title="Hide comments panel" aria-label="Hide comments panel">›</button>
19
-
20
- <aside class="rail" id="rail">
21
- <div class="rail-scroll">
22
- <div class="rail-head">
23
- <span class="section">Feedback</span>
24
- <span class="dim" id="count">0</span>
25
- <button type="button" id="theme" class="icon-btn" title="Switch chrome to dark" aria-label="Switch chrome to dark">☾</button>
39
+ <section id="compose" class="contextual-compose" role="dialog" aria-modal="false" aria-labelledby="composeTitle" hidden>
40
+ <div class="compose-head">
41
+ <div>
42
+ <span class="chip" id="composeKind">Selection</span>
43
+ <strong id="composeTitle">Add comment</strong>
26
44
  </div>
45
+ <button type="button" id="composeClose" class="icon-btn" title="Cancel comment" aria-label="Cancel comment"></button>
46
+ </div>
47
+ <div id="composeDirection" class="compose-direction" hidden>
48
+ <span id="composeDirectionText"></span>
49
+ <button type="button" id="composeReveal" class="link-action">Back to selection</button>
50
+ </div>
51
+ <p class="quote" id="composeQuote"></p>
52
+ <textarea id="composeText" rows="3" placeholder="What should change?" aria-label="Comment" aria-describedby="composeHelp"></textarea>
53
+ <p id="composeHelp" class="compose-help"><span>Enter to comment</span><span>Shift+Enter for new line</span></p>
54
+ <p id="composeError" class="compose-error" role="alert" hidden></p>
55
+ <div class="compose-actions">
56
+ <button type="button" id="composeAdd" class="btn-primary"><span data-icon="messageSquarePlus"></span><span id="composeAddLabel">Comment</span></button>
57
+ <button type="button" id="composeCancel" class="btn-ghost">Cancel</button>
58
+ </div>
59
+ </section>
27
60
 
28
- <div id="compose" hidden>
29
- <div class="compose">
30
- <div class="compose-head">
31
- <span class="chip" id="composeKind">Selection</span>
32
- <span class="dim">Click to write · Esc to dismiss</span>
33
- </div>
34
- <p class="quote" id="composeQuote"></p>
35
- <textarea id="composeText" rows="3" placeholder="What should change?"></textarea>
36
- <div class="compose-actions">
37
- <button type="button" id="composeAdd" class="btn-primary">Add comment <span class="key">⏎</span></button>
38
- <button type="button" id="composeCancel" class="btn-ghost">Cancel</button>
39
- </div>
40
- </div>
61
+ <article id="alignedCard" class="aligned-card" hidden></article>
62
+
63
+ <aside class="drawer" id="drawer" aria-label="Review feedback" aria-hidden="true">
64
+ <div class="drawer-head">
65
+ <div>
66
+ <span class="section">Review</span>
67
+ <h2 id="drawerTitle">Comments</h2>
41
68
  </div>
69
+ <button type="button" id="theme" class="icon-btn" title="Switch chrome to dark" aria-label="Switch chrome to dark"></button>
70
+ <button type="button" id="drawerClose" class="icon-btn" title="Close review drawer" aria-label="Close review drawer"></button>
71
+ </div>
72
+ <div id="commentsSection" class="drawer-inventory" tabindex="-1" aria-labelledby="inventoryHeading">
73
+ <section>
74
+ <div class="rail-head">
75
+ <span class="section" id="inventoryHeading">Comments</span>
76
+ <span class="dim" id="count">0</span>
77
+ </div>
42
78
 
43
- <div id="cards" class="cards"></div>
79
+ <div id="cards" class="cards"></div>
44
80
 
45
- <div id="empty" class="empty">Select text or click an element to comment on it</div>
81
+ <div id="empty" class="empty">Select text or focus an element, then use the comment button</div>
82
+ </section>
46
83
 
47
84
  <section id="othersBox" hidden>
48
85
  <div class="rail-head tight">
@@ -61,21 +98,30 @@
61
98
  <div class="save-line" id="saveLine"><span class="dot"></span><span id="saveText"></span></div>
62
99
  <div class="edit-list" id="editList"></div>
63
100
  </section>
64
- </div>
65
101
 
66
- <div class="rail-foot">
67
- <textarea id="note" rows="2" placeholder="Overall note…"></textarea>
68
- <button type="button" id="send" class="send" disabled>Nothing to send yet</button>
69
- <div class="agent-line" id="agentLine" hidden><span class="dot amber"></span><span id="agentText"></span></div>
70
- <div class="handoff" id="handoff" hidden>
71
- <p class="handoff-title">Sent — but no agent is listening yet.</p>
72
- <p class="handoff-body">Paste this into your agent — Claude Code, Codex, or Cursor:</p>
73
- <div class="handoff-cmd"><code id="handoffCmd"></code></div>
74
- <button type="button" id="handoffCopy" class="btn-ghost">Copy prompt</button>
75
- </div>
76
- <button type="button" id="endReview" class="end-review" title="Stop this review and release the waiting agent">End review</button>
77
102
  </div>
103
+ <footer id="sendSection" class="drawer-footer send-section" tabindex="-1" aria-label="Send feedback">
104
+ <div class="send-primary">
105
+ <div class="rail-head tight">
106
+ <span class="section">Send feedback</span>
107
+ </div>
108
+ <textarea id="note" rows="2" placeholder="Overall note…"></textarea>
109
+ <button type="button" id="send" class="send" disabled>Nothing to send yet</button>
110
+ <div class="agent-line" id="agentLine" hidden><span class="dot amber"></span><span id="agentText"></span></div>
111
+ </div>
112
+ <div class="send-secondary">
113
+ <div class="handoff" id="handoff" hidden>
114
+ <p class="handoff-title">Sent — but no agent is listening yet.</p>
115
+ <p class="handoff-body">Paste this into your agent — Claude Code, Codex, or Cursor:</p>
116
+ <div class="handoff-cmd"><code id="handoffCmd"></code></div>
117
+ <button type="button" id="handoffCopy" class="btn-ghost">Copy prompt</button>
118
+ </div>
119
+ <button type="button" id="endReview" class="end-review" title="Stop this review and release the waiting agent">End review</button>
120
+ </div>
121
+ </footer>
78
122
  </aside>
123
+ <div id="drawerBackdrop" class="drawer-backdrop" hidden></div>
124
+ <div id="liveRegion" class="sr-only" role="status" aria-live="polite"></div>
79
125
  </div>
80
126
 
81
127
  <script type="module" src="/chrome.js"></script>
package/src/cli.js CHANGED
@@ -4,7 +4,15 @@ import http from "node:http";
4
4
  import path from "node:path";
5
5
  import { spawn } from "node:child_process";
6
6
  import { fileURLToPath } from "node:url";
7
- import { canonicalTarget, ensureStateDir, SERVER_PROTOCOL, serverPath, statePath, targetKey } from "./paths.js";
7
+ import {
8
+ canonicalTarget,
9
+ ensureStateDir,
10
+ SERVER_PROTOCOL,
11
+ serverPath,
12
+ serverProtocolMatches,
13
+ statePath,
14
+ targetKey,
15
+ } from "./paths.js";
8
16
  import { readServerLock } from "./server-lock.js";
9
17
  import { installSkills, shellQuote } from "./setup.js";
10
18
 
@@ -69,7 +77,7 @@ async function alive(server) {
69
77
  if (res.status !== 200) return false;
70
78
  const health = JSON.parse(res.raw);
71
79
  return (
72
- health.protocol === SERVER_PROTOCOL &&
80
+ serverProtocolMatches(health.protocol) &&
73
81
  health.pid === server.pid &&
74
82
  health.instance_id === server.instance_id
75
83
  );
@@ -82,7 +90,7 @@ async function ensureServer() {
82
90
  ensureStateDir();
83
91
  for (let launch = 0; launch < 3; launch += 1) {
84
92
  const saved = readServerRecord();
85
- if (saved?.protocol === SERVER_PROTOCOL && saved.port && saved.instance_id && (await alive(saved))) return saved;
93
+ if (serverProtocolMatches(saved?.protocol) && saved.port && saved.instance_id && (await alive(saved))) return saved;
86
94
 
87
95
  const child = spawn(process.execPath, [path.join(here, "server-entry.js")], {
88
96
  detached: true,
@@ -95,7 +103,7 @@ async function ensureServer() {
95
103
  const record = readServerRecord();
96
104
  // Same protocol gate as above: a still-running server from an older
97
105
  // version answers /health too, and must not be adopted here.
98
- if (record?.protocol === SERVER_PROTOCOL && record.port && record.instance_id && (await alive(record))) return record;
106
+ if (serverProtocolMatches(record?.protocol) && record.port && record.instance_id && (await alive(record))) return record;
99
107
  if (child.exitCode !== null && !readServerLock()) break;
100
108
  }
101
109
  }
@@ -239,7 +247,7 @@ async function pollCommand(input, { ackId = "", timeoutSecs = 0 } = {}) {
239
247
  async function statusCommand(input) {
240
248
  const target = canonicalTarget(input).value;
241
249
  const saved = readServerRecord();
242
- if (saved?.protocol === SERVER_PROTOCOL && saved.port && saved.instance_id && (await alive(saved))) {
250
+ if (serverProtocolMatches(saved?.protocol) && saved.port && saved.instance_id && (await alive(saved))) {
243
251
  const res = await request(saved, { method: "GET", path: `/api/status?target=${encodeURIComponent(target)}` });
244
252
  if (res.status === 200) {
245
253
  process.stdout.write(`${JSON.stringify(JSON.parse(res.raw), null, 2)}\n`);
@@ -0,0 +1,22 @@
1
+ const text = (value, limit) => typeof value === "string" ? value.slice(0, limit) : "";
2
+
3
+ export function normalizeCommentAnchor(kind, anchor) {
4
+ if (!anchor || typeof anchor !== "object" || Array.isArray(anchor)) return null;
5
+ if (kind === "element") {
6
+ const selector = text(anchor.selector, 2000);
7
+ if (!selector) return null;
8
+ return {
9
+ selector,
10
+ ...(text(anchor.label, 500) ? { label: text(anchor.label, 500) } : {}),
11
+ };
12
+ }
13
+
14
+ const quote = text(anchor.quote, 4000);
15
+ if (!quote) return null;
16
+ return {
17
+ quote,
18
+ ...(text(anchor.prefix, 1000) ? { prefix: text(anchor.prefix, 1000) } : {}),
19
+ ...(text(anchor.suffix, 1000) ? { suffix: text(anchor.suffix, 1000) } : {}),
20
+ ...(text(anchor.selector, 2000) ? { selector: text(anchor.selector, 2000) } : {}),
21
+ };
22
+ }
@@ -0,0 +1,77 @@
1
+ const finite = (value) => typeof value === "number" && Number.isFinite(value);
2
+
3
+ export function sanitizeRect(rect) {
4
+ if (!rect) return null;
5
+ const values = ["left", "top", "right", "bottom", "width", "height"];
6
+ if (!values.every((key) => finite(rect[key]))) return null;
7
+ if (rect.width < 0 || rect.height < 0 || rect.right < rect.left || rect.bottom < rect.top) return null;
8
+ return {
9
+ left: rect.left,
10
+ top: rect.top,
11
+ right: rect.right,
12
+ bottom: rect.bottom,
13
+ width: rect.width,
14
+ height: rect.height,
15
+ };
16
+ }
17
+
18
+ export function sanitizeClientRect(rect, viewport) {
19
+ const safe = sanitizeRect(rect);
20
+ if (!safe || !viewport || !finite(viewport.width) || !finite(viewport.height)) return null;
21
+ const { left: rawLeft, top: rawTop, right: rawRight, bottom: rawBottom } = safe;
22
+ if (rawRight <= 0 || rawBottom <= 0 || rawLeft >= viewport.width || rawTop >= viewport.height) return null;
23
+ const left = Math.max(0, Math.min(viewport.width, rawLeft));
24
+ const top = Math.max(0, Math.min(viewport.height, rawTop));
25
+ const right = Math.max(left, Math.min(viewport.width, rawRight));
26
+ const bottom = Math.max(top, Math.min(viewport.height, rawBottom));
27
+ return { left, top, right, bottom, width: right - left, height: bottom - top };
28
+ }
29
+
30
+ export function sanitizeClientRects(rects, viewport, limit = 64) {
31
+ if (!Array.isArray(rects)) return [];
32
+ return rects.slice(0, limit).map((rect) => sanitizeClientRect(rect, viewport)).filter(Boolean);
33
+ }
34
+
35
+ export function sanitizeClipRect(rect, viewport) {
36
+ const safe = sanitizeClientRect(rect, viewport);
37
+ return safe && safe.width > 0 && safe.height > 0 ? safe : null;
38
+ }
39
+
40
+ export function sanitizeRelation(value) {
41
+ return ["visible", "above", "below", "unavailable"].includes(value) ? value : "unavailable";
42
+ }
43
+
44
+ export function targetMessage(
45
+ { kind, quote, anchor, rects, generation, relation, clip, horizontal },
46
+ viewport
47
+ ) {
48
+ const safeRects = sanitizeClientRects(rects, viewport);
49
+ const safeClip = sanitizeClipRect(clip, viewport);
50
+ if (!Number.isSafeInteger(generation) || generation < 1) return null;
51
+ const safeRelation = sanitizeRelation(relation);
52
+ if (!safeClip || (safeRelation === "visible" && !safeRects.length)) return null;
53
+ return {
54
+ kind: kind === "element" ? "element" : "selection",
55
+ quote: String(quote || ""),
56
+ anchor: anchor || null,
57
+ rects: safeRects,
58
+ generation,
59
+ relation: safeRelation,
60
+ clip: safeClip,
61
+ horizontal: finite(horizontal) ? Math.max(safeClip.left, Math.min(safeClip.right, horizontal)) : null,
62
+ };
63
+ }
64
+
65
+ export function acceptsTargetGeometry(currentGeneration, incoming) {
66
+ return !!incoming && Number.isSafeInteger(incoming.generation) && incoming.generation === currentGeneration;
67
+ }
68
+
69
+ export function acceptedOpenGeneration(
70
+ { pendingGeneration = null, retargetGeneration = null },
71
+ { accepted, requestedGeneration }
72
+ ) {
73
+ if (!accepted) return pendingGeneration;
74
+ if (requestedGeneration === retargetGeneration) return retargetGeneration;
75
+ if (requestedGeneration === pendingGeneration) return pendingGeneration;
76
+ return null;
77
+ }
package/src/icons.js ADDED
@@ -0,0 +1,251 @@
1
+ // Generated by scripts/generate-icons.js from lucide-static. Do not edit.
2
+ export const ICON_NODES = Object.freeze({
3
+ "eye": [
4
+ [
5
+ "path",
6
+ {
7
+ "d": "M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"
8
+ }
9
+ ],
10
+ [
11
+ "circle",
12
+ {
13
+ "cx": "12",
14
+ "cy": "12",
15
+ "r": "3"
16
+ }
17
+ ]
18
+ ],
19
+ "pencil": [
20
+ [
21
+ "path",
22
+ {
23
+ "d": "M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"
24
+ }
25
+ ],
26
+ [
27
+ "path",
28
+ {
29
+ "d": "m15 5 4 4"
30
+ }
31
+ ]
32
+ ],
33
+ "chevronDown": [
34
+ [
35
+ "path",
36
+ {
37
+ "d": "m6 9 6 6 6-6"
38
+ }
39
+ ]
40
+ ],
41
+ "messageSquarePlus": [
42
+ [
43
+ "path",
44
+ {
45
+ "d": "M22 17a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 21.286V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2z"
46
+ }
47
+ ],
48
+ [
49
+ "path",
50
+ {
51
+ "d": "M12 8v6"
52
+ }
53
+ ],
54
+ [
55
+ "path",
56
+ {
57
+ "d": "M9 11h6"
58
+ }
59
+ ]
60
+ ],
61
+ "messages": [
62
+ [
63
+ "path",
64
+ {
65
+ "d": "M16 10a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 14.286V4a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"
66
+ }
67
+ ],
68
+ [
69
+ "path",
70
+ {
71
+ "d": "M20 9a2 2 0 0 1 2 2v10.286a.71.71 0 0 1-1.212.502l-2.202-2.202A2 2 0 0 0 17.172 19H10a2 2 0 0 1-2-2v-1"
72
+ }
73
+ ]
74
+ ],
75
+ "send": [
76
+ [
77
+ "path",
78
+ {
79
+ "d": "M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z"
80
+ }
81
+ ],
82
+ [
83
+ "path",
84
+ {
85
+ "d": "m21.854 2.147-10.94 10.939"
86
+ }
87
+ ]
88
+ ],
89
+ "trash": [
90
+ [
91
+ "path",
92
+ {
93
+ "d": "M10 11v6"
94
+ }
95
+ ],
96
+ [
97
+ "path",
98
+ {
99
+ "d": "M14 11v6"
100
+ }
101
+ ],
102
+ [
103
+ "path",
104
+ {
105
+ "d": "M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"
106
+ }
107
+ ],
108
+ [
109
+ "path",
110
+ {
111
+ "d": "M3 6h18"
112
+ }
113
+ ],
114
+ [
115
+ "path",
116
+ {
117
+ "d": "M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"
118
+ }
119
+ ]
120
+ ],
121
+ "moreHorizontal": [
122
+ [
123
+ "circle",
124
+ {
125
+ "cx": "12",
126
+ "cy": "12",
127
+ "r": "1"
128
+ }
129
+ ],
130
+ [
131
+ "circle",
132
+ {
133
+ "cx": "19",
134
+ "cy": "12",
135
+ "r": "1"
136
+ }
137
+ ],
138
+ [
139
+ "circle",
140
+ {
141
+ "cx": "5",
142
+ "cy": "12",
143
+ "r": "1"
144
+ }
145
+ ]
146
+ ],
147
+ "x": [
148
+ [
149
+ "path",
150
+ {
151
+ "d": "M18 6 6 18"
152
+ }
153
+ ],
154
+ [
155
+ "path",
156
+ {
157
+ "d": "m6 6 12 12"
158
+ }
159
+ ]
160
+ ],
161
+ "check": [
162
+ [
163
+ "path",
164
+ {
165
+ "d": "M20 6 9 17l-5-5"
166
+ }
167
+ ]
168
+ ],
169
+ "sun": [
170
+ [
171
+ "circle",
172
+ {
173
+ "cx": "12",
174
+ "cy": "12",
175
+ "r": "4"
176
+ }
177
+ ],
178
+ [
179
+ "path",
180
+ {
181
+ "d": "M12 2v2"
182
+ }
183
+ ],
184
+ [
185
+ "path",
186
+ {
187
+ "d": "M12 20v2"
188
+ }
189
+ ],
190
+ [
191
+ "path",
192
+ {
193
+ "d": "m4.93 4.93 1.41 1.41"
194
+ }
195
+ ],
196
+ [
197
+ "path",
198
+ {
199
+ "d": "m17.66 17.66 1.41 1.41"
200
+ }
201
+ ],
202
+ [
203
+ "path",
204
+ {
205
+ "d": "M2 12h2"
206
+ }
207
+ ],
208
+ [
209
+ "path",
210
+ {
211
+ "d": "M20 12h2"
212
+ }
213
+ ],
214
+ [
215
+ "path",
216
+ {
217
+ "d": "m6.34 17.66-1.41 1.41"
218
+ }
219
+ ],
220
+ [
221
+ "path",
222
+ {
223
+ "d": "m19.07 4.93-1.41 1.41"
224
+ }
225
+ ]
226
+ ],
227
+ "moon": [
228
+ [
229
+ "path",
230
+ {
231
+ "d": "M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401"
232
+ }
233
+ ]
234
+ ]
235
+ });
236
+
237
+ const escape = (value) => String(value).replace(/[&<>"]/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[char]);
238
+
239
+ export function iconMarkup(name, { title = "", className = "icon", size = 16 } = {}) {
240
+ const nodes = ICON_NODES[name];
241
+ if (!nodes) throw new Error(`Unknown icon: ${name}`);
242
+ const body = nodes.map(([tag, attrs]) => `<${tag} ${Object.entries(attrs).map(([key, value]) => `${key}="${escape(value)}"`).join(" ")}></${tag}>`).join("");
243
+ const label = title ? ` role="img" aria-label="${escape(title)}"` : ' aria-hidden="true"';
244
+ return `<svg class="${escape(className)}" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"${label}>${body}</svg>`;
245
+ }
246
+
247
+ export function createIcon(name, options = {}) {
248
+ const template = document.createElement("template");
249
+ template.innerHTML = iconMarkup(name, options);
250
+ return template.content.firstElementChild;
251
+ }
package/src/paths.js CHANGED
@@ -5,7 +5,11 @@ import fs from "node:fs";
5
5
 
6
6
  // Bump this when the CLI and detached server no longer share the same request
7
7
  // contract. A new CLI must not silently reuse an older background server.
8
- export const SERVER_PROTOCOL = 11;
8
+ export const SERVER_PROTOCOL = 12;
9
+
10
+ export function serverProtocolMatches(protocol) {
11
+ return Number(protocol) === SERVER_PROTOCOL;
12
+ }
9
13
 
10
14
  export function stateDir() {
11
15
  const override = process.env.DOC_REVIEW_STATE_DIR;
@@ -0,0 +1,107 @@
1
+ const GAP = 12;
2
+
3
+ export function visibleViewport(win = window) {
4
+ const viewport = win.visualViewport;
5
+ return viewport
6
+ ? { left: viewport.offsetLeft, top: viewport.offsetTop, width: viewport.width, height: viewport.height }
7
+ : { left: 0, top: 0, width: win.innerWidth, height: win.innerHeight };
8
+ }
9
+
10
+ export function lastVisibleRect(rects) {
11
+ if (!Array.isArray(rects)) return null;
12
+ for (let index = rects.length - 1; index >= 0; index -= 1) {
13
+ const rect = rects[index];
14
+ if (rect && rect.width >= 0 && rect.height >= 0) return rect;
15
+ }
16
+ return null;
17
+ }
18
+
19
+ const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
20
+
21
+ function frameRectToChrome(rect, frameRect) {
22
+ return {
23
+ left: frameRect.left + rect.left,
24
+ right: frameRect.left + rect.right,
25
+ top: frameRect.top + rect.top,
26
+ bottom: frameRect.top + rect.bottom,
27
+ };
28
+ }
29
+
30
+ export function placeContextualSurface(geometry, options) {
31
+ const {
32
+ frameRect,
33
+ viewport,
34
+ surfaceWidth = 340,
35
+ surfaceHeight = 220,
36
+ narrow = false,
37
+ toolbarHeight = 48,
38
+ } = options;
39
+ const bottomSheet = () => ({
40
+ kind: "sheet",
41
+ left: viewport.left,
42
+ top: viewport.top + viewport.height - Math.min(surfaceHeight, viewport.height),
43
+ width: viewport.width,
44
+ });
45
+ if (!geometry || geometry.relation === "unavailable" || !geometry.clip) return { kind: "hidden" };
46
+ if (narrow) return bottomSheet();
47
+ const clip = frameRectToChrome(geometry.clip, frameRect);
48
+ const minLeft = viewport.left + GAP;
49
+ const maxLeft = viewport.left + viewport.width - surfaceWidth - GAP;
50
+ const viewportMinTop = viewport.top + toolbarHeight + GAP;
51
+ const minTop = Math.max(viewportMinTop, clip.top + GAP);
52
+ const maxTop = viewport.top + viewport.height - surfaceHeight - GAP;
53
+ const clipBottomTop = clip.bottom - surfaceHeight - GAP;
54
+ const horizontal = Number.isFinite(geometry.horizontal)
55
+ ? frameRect.left + geometry.horizontal
56
+ : clip.left;
57
+ const edgeLeft = clamp(horizontal + GAP, minLeft, maxLeft);
58
+
59
+ if (geometry.relation === "above") {
60
+ return { kind: "edge-top", left: edgeLeft, top: minTop, width: surfaceWidth };
61
+ }
62
+ if (geometry.relation === "below") {
63
+ return {
64
+ kind: "edge-bottom",
65
+ left: edgeLeft,
66
+ top: Math.max(viewportMinTop, Math.min(maxTop, clipBottomTop)),
67
+ width: surfaceWidth,
68
+ };
69
+ }
70
+
71
+ const target = lastVisibleRect(geometry.rects);
72
+ if (!target) return { kind: "hidden" };
73
+ const rect = frameRectToChrome(target, frameRect);
74
+ const attachedMaxTop = Math.min(maxTop, clipBottomTop);
75
+ const clampTop = (top) => clamp(top, minTop, Math.max(minTop, attachedMaxTop));
76
+ const candidates = [
77
+ { left: rect.right + GAP, top: clampTop(rect.bottom - surfaceHeight) },
78
+ { left: rect.left - surfaceWidth - GAP, top: clampTop(rect.bottom - surfaceHeight) },
79
+ { left: clamp(rect.left, minLeft, maxLeft), top: rect.bottom + GAP },
80
+ { left: clamp(rect.left, minLeft, maxLeft), top: rect.top - surfaceHeight - GAP },
81
+ ];
82
+ const fit = candidates.find((candidate) =>
83
+ candidate.left >= minLeft &&
84
+ candidate.left <= maxLeft &&
85
+ candidate.top >= minTop &&
86
+ candidate.top <= attachedMaxTop &&
87
+ candidate.left + surfaceWidth <= Math.min(viewport.left + viewport.width - GAP, clip.right) &&
88
+ candidate.top + surfaceHeight <= clip.bottom
89
+ );
90
+ const fallback = fit || {
91
+ left: clamp(rect.right + GAP, minLeft, maxLeft),
92
+ top: clampTop(rect.top),
93
+ };
94
+ return { kind: "attached", ...fallback, width: surfaceWidth };
95
+ }
96
+
97
+ export function alignedCardPosition(rects, { frameRect, viewport, width = 300, height = 180 }) {
98
+ const target = lastVisibleRect(rects);
99
+ if (!target) return null;
100
+ const top = frameRect.top + target.top;
101
+ const right = frameRect.left + target.right;
102
+ if (top < frameRect.top || top > frameRect.bottom || right < frameRect.left || right > frameRect.right) return null;
103
+ return {
104
+ left: Math.max(viewport.left + 12, Math.min(viewport.left + viewport.width - width - 12, right + 12)),
105
+ top: Math.max(viewport.top + 12, Math.min(viewport.top + viewport.height - height - 12, top)),
106
+ };
107
+ }