kapi-ui 0.2.2 → 0.3.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 James Bermudo
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,531 @@
1
+ import { lockHighlightOn, unlockHighlight, clearHighlightIfNotInspecting, getSourceLocation, getComponentInfo, renderComponentBadge, isDisabled, clearSelection, isBoxSelectClick, lockWithoutHighlight, previewElements, clearPreview, } from './inspector.js';
2
+ import { ARROW_SVG, DELETE_SVG } from './icons.js';
3
+ import STYLES from './styles/comments.css?inline';
4
+ const TAG = 'kapi-comments';
5
+ const MARKER_SIZE = 22;
6
+ const MARKER_RADIUS = MARKER_SIZE / 2;
7
+ // Storage key and in-memory comments are scoped to the current page. In an
8
+ // SPA, navigating via the router doesn't reload this script, so both are
9
+ // re-derived from `location.pathname` on every navigation (see
10
+ // watchForNavigation below) rather than fixed once at module load.
11
+ let storageKey = `kapi-comments:${location.pathname}`;
12
+ let currentPathname = location.pathname;
13
+ let root = null;
14
+ let comments = [];
15
+ let draft = null;
16
+ // Builds a positional selector (nth-child chain from <body>) so a comment's
17
+ // element can be re-found across page reloads without relying on id/class.
18
+ function buildUniqueSelector(el) {
19
+ const parts = [];
20
+ let node = el;
21
+ while (node && node !== document.body) {
22
+ const parentEl = node.parentElement;
23
+ if (!parentEl)
24
+ break;
25
+ const index = Array.from(parentEl.children).indexOf(node) + 1;
26
+ parts.unshift(`${node.tagName.toLowerCase()}:nth-child(${index})`);
27
+ node = parentEl;
28
+ }
29
+ parts.unshift('body');
30
+ return parts.join(' > ');
31
+ }
32
+ function saveToStorage() {
33
+ const data = comments.map((c) => ({
34
+ id: c.id,
35
+ selector: buildUniqueSelector(c.el),
36
+ ratioX: c.ratioX,
37
+ ratioY: c.ratioY,
38
+ text: c.text,
39
+ source: c.source,
40
+ component: c.component,
41
+ targets: c.targets?.map((t) => ({
42
+ selector: buildUniqueSelector(t.el),
43
+ source: t.source,
44
+ component: t.component,
45
+ })),
46
+ }));
47
+ try {
48
+ localStorage.setItem(storageKey, JSON.stringify(data));
49
+ }
50
+ catch {
51
+ /* ignore (storage disabled/full) */
52
+ }
53
+ }
54
+ function loadFromStorage() {
55
+ let data;
56
+ try {
57
+ const raw = localStorage.getItem(storageKey);
58
+ if (!raw)
59
+ return;
60
+ data = JSON.parse(raw);
61
+ }
62
+ catch {
63
+ return; // ignore corrupt/inaccessible storage
64
+ }
65
+ for (const item of data) {
66
+ const el = document.querySelector(item.selector);
67
+ if (!el)
68
+ continue; // page structure changed since this was saved; skip it
69
+ let targets;
70
+ if (item.targets) {
71
+ targets = item.targets.reduce((acc, t) => {
72
+ const targetEl = document.querySelector(t.selector);
73
+ if (targetEl)
74
+ acc.push({ el: targetEl, source: t.source, component: t.component });
75
+ return acc;
76
+ }, []);
77
+ }
78
+ comments.push({
79
+ id: item.id,
80
+ el,
81
+ ratioX: item.ratioX,
82
+ ratioY: item.ratioY,
83
+ text: item.text,
84
+ source: item.source,
85
+ component: item.component,
86
+ targets,
87
+ });
88
+ }
89
+ }
90
+ function ensureRoot() {
91
+ if (root)
92
+ return root;
93
+ const host = document.createElement(TAG);
94
+ root = host.attachShadow({ mode: 'open' });
95
+ const style = document.createElement('style');
96
+ style.textContent = STYLES;
97
+ root.appendChild(style);
98
+ document.body.appendChild(host);
99
+ return root;
100
+ }
101
+ function clamp(value, min, max) {
102
+ return Math.min(Math.max(value, min), max);
103
+ }
104
+ function position(target, wrapper) {
105
+ const rect = target.el.getBoundingClientRect();
106
+ const x = rect.left + target.ratioX * rect.width;
107
+ const y = rect.top + target.ratioY * rect.height;
108
+ wrapper.style.setProperty('--kapi-x', `${x - MARKER_RADIUS}px`);
109
+ wrapper.style.setProperty('--kapi-y', `${y - MARKER_RADIUS}px`);
110
+ }
111
+ // Plays the exit animation on a wrapper, then removes it. The node keeps its
112
+ // `.kapi-comment` class (so its position vars still apply) but gains
113
+ // `.kapi-leaving`, which render()/repositionAll skip — so it animates out
114
+ // independently of the fresh nodes render() builds, then self-removes.
115
+ function animateOut(node) {
116
+ node.classList.add('kapi-leaving');
117
+ // Fallback: if animationend never fires (reduced motion, backgrounded tab,
118
+ // animation not run), remove anyway so leaving nodes can't accumulate.
119
+ let fallback;
120
+ const remove = () => {
121
+ clearTimeout(fallback);
122
+ node.removeEventListener('animationend', onEnd);
123
+ node.remove();
124
+ };
125
+ const onEnd = (e) => {
126
+ if (e.target !== node)
127
+ return;
128
+ remove();
129
+ };
130
+ node.addEventListener('animationend', onEnd);
131
+ fallback = setTimeout(remove, 250);
132
+ }
133
+ function renderMarker(entry, label) {
134
+ const wrapper = document.createElement('div');
135
+ wrapper.className = 'kapi-comment';
136
+ const marker = document.createElement('div');
137
+ marker.className = 'kapi-comment-marker';
138
+ marker.textContent = label;
139
+ const tooltip = document.createElement('div');
140
+ tooltip.className = 'kapi-comment-tooltip';
141
+ if (entry.component) {
142
+ tooltip.appendChild(renderComponentBadge(entry.component, 'kapi-comment-tooltip-component'));
143
+ }
144
+ if (entry.source) {
145
+ const sourceEl = document.createElement('div');
146
+ sourceEl.className = 'kapi-comment-tooltip-source';
147
+ sourceEl.textContent = `${entry.source.file}:${entry.source.line}:${entry.source.column}`;
148
+ tooltip.appendChild(sourceEl);
149
+ }
150
+ const textEl = document.createElement('div');
151
+ textEl.textContent = entry.text;
152
+ tooltip.appendChild(textEl);
153
+ if (entry.targets && entry.targets.length > 1) {
154
+ const countEl = document.createElement('div');
155
+ countEl.className = 'kapi-comment-tooltip-source';
156
+ countEl.textContent = `applies to ${entry.targets.length} elements`;
157
+ tooltip.appendChild(countEl);
158
+ }
159
+ marker.addEventListener('mouseenter', () => wrapper.classList.add('kapi-hovering'));
160
+ marker.addEventListener('mouseleave', () => wrapper.classList.remove('kapi-hovering'));
161
+ marker.addEventListener('click', () => beginEdit(entry));
162
+ wrapper.append(marker, tooltip);
163
+ position(entry, wrapper);
164
+ return wrapper;
165
+ }
166
+ function renderComposer(label, target, initialText = '') {
167
+ const wrapper = document.createElement('div');
168
+ wrapper.className = 'kapi-comment';
169
+ const marker = document.createElement('div');
170
+ marker.className = 'kapi-comment-marker kapi-comment-marker-enter';
171
+ marker.textContent = label;
172
+ const composer = document.createElement('div');
173
+ composer.className = 'kapi-comment-composer';
174
+ const input = document.createElement('textarea');
175
+ input.className = 'kapi-comment-input';
176
+ input.placeholder = 'Add a comment';
177
+ input.rows = 1;
178
+ input.value = initialText;
179
+ const autoGrow = () => {
180
+ input.style.height = 'auto';
181
+ input.style.height = `${input.scrollHeight}px`;
182
+ };
183
+ input.addEventListener('input', autoGrow);
184
+ const sendBtn = document.createElement('button');
185
+ sendBtn.type = 'button';
186
+ sendBtn.className = 'kapi-comment-send';
187
+ sendBtn.setAttribute('aria-label', 'Submit comment');
188
+ sendBtn.innerHTML = ARROW_SVG;
189
+ const submit = () => submitDraft(input.value);
190
+ sendBtn.addEventListener('click', submit);
191
+ input.addEventListener('keydown', (e) => {
192
+ if (e.key === 'Enter' && !e.shiftKey) {
193
+ e.preventDefault();
194
+ submit();
195
+ }
196
+ if (e.key === 'Escape')
197
+ cancelDraft();
198
+ });
199
+ const inputRow = document.createElement('div');
200
+ inputRow.className = 'kapi-comment-composer-input-row';
201
+ inputRow.appendChild(input);
202
+ const actionsRow = document.createElement('div');
203
+ actionsRow.className = 'kapi-comment-composer-actions';
204
+ if (initialText) {
205
+ const deleteBtn = document.createElement('button');
206
+ deleteBtn.type = 'button';
207
+ deleteBtn.className = 'kapi-comment-delete';
208
+ deleteBtn.setAttribute('aria-label', 'Delete comment');
209
+ deleteBtn.innerHTML = DELETE_SVG;
210
+ deleteBtn.addEventListener('click', () => {
211
+ if (draft?.id)
212
+ deleteComment(draft.id);
213
+ });
214
+ actionsRow.appendChild(deleteBtn);
215
+ }
216
+ actionsRow.appendChild(sendBtn);
217
+ composer.append(inputRow, actionsRow);
218
+ wrapper.append(marker, composer);
219
+ position(target, wrapper);
220
+ queueMicrotask(() => {
221
+ input.focus();
222
+ input.setSelectionRange(input.value.length, input.value.length);
223
+ autoGrow();
224
+ // Lock the composer's vertical position to its initial (single-line) height,
225
+ // centered on the marker, so later growth only extends it downward instead
226
+ // of continuously re-centering (which would grow it upward too).
227
+ composer.style.top = `${MARKER_RADIUS - composer.getBoundingClientRect().height / 2}px`;
228
+ });
229
+ return wrapper;
230
+ }
231
+ // Sum of comments stored on every page that iterates before this one, so
232
+ // marker labels continue the global numbering (same localStorage order as
233
+ // buildAllCommentsPrompt, so markers and the copied/sent prompt agree). If the
234
+ // current page has no key yet (no comments saved), every existing page counts —
235
+ // which matches: its key gets appended last on first save.
236
+ function globalOffset() {
237
+ let offset = 0;
238
+ for (let i = 0; i < localStorage.length; i++) {
239
+ const key = localStorage.key(i);
240
+ if (!key?.startsWith('kapi-comments:'))
241
+ continue;
242
+ if (key === storageKey)
243
+ break;
244
+ try {
245
+ const data = JSON.parse(localStorage.getItem(key) || '[]');
246
+ if (Array.isArray(data))
247
+ offset += data.length;
248
+ }
249
+ catch {
250
+ /* skip corrupt entry (also skipped by buildAllCommentsPrompt) */
251
+ }
252
+ }
253
+ return offset;
254
+ }
255
+ function render() {
256
+ const r = ensureRoot();
257
+ r.querySelectorAll('.kapi-comment:not(.kapi-leaving)').forEach((n) => n.remove());
258
+ const offset = globalOffset();
259
+ comments.forEach((entry, index) => {
260
+ const label = String(offset + index + 1);
261
+ if (draft && draft.id === entry.id) {
262
+ r.appendChild(renderComposer(label, draft, draft.text));
263
+ }
264
+ else {
265
+ r.appendChild(renderMarker(entry, label));
266
+ }
267
+ });
268
+ if (draft && draft.id === undefined) {
269
+ const label = draft.els && draft.els.length > 1 ? `${draft.els.length}` : String(offset + comments.length + 1);
270
+ r.appendChild(renderComposer(label, draft, draft.text));
271
+ }
272
+ }
273
+ function repositionAll() {
274
+ if (!root)
275
+ return;
276
+ const wrappers = root.querySelectorAll('.kapi-comment:not(.kapi-leaving)');
277
+ const targets = [...comments];
278
+ if (draft)
279
+ targets.push(draft);
280
+ wrappers.forEach((wrapper, i) => {
281
+ const target = targets[i];
282
+ if (target)
283
+ position(target, wrapper);
284
+ });
285
+ }
286
+ window.addEventListener('resize', repositionAll);
287
+ window.addEventListener('scroll', repositionAll, true);
288
+ document.addEventListener('keydown', (e) => {
289
+ if (e.key === 'Escape' && draft)
290
+ cancelDraft();
291
+ }, true);
292
+ document.addEventListener('click', (e) => {
293
+ if (!draft || !root)
294
+ return;
295
+ // Let a shift-click, or the trailing click a drag-select release fires,
296
+ // fall through to the inspector's blocker instead of canceling — those
297
+ // are how a batch draft's element selection keeps growing while its
298
+ // composer is open.
299
+ if (e.shiftKey || isBoxSelectClick())
300
+ return;
301
+ const target = e.target;
302
+ const kapiHost = root.host;
303
+ if (!kapiHost.contains(target)) {
304
+ e.stopImmediatePropagation();
305
+ cancelDraft();
306
+ clearHighlightIfNotInspecting();
307
+ }
308
+ }, true);
309
+ function submitDraft(rawText) {
310
+ if (!draft)
311
+ return;
312
+ const text = rawText.trim();
313
+ if (!text) {
314
+ cancelDraft();
315
+ return;
316
+ }
317
+ if (draft.els) {
318
+ const targets = draft.els.map((el) => ({
319
+ el,
320
+ source: getSourceLocation(el),
321
+ component: getComponentInfo(el),
322
+ }));
323
+ comments.push({
324
+ id: comments.length + 1,
325
+ el: draft.el,
326
+ ratioX: 0.5,
327
+ ratioY: 0.5,
328
+ text,
329
+ source: targets[0]?.source ?? null,
330
+ component: targets[0]?.component ?? null,
331
+ targets,
332
+ });
333
+ }
334
+ else if (draft.id !== undefined) {
335
+ const entry = comments.find((c) => c.id === draft.id);
336
+ if (entry)
337
+ entry.text = text;
338
+ }
339
+ else {
340
+ comments.push({
341
+ id: comments.length + 1,
342
+ el: draft.el,
343
+ ratioX: draft.ratioX,
344
+ ratioY: draft.ratioY,
345
+ text,
346
+ source: getSourceLocation(draft.el),
347
+ component: getComponentInfo(draft.el),
348
+ });
349
+ }
350
+ draft = null;
351
+ unlockHighlight();
352
+ clearSelection();
353
+ clearPreview();
354
+ saveToStorage();
355
+ render();
356
+ }
357
+ function cancelDraft() {
358
+ const node = root?.querySelector('.kapi-comment-composer')?.closest('.kapi-comment');
359
+ if (node)
360
+ animateOut(node);
361
+ draft = null;
362
+ unlockHighlight();
363
+ clearSelection();
364
+ clearPreview();
365
+ render();
366
+ }
367
+ export function cancelOpenDraft() {
368
+ if (draft)
369
+ cancelDraft();
370
+ }
371
+ // Builds one prompt covering comments from every page, read straight from
372
+ // localStorage (each page persists under `kapi-comments:<pathname>`). Other
373
+ // pages aren't in the DOM, so locations come from the stored selector/source
374
+ // rather than a live element.
375
+ export function buildAllCommentsPrompt() {
376
+ const describe = (selector, source, component) => {
377
+ const location = source ? `${source.file}:${source.line}:${source.column}` : selector;
378
+ return `${component ? `<${component.name}> ` : ''}${location}`;
379
+ };
380
+ const sections = [];
381
+ let n = 0; // one running number shared across every page
382
+ for (let i = 0; i < localStorage.length; i++) {
383
+ const key = localStorage.key(i);
384
+ if (!key?.startsWith('kapi-comments:'))
385
+ continue;
386
+ let data;
387
+ try {
388
+ data = JSON.parse(localStorage.getItem(key) || '[]');
389
+ }
390
+ catch {
391
+ continue; // skip corrupt entry
392
+ }
393
+ if (!data.length)
394
+ continue;
395
+ const lines = data.map((c) => {
396
+ const locations = c.targets?.length
397
+ ? c.targets.map((t) => describe(t.selector, t.source, t.component)).join(', ')
398
+ : describe(c.selector, c.source, c.component);
399
+ return `${++n}. [${locations}] feedback: ${c.text}`;
400
+ });
401
+ sections.push([`## Page: ${key.slice('kapi-comments:'.length)}`, ...lines].join('\n'));
402
+ }
403
+ if (!sections.length)
404
+ return null;
405
+ return [
406
+ 'Address each of the following review comments left on specific elements in this app, grouped by page:',
407
+ '',
408
+ ...sections,
409
+ ].join('\n\n');
410
+ }
411
+ export function clearAllComments() {
412
+ root?.querySelectorAll('.kapi-comment').forEach((n) => animateOut(n));
413
+ comments = [];
414
+ if (draft) {
415
+ draft = null;
416
+ unlockHighlight();
417
+ clearPreview();
418
+ }
419
+ try {
420
+ localStorage.removeItem(storageKey);
421
+ }
422
+ catch {
423
+ /* ignore (storage disabled) */
424
+ }
425
+ render();
426
+ }
427
+ export function beginComment(el, clientX, clientY) {
428
+ if (isDisabled() || draft)
429
+ return;
430
+ const rect = el.getBoundingClientRect();
431
+ const ratioX = rect.width > 0 ? clamp((clientX - rect.left) / rect.width, 0, 1) : 0.5;
432
+ const ratioY = rect.height > 0 ? clamp((clientY - rect.top) / rect.height, 0, 1) : 0.5;
433
+ draft = { el, ratioX, ratioY };
434
+ lockHighlightOn(el);
435
+ render();
436
+ }
437
+ // Called on every shift-click/drag-select change in inspector.ts. Keeps one
438
+ // shared composer live as the selection grows or shrinks, so no separate
439
+ // "commit" click is needed. On submit, one CommentEntry per selected element
440
+ // is created with the same text.
441
+ export function updateSelection(els) {
442
+ if (isDisabled())
443
+ return;
444
+ if (els.length === 0) {
445
+ if (draft?.els)
446
+ cancelDraft();
447
+ return;
448
+ }
449
+ // A normal single comment/edit is already in progress (reachable if a
450
+ // shift-click lands while editing an existing comment) — leave it alone.
451
+ if (draft && !draft.els)
452
+ return;
453
+ const anchor = els[els.length - 1];
454
+ if (draft?.els) {
455
+ // Preserve whatever the user has typed so far across the re-render.
456
+ const input = root?.querySelector('.kapi-comment-input');
457
+ if (input)
458
+ draft.text = input.value;
459
+ draft.els = els;
460
+ draft.el = anchor;
461
+ }
462
+ else {
463
+ draft = { el: anchor, ratioX: 0.5, ratioY: 0.5, els };
464
+ }
465
+ lockWithoutHighlight();
466
+ render();
467
+ }
468
+ function deleteComment(id) {
469
+ const node = root?.querySelector('.kapi-comment-composer')?.closest('.kapi-comment');
470
+ if (node)
471
+ animateOut(node);
472
+ comments = comments.filter((c) => c.id !== id);
473
+ // Renumber so ids stay contiguous (1..n) and match marker labels.
474
+ comments.forEach((c, i) => (c.id = i + 1));
475
+ draft = null;
476
+ unlockHighlight();
477
+ clearPreview();
478
+ saveToStorage();
479
+ render();
480
+ }
481
+ function beginEdit(entry) {
482
+ if (isDisabled())
483
+ return;
484
+ if (draft)
485
+ cancelDraft();
486
+ draft = { el: entry.el, ratioX: entry.ratioX, ratioY: entry.ratioY, id: entry.id, text: entry.text };
487
+ if (entry.targets && entry.targets.length > 1) {
488
+ previewElements(entry.targets.map((t) => t.el));
489
+ lockWithoutHighlight();
490
+ }
491
+ else {
492
+ lockHighlightOn(entry.el);
493
+ }
494
+ render();
495
+ }
496
+ // In an SPA, client-side navigation (Vue Router, etc.) changes
497
+ // location.pathname without reloading this script. Patch history's
498
+ // navigation methods and listen for back/forward/hash changes so comments
499
+ // stay scoped to whichever page is actually showing.
500
+ function handleNavigation() {
501
+ if (location.pathname === currentPathname)
502
+ return;
503
+ currentPathname = location.pathname;
504
+ storageKey = `kapi-comments:${currentPathname}`;
505
+ if (draft)
506
+ cancelDraft();
507
+ comments = [];
508
+ // Vue Router updates the DOM asynchronously after pushState/popstate fires,
509
+ // so querying for saved selectors must wait a frame for the new page to render.
510
+ requestAnimationFrame(() => {
511
+ loadFromStorage();
512
+ render();
513
+ });
514
+ }
515
+ function watchForNavigation() {
516
+ const originalPushState = history.pushState.bind(history);
517
+ history.pushState = function (...args) {
518
+ originalPushState(...args);
519
+ handleNavigation();
520
+ };
521
+ const originalReplaceState = history.replaceState.bind(history);
522
+ history.replaceState = function (...args) {
523
+ originalReplaceState(...args);
524
+ handleNavigation();
525
+ };
526
+ window.addEventListener('popstate', handleNavigation);
527
+ window.addEventListener('hashchange', handleNavigation);
528
+ }
529
+ watchForNavigation();
530
+ loadFromStorage();
531
+ render();
@@ -1,16 +1,24 @@
1
+ import { renderComponentBadge, describeElement } from './inspector.js';
1
2
  const PANEL_TAG = 'kapi-hover-panel';
