angular-grab 0.1.0 → 0.1.2

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 (132) hide show
  1. package/README.md +215 -0
  2. package/examples/angular-19-app/.editorconfig +17 -0
  3. package/examples/angular-19-app/.vscode/extensions.json +4 -0
  4. package/examples/angular-19-app/.vscode/launch.json +20 -0
  5. package/examples/angular-19-app/.vscode/mcp.json +9 -0
  6. package/examples/angular-19-app/.vscode/tasks.json +42 -0
  7. package/examples/angular-19-app/README.md +59 -0
  8. package/examples/angular-19-app/angular.json +74 -0
  9. package/examples/angular-19-app/package.json +44 -0
  10. package/examples/angular-19-app/public/favicon.ico +0 -0
  11. package/examples/angular-19-app/src/app/app.config.ts +13 -0
  12. package/examples/angular-19-app/src/app/app.css +37 -0
  13. package/examples/angular-19-app/src/app/app.html +25 -0
  14. package/examples/angular-19-app/src/app/app.routes.ts +3 -0
  15. package/examples/angular-19-app/src/app/app.spec.ts +23 -0
  16. package/examples/angular-19-app/src/app/app.ts +12 -0
  17. package/examples/angular-19-app/src/app/button/button.component.ts +25 -0
  18. package/examples/angular-19-app/src/app/card/card.component.ts +33 -0
  19. package/examples/angular-19-app/src/app/header/header.component.ts +31 -0
  20. package/examples/angular-19-app/src/app/popover/popover.component.ts +133 -0
  21. package/examples/angular-19-app/src/index.html +13 -0
  22. package/examples/angular-19-app/src/main.ts +6 -0
  23. package/examples/angular-19-app/src/styles.css +1 -0
  24. package/examples/angular-19-app/tsconfig.app.json +15 -0
  25. package/examples/angular-19-app/tsconfig.json +33 -0
  26. package/examples/angular-19-app/tsconfig.spec.json +15 -0
  27. package/package.json +14 -111
  28. package/packages/angular-grab/package.json +96 -0
  29. package/packages/angular-grab/src/angular/__tests__/context-builder.test.ts +216 -0
  30. package/packages/angular-grab/src/angular/angular-grab.service.ts +62 -0
  31. package/packages/angular-grab/src/angular/index.ts +13 -0
  32. package/packages/angular-grab/src/angular/provide-angular-grab.ts +22 -0
  33. package/packages/angular-grab/src/angular/resolvers/component-resolver.ts +71 -0
  34. package/packages/angular-grab/src/angular/resolvers/context-builder.ts +86 -0
  35. package/packages/angular-grab/src/angular/resolvers/ng-utils.ts +14 -0
  36. package/packages/angular-grab/src/angular/resolvers/source-resolver.ts +61 -0
  37. package/packages/angular-grab/src/builder/__tests__/builder.test.ts +72 -0
  38. package/packages/angular-grab/src/builder/builders/application/index.ts +13 -0
  39. package/packages/angular-grab/src/builder/builders/dev-server/index.ts +9 -0
  40. package/packages/angular-grab/src/builder/index.ts +3 -0
  41. package/packages/angular-grab/src/cli/__tests__/cli.test.ts +239 -0
  42. package/packages/angular-grab/src/cli/commands/init.ts +106 -0
  43. package/packages/angular-grab/src/cli/index.ts +15 -0
  44. package/packages/angular-grab/src/cli/utils/detect-project.ts +78 -0
  45. package/packages/angular-grab/src/cli/utils/modify-angular-json.ts +42 -0
  46. package/packages/angular-grab/src/cli/utils/modify-app-config.ts +42 -0
  47. package/packages/angular-grab/src/core/__tests__/generate-snippet.test.ts +149 -0
  48. package/packages/angular-grab/src/core/__tests__/plugin-registry.test.ts +286 -0
  49. package/packages/angular-grab/src/core/__tests__/store.test.ts +118 -0
  50. package/packages/angular-grab/src/core/__tests__/utils.test.ts +85 -0
  51. package/packages/angular-grab/src/core/clipboard/copy.ts +104 -0
  52. package/packages/angular-grab/src/core/clipboard/generate-snippet.ts +38 -0
  53. package/packages/angular-grab/src/core/constants.ts +10 -0
  54. package/packages/angular-grab/src/core/grab.ts +596 -0
  55. package/packages/angular-grab/src/core/index.global.ts +13 -0
  56. package/packages/angular-grab/src/core/index.ts +19 -0
  57. package/packages/angular-grab/src/core/keyboard/keyboard-handler.ts +163 -0
  58. package/packages/angular-grab/src/core/overlay/crosshair.ts +107 -0
  59. package/packages/angular-grab/src/core/overlay/freeze-overlay.ts +239 -0
  60. package/packages/angular-grab/src/core/overlay/overlay-renderer.ts +180 -0
  61. package/packages/angular-grab/src/core/overlay/select-feedback.ts +108 -0
  62. package/packages/angular-grab/src/core/overlay/toast.ts +175 -0
  63. package/packages/angular-grab/src/core/picker/element-picker.ts +114 -0
  64. package/packages/angular-grab/src/core/plugins/plugin-registry.ts +83 -0
  65. package/packages/angular-grab/src/core/store.ts +52 -0
  66. package/packages/angular-grab/src/core/toolbar/actions-menu.ts +178 -0
  67. package/packages/angular-grab/src/core/toolbar/comment-popover.ts +235 -0
  68. package/packages/angular-grab/src/core/toolbar/copy-actions.ts +98 -0
  69. package/packages/angular-grab/src/core/toolbar/history-popover.ts +245 -0
  70. package/packages/angular-grab/src/core/toolbar/theme-manager.ts +188 -0
  71. package/packages/angular-grab/src/core/toolbar/toolbar-icons.ts +29 -0
  72. package/packages/angular-grab/src/core/toolbar/toolbar-renderer.ts +239 -0
  73. package/packages/angular-grab/src/core/types.ts +139 -0
  74. package/packages/angular-grab/src/core/utils.ts +16 -0
  75. package/packages/angular-grab/src/esbuild-plugin/__tests__/transform.test.ts +174 -0
  76. package/packages/angular-grab/src/esbuild-plugin/index.ts +3 -0
  77. package/packages/angular-grab/src/esbuild-plugin/plugin.ts +29 -0
  78. package/packages/angular-grab/src/esbuild-plugin/scan.ts +105 -0
  79. package/packages/angular-grab/src/esbuild-plugin/transform.ts +152 -0
  80. package/packages/angular-grab/src/vite-plugin/__tests__/plugin.test.ts +84 -0
  81. package/packages/angular-grab/src/vite-plugin/index.ts +19 -0
  82. package/packages/angular-grab/src/webpack-plugin/__tests__/plugin.test.ts +72 -0
  83. package/packages/angular-grab/src/webpack-plugin/index.ts +2 -0
  84. package/packages/angular-grab/src/webpack-plugin/loader.ts +15 -0
  85. package/packages/angular-grab/src/webpack-plugin/plugin.ts +20 -0
  86. package/packages/angular-grab/tsconfig.json +15 -0
  87. package/packages/angular-grab/tsup.config.ts +119 -0
  88. package/pnpm-workspace.yaml +3 -0
  89. package/turbo.json +21 -0
  90. package/dist/angular/index.d.ts +0 -151
  91. package/dist/angular/index.js +0 -2811
  92. package/dist/angular/index.js.map +0 -1
  93. package/dist/builder/builders/application/index.js +0 -143
  94. package/dist/builder/builders/application/index.js.map +0 -1
  95. package/dist/builder/builders/dev-server/index.js +0 -139
  96. package/dist/builder/builders/dev-server/index.js.map +0 -1
  97. package/dist/builder/index.js +0 -2
  98. package/dist/builder/index.js.map +0 -1
  99. package/dist/builder/package.json +0 -1
  100. package/dist/cli/index.js +0 -223
  101. package/dist/cli/index.js.map +0 -1
  102. package/dist/core/index.cjs +0 -2589
  103. package/dist/core/index.cjs.map +0 -1
  104. package/dist/core/index.d.cts +0 -139
  105. package/dist/core/index.d.ts +0 -139
  106. package/dist/core/index.global.js +0 -542
  107. package/dist/core/index.js +0 -2560
  108. package/dist/core/index.js.map +0 -1
  109. package/dist/esbuild-plugin/index.cjs +0 -239
  110. package/dist/esbuild-plugin/index.cjs.map +0 -1
  111. package/dist/esbuild-plugin/index.d.cts +0 -26
  112. package/dist/esbuild-plugin/index.d.ts +0 -26
  113. package/dist/esbuild-plugin/index.js +0 -200
  114. package/dist/esbuild-plugin/index.js.map +0 -1
  115. package/dist/vite-plugin/index.d.ts +0 -7
  116. package/dist/vite-plugin/index.js +0 -128
  117. package/dist/vite-plugin/index.js.map +0 -1
  118. package/dist/webpack-plugin/index.cjs +0 -54
  119. package/dist/webpack-plugin/index.cjs.map +0 -1
  120. package/dist/webpack-plugin/index.d.cts +0 -5
  121. package/dist/webpack-plugin/index.d.ts +0 -5
  122. package/dist/webpack-plugin/index.js +0 -23
  123. package/dist/webpack-plugin/index.js.map +0 -1
  124. package/dist/webpack-plugin/loader.cjs +0 -155
  125. package/dist/webpack-plugin/loader.cjs.map +0 -1
  126. package/dist/webpack-plugin/loader.d.cts +0 -3
  127. package/dist/webpack-plugin/loader.d.ts +0 -3
  128. package/dist/webpack-plugin/loader.js +0 -122
  129. package/dist/webpack-plugin/loader.js.map +0 -1
  130. /package/{builders.json → packages/angular-grab/builders.json} +0 -0
  131. /package/{dist → packages/angular-grab/src}/builder/builders/application/schema.json +0 -0
  132. /package/{dist → packages/angular-grab/src}/builder/builders/dev-server/schema.json +0 -0