3
+ const GAP = 6; // px between the hovered element and the panel
4
+ const VIEWPORT_MARGIN = 12;
5
+ const CORNER_OFFSET = 20; // fallback position (no anchor element) — top-right corner
2
6
  const STYLES = `
3
7
  :host {
4
8
  all: initial;
5
- position: fixed;
6
- top: 20px;
7
- right: 20px;
8
- z-index: 2147483647;
9
9
  color-scheme: dark;
10
+ /* Purely informational — never intercept hits, so elementsFromPoint()
11
+ in inspector.ts sees straight through to the element under the cursor
12
+ even when the panel is positioned right on top of it. */
13
+ pointer-events: none;
10
14
  }
11
15
 
12
16
  .kapi-hover-panel {
13
17
  display: none;
18
+ position: fixed;
19
+ top: 0;
20
+ left: 0;
21
+ z-index: 2147483647;
14
22
  max-width: 360px;
15
23
  box-sizing: border-box;
16
24
  padding: 8px 12px;
@@ -25,6 +33,10 @@ const STYLES = `
25
33
  line-height: 1.5;
26
34
  color: rgba(255, 255, 255, 0.75);
27
35
  word-break: break-word;
36
+ /* Slides to the new position when hover moves between elements. Has no
37
+ effect on first appearance — the panel goes straight from display:none
38
+ to its initial position, with no prior rendered frame to animate from. */
39
+ transition: transform 120ms ease;
28
40
  }