@@ -0,0 +1,235 @@
1
+ import { Z_INDEX_POPOVER, TOOLBAR_POPOVER_OFFSET } from '../constants';
2
+
3
+ const POPOVER_ID = '__ag-comment-popover__';
4
+ const STYLE_ID = '__ag-comment-styles__';
5
+
6
+ export interface CommentPopover {
7
+ show(): void;
8
+ hide(): void;
9
+ isVisible(): boolean;
10
+ isPopoverElement(el: Element): boolean;
11
+ dispose(): void;
12
+ }
13
+
14
+ export interface CommentPopoverCallbacks {
15
+ onSubmit: (comment: string) => void;
16
+ onCancel: () => void;
17
+ }
18
+
19
+ export function createCommentPopover(callbacks: CommentPopoverCallbacks): CommentPopover {
20
+ let popover: HTMLDivElement | null = null;
21
+ let textarea: HTMLTextAreaElement | null = null;
22
+ let visible = false;
23
+ let keydownHandler: ((e: KeyboardEvent) => void) | null = null;
24
+
25
+ function injectStyles(): void {
26
+ if (document.getElementById(STYLE_ID)) return;
27
+
28
+ const style = document.createElement('style');
29
+ style.id = STYLE_ID;
30
+ style.textContent = `
31
+ #${POPOVER_ID} {
32
+ position: fixed;
33
+ bottom: ${TOOLBAR_POPOVER_OFFSET};
34
+ left: 50%;
35
+ transform: translateX(-50%);
36
+ z-index: ${Z_INDEX_POPOVER};
37
+ background: var(--ag-popover-bg, #0f172a);
38
+ border: 1px solid var(--ag-popover-border, #1e293b);
39
+ border-radius: 12px;
40
+ box-shadow: 0 8px 24px var(--ag-popover-shadow, rgba(0, 0, 0, 0.5));
41
+ width: 340px;
42
+ padding: 14px;
43
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
44
+ opacity: 0;
45
+ visibility: hidden;
46
+ transition: opacity 0.15s ease, visibility 0.15s ease;
47
+ pointer-events: auto;
48
+ }
49
+ #${POPOVER_ID}.ag-comment-visible {
50
+ opacity: 1;
51
+ visibility: visible;
52
+ }
53
+ #${POPOVER_ID} textarea {
54
+ width: 100%;
55
+ min-height: 80px;
56
+ padding: 8px 10px;
57
+ border: 1px solid var(--ag-popover-border, #1e293b);
58
+ border-radius: 8px;
59
+ background: var(--ag-surface, #1e293b);
60
+ color: var(--ag-popover-text, #e2e8f0);
61
+ font: 13px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
62
+ resize: vertical;
63
+ outline: none;
64
+ box-sizing: border-box;
65
+ }
66
+ #${POPOVER_ID} textarea:focus {
67
+ border-color: var(--ag-accent, #3b82f6);
68
+ }
69
+ #${POPOVER_ID} textarea::placeholder {
70
+ color: var(--ag-text-muted, #64748b);
71
+ }
72
+ #${POPOVER_ID} .ag-comment-footer {
73
+ display: flex;
74
+ justify-content: flex-end;
75
+ gap: 8px;
76
+ margin-top: 10px;
77
+ }
78
+ #${POPOVER_ID} .ag-comment-btn {
79
+ padding: 6px 14px;
80
+ border: none;
81
+ border-radius: 6px;
82
+ font-size: 13px;
83
+ font-weight: 500;
84
+ cursor: pointer;
85
+ transition: background 0.15s ease;
86
+ }
87
+ #${POPOVER_ID} .ag-comment-cancel {
88
+ background: transparent;
89
+ color: var(--ag-text-muted, #64748b);
90
+ }
91
+ #${POPOVER_ID} .ag-comment-cancel:hover {
92
+ background: var(--ag-popover-hover, #1e293b);
93
+ color: var(--ag-popover-text, #e2e8f0);
94
+ }
95
+ #${POPOVER_ID} .ag-comment-submit {
96
+ background: var(--ag-accent, #3b82f6);
97
+ color: #fff;
98
+ }
99
+ #${POPOVER_ID} .ag-comment-submit:hover {
100
+ background: var(--ag-accent-hover, #2563eb);
101
+ }
102
+ `;
103
+ document.head.appendChild(style);
104
+ }
105
+
106
+ function ensurePopover(): HTMLDivElement {
107
+ if (popover) return popover;
108
+
109
+ injectStyles();
110
+
111
+ popover = document.createElement('div');
112
+ popover.id = POPOVER_ID;
113
+ popover.setAttribute('role', 'dialog');
114
+ popover.setAttribute('aria-label', 'Add comment');
115
+
116
+ textarea = document.createElement('textarea');
117
+ textarea.placeholder = 'Add a comment...';
118
+ textarea.rows = 3;
119
+
120
+ const footer = document.createElement('div');
121
+ footer.className = 'ag-comment-footer';
122
+
123
+ const cancelBtn = document.createElement('button');
124
+ cancelBtn.className = 'ag-comment-btn ag-comment-cancel';
125
+ cancelBtn.textContent = 'Cancel';
126
+ cancelBtn.addEventListener('click', (e) => {
127
+ e.preventDefault();
128
+ e.stopPropagation();
129
+ doHide();
130
+ callbacks.onCancel();
131
+ });
132
+
133
+ const submitBtn = document.createElement('button');
134
+ submitBtn.className = 'ag-comment-btn ag-comment-submit';
135
+ submitBtn.textContent = 'Copy with Comment';
136
+ submitBtn.addEventListener('click', (e) => {
137
+ e.preventDefault();
138
+ e.stopPropagation();
139
+ const comment = textarea!.value.trim();
140
+ if (comment) {
141
+ doHide();
142
+ callbacks.onSubmit(comment);
143
+ } else {
144
+ textarea!.style.borderColor = 'var(--ag-accent, #3b82f6)';
145
+ textarea!.setAttribute('placeholder', 'Please enter a comment...');
146
+ textarea!.focus();
147
+ setTimeout(() => {
148
+ if (textarea) {
149
+ textarea.style.borderColor = '';
150
+ textarea.setAttribute('placeholder', 'Add a comment...');
151
+ }
152
+ }, 2000);
153
+ }
154
+ });
155
+
156
+ footer.appendChild(cancelBtn);
157
+ footer.appendChild(submitBtn);
158
+ popover.appendChild(textarea);
159
+ popover.appendChild(footer);
160
+
161
+ document.body.appendChild(popover);
162
+ return popover;
163
+ }
164
+
165
+ function attachKeydownInterceptor(): void {
166
+ if (keydownHandler) return;
167
+
168
+ keydownHandler = (e: KeyboardEvent) => {
169
+ // When textarea is focused, prevent keyboard handler from intercepting
170
+ if (textarea && document.activeElement === textarea) {
171
+ e.stopImmediatePropagation();
172
+
173
+ if (e.key === 'Escape') {
174
+ doHide();
175
+ callbacks.onCancel();
176
+ }
177
+ }
178
+ };
179
+
180
+ document.addEventListener('keydown', keydownHandler, true);
181
+ }
182
+
183
+ function detachKeydownInterceptor(): void {
184
+ if (keydownHandler) {
185
+ document.removeEventListener('keydown', keydownHandler, true);
186
+ keydownHandler = null;
187
+ }
188
+ }
189
+
190
+ function doHide(): void {
191
+ visible = false;
192
+ popover?.classList.remove('ag-comment-visible');
193
+ detachKeydownInterceptor();
194
+ }
195
+
196
+ return {
197
+ show(): void {
198
+ const el = ensurePopover();
199
+ textarea!.value = '';
200
+ visible = true;
201
+ void el.offsetHeight;
202
+ el.classList.add('ag-comment-visible');
203
+ attachKeydownInterceptor();
204
+ // Focus after transition starts
205
+ requestAnimationFrame(() => textarea?.focus());
206
+ },
207
+
208
+ hide(): void {
209
+ doHide();
210
+ },
211
+
212
+ isVisible(): boolean {
213
+ return visible;
214
+ },
215
+
216
+ isPopoverElement(el: Element): boolean {
217
+ if (!popover) return false;
218
+ let current: Element | null = el;
219
+ while (current) {
220
+ if (current === popover || current.id === POPOVER_ID) return true;
221
+ current = current.parentElement;
222
+ }
223
+ return false;
224
+ },
225
+
226
+ dispose(): void {
227
+ detachKeydownInterceptor();
228
+ popover?.remove();
229
+ document.getElementById(STYLE_ID)?.remove();
230
+ popover = null;
231
+ textarea = null;
232
+ visible = false;
233
+ },
234
+ };
235
+ }
@@ -0,0 +1,98 @@
1
+ import type { ElementContext } from '../types';
2
+ import type { PluginRegistry } from '../plugins/plugin-registry';
3
+ import { generateSnippet } from '../clipboard/generate-snippet';
4
+ import { showToast } from '../overlay/toast';
5
+ import { cleanAngularAttrs } from '../utils';
6
+
7
+ export async function copyElementSnippet(
8
+ context: ElementContext,
9
+ maxLines: number,
10
+ pluginRegistry?: PluginRegistry,
11
+ ): Promise<boolean> {
12
+ let snippet = generateSnippet(context, maxLines);
13
+ if (pluginRegistry) {
14
+ snippet = pluginRegistry.callTransformHook(snippet, context);
15
+ }
16
+ const ok = await writeAndToast(snippet, 'Copied to clipboard', context);
17
+ if (ok) pluginRegistry?.callHook('onCopySuccess', snippet, context, undefined);
18
+ return ok;
19
+ }
20
+
21
+ export async function copyElementHtml(
22
+ context: ElementContext,
23
+ pluginRegistry?: PluginRegistry,
24
+ ): Promise<boolean> {
25
+ const cleaned = cleanAngularAttrs(context.html);
26
+ const ok = await writeAndToast(cleaned, 'HTML copied to clipboard', context);
27
+ if (ok) pluginRegistry?.callHook('onCopySuccess', cleaned, context, undefined);
28
+ return ok;
29
+ }
30
+
31
+ export async function copyElementStyles(element: Element): Promise<boolean> {
32
+ if (!element.isConnected) {
33
+ showToast('Element is no longer on the page');
34
+ return false;
35
+ }
36
+
37
+ const computed = window.getComputedStyle(element);
38
+ const tag = element.tagName.toLowerCase();
39
+ const lines: string[] = [`/* Computed styles for <${tag}> */`, `${tag} {`];
40
+
41
+ const props = [
42
+ 'display', 'position', 'top', 'right', 'bottom', 'left',
43
+ 'width', 'height', 'min-width', 'min-height', 'max-width', 'max-height',
44
+ 'margin', 'padding',
45
+ 'border', 'border-radius',
46
+ 'background', 'background-color',
47
+ 'color', 'font', 'font-size', 'font-weight', 'font-family', 'line-height',
48
+ 'text-align', 'text-decoration', 'text-transform',
49
+ 'opacity', 'overflow', 'z-index',
50
+ 'flex-direction', 'justify-content', 'align-items', 'gap',
51
+ 'grid-template-columns', 'grid-template-rows',
52
+ 'box-shadow', 'cursor', 'transition', 'transform',
53
+ ];
54
+
55
+ for (const prop of props) {
56
+ const value = computed.getPropertyValue(prop);
57
+ if (value && value !== 'none' && value !== 'normal' && value !== 'auto' && value !== '0px' && value !== 'visible') {
58
+ lines.push(` ${prop}: ${value};`);
59
+ }
60
+ }
61
+
62
+ lines.push('}');
63
+ const css = lines.join('\n');
64
+
65
+ return writeAndToast(css, 'Styles copied to clipboard');
66
+ }
67
+
68
+ export async function copyWithComment(
69
+ context: ElementContext,
70
+ comment: string,
71
+ maxLines: number,
72
+ pluginRegistry?: PluginRegistry,
73
+ ): Promise<boolean> {
74
+ let snippet = generateSnippet(context, maxLines);
75
+ if (pluginRegistry) {
76
+ snippet = pluginRegistry.callTransformHook(snippet, context);
77
+ }
78
+ const full = `${snippet}\n\n/* Comment: ${comment} */`;
79
+ const ok = await writeAndToast(full, 'Copied with comment', context);
80
+ if (ok) pluginRegistry?.callHook('onCopySuccess', full, context, comment);
81
+ return ok;
82
+ }
83
+
84
+ async function writeAndToast(text: string, message: string, context?: ElementContext): Promise<boolean> {
85
+ try {
86
+ await navigator.clipboard.writeText(text);
87
+ showToast(message, context ? {
88
+ componentName: context.componentName,
89
+ filePath: context.filePath,
90
+ line: context.line,
91
+ column: context.column,
92
+ cssClasses: context.cssClasses,
93
+ } : undefined);
94
+ return true;
95
+ } catch {
96
+ return false;
97
+ }
98
+ }
@@ -0,0 +1,245 @@
1
+ import type { HistoryEntry } from '../types';
2
+ import { escapeHtml } from '../utils';
3
+ import { Z_INDEX_POPOVER, TOOLBAR_POPOVER_OFFSET } from '../constants';
4
+
5
+ const POPOVER_ID = '__ag-history-popover__';
6
+ const STYLE_ID = '__ag-history-styles__';
7
+
8
+ export interface HistoryPopover {
9
+ show(entries: HistoryEntry[]): void;
10
+ hide(): void;
11
+ isVisible(): boolean;
12
+ isPopoverElement(el: Element): boolean;
13
+ dispose(): void;
14
+ }
15
+
16
+ export interface HistoryPopoverCallbacks {
17
+ onEntryClick: (entry: HistoryEntry) => void;
18
+ }
19
+
20
+ function formatRelativeTime(timestamp: number): string {
21
+ const diff = Date.now() - timestamp;
22
+ const seconds = Math.floor(diff / 1000);
23
+
24
+ if (seconds < 60) return 'just now';
25
+ const minutes = Math.floor(seconds / 60);
26
+ if (minutes < 60) return `${minutes}m ago`;
27
+ const hours = Math.floor(minutes / 60);
28
+ if (hours < 24) return `${hours}h ago`;
29
+ const days = Math.floor(hours / 24);
30
+ return `${days}d ago`;
31
+ }
32
+
33
+ function shortPath(filePath: string): string {
34
+ const parts = filePath.split('/');
35
+ return parts[parts.length - 1];
36
+ }
37
+
38
+ function buildVsCodeUri(filePath: string, line: number | null, column: number | null): string {
39
+ let uri = `vscode://file/${encodeURI(filePath)}`;
40
+ if (line != null) uri += `:${line}`;
41
+ if (line != null && column != null) uri += `:${column}`;
42
+ return uri;
43
+ }
44
+
45
+ export function createHistoryPopover(callbacks: HistoryPopoverCallbacks): HistoryPopover {
46
+ let popover: HTMLDivElement | null = null;
47
+ let visible = false;
48
+
49
+ function injectStyles(): void {
50
+ if (document.getElementById(STYLE_ID)) return;
51
+
52
+ const style = document.createElement('style');
53
+ style.id = STYLE_ID;
54
+ style.textContent = `
55
+ #${POPOVER_ID} {
56
+ position: fixed;
57
+ bottom: ${TOOLBAR_POPOVER_OFFSET};
58
+ left: 50%;
59
+ transform: translateX(-50%);
60
+ z-index: ${Z_INDEX_POPOVER};
61
+ background: var(--ag-popover-bg, #0f172a);
62
+ border: 1px solid var(--ag-popover-border, #1e293b);
63
+ border-radius: 12px;
64
+ box-shadow: 0 8px 24px var(--ag-popover-shadow, rgba(0, 0, 0, 0.5));
65
+ min-width: 320px;
66
+ max-width: 420px;
67
+ max-height: 360px;
68
+ overflow-y: auto;
69
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
70
+ opacity: 0;
71
+ visibility: hidden;
72
+ transition: opacity 0.15s ease, visibility 0.15s ease;
73
+ pointer-events: auto;
74
+ }
75
+ #${POPOVER_ID}.ag-popover-visible {
76
+ opacity: 1;
77
+ visibility: visible;
78
+ }
79
+ #${POPOVER_ID} .ag-history-header {
80
+ padding: 10px 14px 8px;
81
+ font-size: 11px;
82
+ font-weight: 600;
83
+ text-transform: uppercase;
84
+ letter-spacing: 0.05em;
85
+ color: var(--ag-text-muted, #64748b);
86
+ border-bottom: 1px solid var(--ag-popover-border, #1e293b);
87
+ }
88
+ #${POPOVER_ID} .ag-history-empty {
89
+ padding: 24px 14px;
90
+ text-align: center;
91
+ color: var(--ag-text-muted, #64748b);
92
+ font-size: 13px;
93
+ }
94
+ #${POPOVER_ID} .ag-history-item {
95
+ display: flex;
96
+ align-items: center;
97
+ justify-content: space-between;
98
+ gap: 12px;
99
+ padding: 8px 14px;
100
+ cursor: pointer;
101
+ border: none;
102
+ border-bottom: 1px solid var(--ag-popover-border, #1e293b);
103
+ background: transparent;
104
+ width: 100%;
105
+ text-align: left;
106
+ font: inherit;
107
+ color: inherit;
108
+ transition: background 0.1s ease;
109
+ }
110
+ #${POPOVER_ID} .ag-history-item:last-child {
111
+ border-bottom: none;
112
+ }
113
+ #${POPOVER_ID} .ag-history-item:hover {
114
+ background: var(--ag-popover-hover, #1e293b);
115
+ }
116
+ #${POPOVER_ID} .ag-history-info {
117
+ flex: 1;
118
+ min-width: 0;
119
+ }
120
+ #${POPOVER_ID} .ag-history-selector {
121
+ font: 12px/1.3 ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
122
+ color: var(--ag-popover-text, #e2e8f0);
123
+ overflow: hidden;
124
+ text-overflow: ellipsis;
125
+ white-space: nowrap;
126
+ }
127
+ #${POPOVER_ID} .ag-history-meta {
128
+ font-size: 11px;
129
+ color: var(--ag-text-muted, #64748b);
130
+ margin-top: 2px;
131
+ overflow: hidden;
132
+ text-overflow: ellipsis;
133
+ white-space: nowrap;
134
+ }
135
+ #${POPOVER_ID} .ag-history-time {
136
+ font-size: 11px;
137
+ color: var(--ag-text-muted, #64748b);
138
+ flex-shrink: 0;
139
+ }
140
+ #${POPOVER_ID} .ag-history-file-link {
141
+ color: var(--ag-text-muted, #64748b);
142
+ text-decoration: none;
143
+ }
144
+ #${POPOVER_ID} .ag-history-file-link:hover {
145
+ text-decoration: underline;
146
+ color: var(--ag-accent, #3b82f6);
147
+ }
148
+ `;
149
+ document.head.appendChild(style);
150
+ }
151
+
152
+ function ensurePopover(): HTMLDivElement {
153
+ if (popover) return popover;
154
+
155
+ injectStyles();
156
+ popover = document.createElement('div');
157
+ popover.id = POPOVER_ID;
158
+ popover.setAttribute('role', 'dialog');
159
+ popover.setAttribute('aria-label', 'Grab history');
160
+ document.body.appendChild(popover);
161
+ return popover;
162
+ }
163
+
164
+ function render(entries: HistoryEntry[]): void {
165
+ const el = ensurePopover();
166
+
167
+ let html = '<div class="ag-history-header">History</div>';
168
+
169
+ if (entries.length === 0) {
170
+ html += '<div class="ag-history-empty">No elements grabbed yet</div>';
171
+ } else {
172
+ for (const entry of entries) {
173
+ const selector = escapeHtml(entry.context.selector);
174
+ const comp = entry.context.componentName ? escapeHtml(entry.context.componentName) : '';
175
+ const time = formatRelativeTime(entry.timestamp);
176
+ let meta = comp ? `in ${comp}` : '';
177
+ if (entry.context.filePath) {
178
+ const uri = buildVsCodeUri(entry.context.filePath, entry.context.line, entry.context.column);
179
+ const fileName = escapeHtml(shortPath(entry.context.filePath));
180
+ const sep = meta ? ' \u2014 ' : '';
181
+ meta += `${sep}<a class="ag-history-file-link" href="${escapeHtml(uri)}" title="Open in VS Code">${fileName}</a>`;
182
+ }
183
+
184
+ html += `<button class="ag-history-item" data-ag-history-id="${escapeHtml(entry.id)}" aria-label="Re-copy ${selector}">`;
185
+ html += `<div class="ag-history-info">`;
186
+ html += `<div class="ag-history-selector">${selector}</div>`;
187
+ if (meta) html += `<div class="ag-history-meta">${meta}</div>`;
188
+ html += `</div>`;
189
+ html += `<span class="ag-history-time">${time}</span>`;
190
+ html += `</button>`;
191
+ }
192
+ }
193
+
194
+ el.innerHTML = html;
195
+
196
+ // Attach click handlers
197
+ const items = el.querySelectorAll('.ag-history-item');
198
+ items.forEach((item) => {
199
+ item.addEventListener('click', (e) => {
200
+ e.stopPropagation();
201
+ const id = (item as HTMLElement).dataset.agHistoryId;
202
+ const entry = entries.find((ent) => ent.id === id);
203
+ if (entry) {
204
+ callbacks.onEntryClick(entry);
205
+ }
206
+ });
207
+ });
208
+ }
209
+
210
+ return {
211
+ show(entries: HistoryEntry[]): void {
212
+ render(entries);
213
+ visible = true;
214
+ // Force reflow for transition
215
+ void ensurePopover().offsetHeight;
216
+ ensurePopover().classList.add('ag-popover-visible');
217
+ },
218
+
219
+ hide(): void {
220
+ visible = false;
221
+ popover?.classList.remove('ag-popover-visible');
222
+ },
223
+
224
+ isVisible(): boolean {
225
+ return visible;
226
+ },
227
+
228
+ isPopoverElement(el: Element): boolean {
229
+ if (!popover) return false;
230
+ let current: Element | null = el;
231
+ while (current) {
232
+ if (current === popover || current.id === POPOVER_ID) return true;
233
+ current = current.parentElement;
234
+ }
235
+ return false;
236
+ },
237
+
238
+ dispose(): void {
239
+ popover?.remove();
240
+ document.getElementById(STYLE_ID)?.remove();
241
+ popover = null;
242
+ visible = false;
243
+ },
244
+ };
245
+ }