29
41
 
30
42
  .kapi-hover-panel.kapi-visible {
@@ -36,9 +48,13 @@ const STYLES = `
36
48
  font-weight: 600;
37
49
  }
38
50
 
39
- .kapi-hover-panel-source {
51
+ .kapi-hover-panel-component {
40
52
  color: rgb(74, 222, 128);
41
53
  font-weight: 600;
54
+ }
55
+
56
+ .kapi-hover-panel-source {
57
+ color: rgba(255, 255, 255, 0.5);
42
58
  margin-bottom: 2px;
43
59
  }
44
60
 
@@ -84,28 +100,57 @@ function ensurePanel() {
84
100
  document.body.appendChild(host);
85
101
  return panel;
86
102
  }
87
- export function updateHoverPanel(location) {
88
- const el = ensurePanel();
89
- if (!location) {
90
- el.classList.remove('kapi-visible');
103
+ // Anchors the panel just below-left of `anchorRect` (the hovered element),
104
+ // flipping above it if there's no room below, and clamping to the viewport.
105
+ // With no anchor (e.g. during processing, when nothing is hovered), falls
106
+ // back to a fixed top-right corner.
107
+ function position(anchorRect) {
108
+ const panelEl = ensurePanel();
109
+ const panelRect = panelEl.getBoundingClientRect();
110
+ let left;
111
+ let top;
112
+ if (anchorRect) {
113
+ left = anchorRect.left;
114
+ top = anchorRect.bottom + GAP;
115
+ if (top + panelRect.height > window.innerHeight - VIEWPORT_MARGIN) {
116
+ top = anchorRect.top - panelRect.height - GAP;
117
+ }
118
+ }
119
+ else {
120
+ left = window.innerWidth - panelRect.width - CORNER_OFFSET;
121
+ top = CORNER_OFFSET;
122
+ }
123
+ left = Math.min(Math.max(left, VIEWPORT_MARGIN), window.innerWidth - panelRect.width - VIEWPORT_MARGIN);
124
+ top = Math.max(top, VIEWPORT_MARGIN);
125
+ panelEl.style.transform = `translate(${left}px, ${top}px)`;
126
+ }
127
+ export function updateHoverPanel(el) {
128
+ const panelEl = ensurePanel();
129
+ if (!el) {
130
+ panelEl.classList.remove('kapi-visible');
91
131
  return;
92
132
  }
93
- el.replaceChildren();
133
+ const location = describeElement(el);
134
+ panelEl.replaceChildren();
135
+ if (location.component) {
136
+ panelEl.appendChild(renderComponentBadge(location.component, 'kapi-hover-panel-component'));
137
+ }
94
138
  if (location.source) {
95
139
  const sourceEl = document.createElement('div');
96
140
  sourceEl.className = 'kapi-hover-panel-source';
97
141
  sourceEl.textContent = `${location.source.file}:${location.source.line}:${location.source.column}`;
98
- el.appendChild(sourceEl);
142
+ panelEl.appendChild(sourceEl);
99
143
  }
100
144
  const selectorEl = document.createElement('div');
101
145
  selectorEl.className = 'kapi-hover-panel-selector';
102
146
  selectorEl.textContent = location.selector;
103
- el.appendChild(selectorEl);
104
- el.classList.add('kapi-visible');
147
+ panelEl.appendChild(selectorEl);
148
+ panelEl.classList.add('kapi-visible');
149
+ position(el.getBoundingClientRect());
105
150
  }
106
151
  export function showProcessingStatus(status) {
107
- const el = ensurePanel();
108
- el.replaceChildren();
152
+ const panelEl = ensurePanel();
153
+ panelEl.replaceChildren();
109
154
  const row = document.createElement('div');
110
155
  row.className = 'kapi-hover-panel-status';
111
156
  const dot = document.createElement('span');
@@ -113,6 +158,7 @@ export function showProcessingStatus(status) {
113
158
  const text = document.createElement('span');
114
159
  text.textContent = status;
115
160
  row.append(dot, text);
116
- el.appendChild(row);
117
- el.classList.add('kapi-visible');
161
+ panelEl.appendChild(row);
162
+ panelEl.classList.add('kapi-visible');
163
+ position(null);
118
164
  }