@unhingged/vizu-core 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Zoltán Balogh
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.
package/README.md ADDED
@@ -0,0 +1,428 @@
1
+ # @vizu/core
2
+
3
+ A drop-in DOM commenting tool. Press a shortcut on any page → highlight any element → leave a comment → the host application handles persistence and downstream actions via events.
4
+
5
+ **Vizu is a UI for collecting annotations, not a storage system or an LLM-prompt builder.** It emits events; you decide what to do with them.
6
+
7
+ ```bash
8
+ npm install @vizu/core
9
+ ```
10
+
11
+ ## Install via `<script>`
12
+
13
+ ```html
14
+ <script
15
+ src="https://unpkg.com/@vizu/core/dist/vizu.min.js"
16
+ data-shortcut="mod+shift+e"
17
+ data-namespace="my-site"
18
+ data-version="v1"
19
+ data-start-enabled="true"
20
+ data-user-name="Jane Doe"
21
+ data-user-avatar="https://example.com/jane.jpg">
22
+ </script>
23
+ <script>
24
+ // Vizu is at window.__vizu — register your actions here
25
+ window.__vizu.addAction({
26
+ id: 'copy',
27
+ label: 'Copy',
28
+ variant: 'primary',
29
+ onClick: (ctx) => ctx.copyToClipboard(JSON.stringify(ctx.comments, null, 2)),
30
+ });
31
+ window.__vizu.on('comment:added', ({ comment }) => {
32
+ fetch('/api/comments', { method: 'POST', body: JSON.stringify(comment) });
33
+ });
34
+ </script>
35
+ ```
36
+
37
+ Defaults in `<script>` mode: `data-storage="local"` (localStorage), no actions registered.
38
+
39
+ ## What it does — and what it doesn't
40
+
41
+ | Vizu does | Vizu does NOT |
42
+ |---|---|
43
+ | Renders the highlight, marker, popover, sidebar, pill | Persist comments anywhere by default (you wire that) |
44
+ | Captures DOM-anchored comments with fingerprints that survive edits | Format prompts / payloads (you build whatever shape you want) |
45
+ | Emits typed events for everything | Send anything to a backend |
46
+ | Exposes a `setUser` / `setComments` API for hydration | Manage auth or sessions |
47
+ | Lets you register pill actions | Ship "Copy as prompt" / "JSON" / "Clear" buttons |
48
+
49
+ ---
50
+
51
+ ## Integration
52
+
53
+ ### Plain JS / Vanilla
54
+
55
+ ```ts
56
+ import { Vizu } from '@vizu/core';
57
+
58
+ const vizu = new Vizu({
59
+ namespace: 'my-site',
60
+ pageVersion: 'home-v1',
61
+ shortcut: 'mod+shift+e',
62
+ user: { id: '123', name: 'Jane Doe', avatarUrl: '/jane.jpg' },
63
+ // storage defaults to 'memory' in the programmatic API — host owns persistence
64
+ });
65
+
66
+ // 1. Listen for new comments → POST to your backend
67
+ vizu.on('comment:added', async ({ comment }) => {
68
+ await fetch('/api/comments', {
69
+ method: 'POST',
70
+ headers: { 'Content-Type': 'application/json' },
71
+ body: JSON.stringify(comment),
72
+ });
73
+ });
74
+
75
+ vizu.on('comment:removed', async ({ id }) => {
76
+ await fetch(`/api/comments/${id}`, { method: 'DELETE' });
77
+ });
78
+
79
+ // 2. Hydrate from your backend on load
80
+ const existing = await fetch('/api/comments?ns=my-site').then((r) => r.json());
81
+ await vizu.setComments(existing, { persist: false });
82
+
83
+ // 3. Register a pill action that builds your own prompt format
84
+ vizu.addAction({
85
+ id: 'send-to-llm',
86
+ label: 'Send to LLM',
87
+ variant: 'primary',
88
+ onClick: async (ctx) => {
89
+ const res = await fetch('/api/iterate', {
90
+ method: 'POST',
91
+ body: JSON.stringify({
92
+ comments: ctx.comments,
93
+ pageHtml: ctx.pageHtml,
94
+ version: ctx.pageVersion,
95
+ }),
96
+ });
97
+ const { newHtml } = await res.json();
98
+ document.documentElement.innerHTML = newHtml;
99
+ ctx.toast('Page iterated');
100
+ },
101
+ visibleWhen: ({ commentsCount }) => commentsCount > 0,
102
+ });
103
+
104
+ vizu.enable();
105
+ ```
106
+
107
+ ### React (via [@vizu/react](../vizu-react))
108
+
109
+ ```tsx
110
+ 'use client';
111
+ import {
112
+ VizuProvider, useVizu, useComments, useVizuUser,
113
+ useVizuEvent, useVizuAction,
114
+ } from '@vizu/react';
115
+
116
+ function App() {
117
+ return (
118
+ <VizuProvider options={{ namespace: 'my-app', startEnabled: true }}>
119
+ <Annotations />
120
+ <YourPage />
121
+ </VizuProvider>
122
+ );
123
+ }
124
+
125
+ function Annotations() {
126
+ const [, setUser] = useVizuUser();
127
+ const session = useSession();
128
+ useEffect(() => {
129
+ if (session) setUser({ id: session.userId, name: session.name, avatarUrl: session.avatar });
130
+ }, [session, setUser]);
131
+
132
+ useVizuEvent('comment:added', ({ comment }) =>
133
+ fetch('/api/comments', { method: 'POST', body: JSON.stringify(comment) }),
134
+ );
135
+
136
+ useVizuAction({
137
+ id: 'send',
138
+ label: 'Send to backend',
139
+ variant: 'primary',
140
+ onClick: (ctx) => fetch('/api/iterate', { method: 'POST', body: JSON.stringify(ctx) }),
141
+ visibleWhen: ({ commentsCount }) => commentsCount > 0,
142
+ });
143
+
144
+ const comments = useComments();
145
+ return <CommentsBadge count={comments.length} />;
146
+ }
147
+ ```
148
+
149
+ ### Vue 3
150
+
151
+ A thin composable is the cleanest shape. Until `@vizu/vue` ships, write your own:
152
+
153
+ ```ts
154
+ // composables/useVizu.ts
155
+ import { onMounted, onUnmounted, ref, type Ref } from 'vue';
156
+ import { Vizu, type VizuOptions, type VizuComment } from '@vizu/core';
157
+
158
+ export function useVizu(options: VizuOptions) {
159
+ const vizu = new Vizu(options);
160
+ const comments: Ref<VizuComment[]> = ref([]);
161
+
162
+ const refresh = () => { comments.value = vizu.getComments(); };
163
+ const offs: Array<() => void> = [];
164
+
165
+ onMounted(() => {
166
+ vizu.enable();
167
+ offs.push(
168
+ vizu.on('comment:added', refresh),
169
+ vizu.on('comment:removed', refresh),
170
+ vizu.on('comments:cleared', refresh),
171
+ vizu.on('comments:set', refresh),
172
+ vizu.on('comments:loaded', refresh),
173
+ );
174
+ refresh();
175
+ });
176
+ onUnmounted(() => {
177
+ offs.forEach((o) => o());
178
+ vizu.destroy();
179
+ });
180
+ return { vizu, comments };
181
+ }
182
+ ```
183
+
184
+ Use in a component:
185
+ ```vue
186
+ <script setup lang="ts">
187
+ import { useVizu } from './composables/useVizu';
188
+ const { vizu, comments } = useVizu({ namespace: 'my-vue-app', startEnabled: true });
189
+ vizu.on('comment:added', ({ comment }) => fetch('/api/comments', { method: 'POST', body: JSON.stringify(comment) }));
190
+ </script>
191
+ <template>
192
+ <div>{{ comments.length }} comments</div>
193
+ </template>
194
+ ```
195
+
196
+ ### Angular
197
+
198
+ Provide Vizu via a service so identity + listeners are global:
199
+
200
+ ```ts
201
+ // vizu.service.ts
202
+ import { Injectable, NgZone, OnDestroy } from '@angular/core';
203
+ import { Vizu, type VizuOptions } from '@vizu/core';
204
+
205
+ @Injectable({ providedIn: 'root' })
206
+ export class VizuService implements OnDestroy {
207
+ readonly vizu: Vizu;
208
+ private offs: Array<() => void> = [];
209
+
210
+ constructor(private zone: NgZone) {
211
+ // Construct outside the zone so Vizu's own DOM listeners don't trigger CD on every mouse move
212
+ this.vizu = this.zone.runOutsideAngular(() => new Vizu({
213
+ namespace: 'my-ng-app',
214
+ startEnabled: true,
215
+ } satisfies VizuOptions));
216
+
217
+ // Pipe events back into the zone if your handlers update Angular state
218
+ this.offs.push(this.vizu.on('comment:added', (payload) =>
219
+ this.zone.run(() => fetch('/api/comments', { method: 'POST', body: JSON.stringify(payload.comment) })),
220
+ ));
221
+ }
222
+
223
+ setUser(name: string, avatarUrl?: string) {
224
+ this.vizu.setUser({ name, avatarUrl });
225
+ }
226
+
227
+ ngOnDestroy() {
228
+ this.offs.forEach((o) => o());
229
+ this.vizu.destroy();
230
+ }
231
+ }
232
+ ```
233
+
234
+ Use it in a component:
235
+ ```ts
236
+ @Component({...})
237
+ export class AppComponent {
238
+ constructor(private vizuService: VizuService) {
239
+ this.vizuService.vizu.addAction({
240
+ id: 'send',
241
+ label: 'Send to API',
242
+ variant: 'primary',
243
+ onClick: (ctx) => fetch('/api/iterate', { method: 'POST', body: JSON.stringify(ctx) }),
244
+ });
245
+ }
246
+ }
247
+ ```
248
+
249
+ ### Other frameworks
250
+
251
+ Anything with a DOM works. The package is framework-agnostic — `new Vizu(...)`, subscribe to events, call `setUser`/`setComments`/`addAction`. Lifecycle hook → `vizu.destroy()` on teardown.
252
+
253
+ ---
254
+
255
+ ## Events
256
+
257
+ Fire-and-forget. Handlers throw silently (errors are logged but don't break Vizu's state). Every payload is an object so the shape stays forward-compatible.
258
+
259
+ | Event | Payload | When it fires |
260
+ |---|---|---|
261
+ | `enabled` | `{}` | After `vizu.enable()` |
262
+ | `disabled` | `{}` | After `vizu.disable()` |
263
+ | `mounted` | `{}` | After the UI is in the DOM |
264
+ | `unmounted` | `{}` | After the UI is removed |
265
+ | `comment:added` | `{ comment: VizuComment }` | User saved a new comment |
266
+ | `comment:removed` | `{ id: string, comment: VizuComment }` | User clicked delete |
267
+ | `comments:cleared` | `{ previous: VizuComment[] }` | `clearAll()` resolved |
268
+ | `comments:set` | `{ comments: VizuComment[] }` | `setComments(...)` resolved (hydration) |
269
+ | `comments:loaded` | `{ comments: VizuComment[] }` | Storage adapter finished initial `load()` |
270
+ | `element:selected` | `{ target: Element, fingerprint: ElementFingerprint }` | User clicked a commentable element |
271
+ | `element:deselected` | `{}` | Popover closed |
272
+ | `sidebar:opened` | `{}` | Sidebar drawer opened |
273
+ | `sidebar:closed` | `{}` | Sidebar drawer closed |
274
+ | `action:invoked` | `{ id: string }` | Pill action clicked (before its `onClick` runs) |
275
+ | `user:changed` | `{ user: VizuUser \| null }` | `setUser(...)` called |
276
+
277
+ ### Subscription API
278
+
279
+ ```ts
280
+ const off = vizu.on('comment:added', ({ comment }) => { /* ... */ });
281
+ off(); // unsubscribe
282
+ vizu.off('comment:added', handler); // alternative
283
+ ```
284
+
285
+ `on()` returns an unsubscribe function. Use it in cleanup paths (React `useEffect` return, Vue `onUnmounted`, Angular `ngOnDestroy`).
286
+
287
+ ---
288
+
289
+ ## Supplying current user data
290
+
291
+ Three ways:
292
+
293
+ **1. At construction**
294
+ ```ts
295
+ new Vizu({ user: { id: '123', name: 'Jane Doe', avatarUrl: '/jane.jpg' } });
296
+ ```
297
+
298
+ **2. Imperatively (re-renders the pill chip + popover author line)**
299
+ ```ts
300
+ vizu.setUser({ id: '123', name: 'Jane Doe', avatarUrl: '/jane.jpg' });
301
+ // or clear
302
+ vizu.setUser(null);
303
+ ```
304
+
305
+ **3. Script-tag data attributes**
306
+ ```html
307
+ <script src="vizu.min.js" data-user-name="Jane" data-user-id="123" data-user-avatar="/jane.jpg"></script>
308
+ ```
309
+
310
+ The current user is captured into every new comment as `comment.author` — that snapshot is preserved even if the user logs out or changes name later.
311
+
312
+ `VizuUser` shape:
313
+ ```ts
314
+ {
315
+ id?: string;
316
+ name: string;
317
+ avatarUrl?: string;
318
+ email?: string;
319
+ meta?: Record<string, unknown>; // free-form host extras (team, role, etc.)
320
+ }
321
+ ```
322
+
323
+ ---
324
+
325
+ ## Loading existing comments
326
+
327
+ Vizu doesn't ship a server adapter — bring your own. Two patterns:
328
+
329
+ ### Listen + push (recommended for live apps)
330
+
331
+ ```ts
332
+ // On load, hydrate from your API
333
+ const initial = await fetch('/api/comments?ns=my-site').then(r => r.json());
334
+ await vizu.setComments(initial, { persist: false });
335
+
336
+ // Then mirror every change to the backend
337
+ vizu.on('comment:added', ({ comment }) => fetch('/api/comments', { method: 'POST', body: JSON.stringify(comment) }));
338
+ vizu.on('comment:removed', ({ id }) => fetch(`/api/comments/${id}`, { method: 'DELETE' }));
339
+ vizu.on('comments:cleared', () => fetch('/api/comments?ns=my-site', { method: 'DELETE' }));
340
+ ```
341
+
342
+ ### Custom `StorageAdapter`
343
+
344
+ ```ts
345
+ import { Vizu, type StorageAdapter, type VizuComment } from '@vizu/core';
346
+
347
+ const remote: StorageAdapter = {
348
+ async load(ns) { const r = await fetch(`/api/comments?ns=${ns}`); return r.json(); },
349
+ async save(ns, c) { await fetch(`/api/comments?ns=${ns}`, { method: 'PUT', body: JSON.stringify(c) }); },
350
+ async clear(ns) { await fetch(`/api/comments?ns=${ns}`, { method: 'DELETE' }); },
351
+ };
352
+
353
+ new Vizu({ storage: remote, namespace: 'my-site' });
354
+ ```
355
+
356
+ The adapter is called transparently on add/remove/clear and once at startup. Mix and match: built-in `'local'` adapter for fast iteration, custom one for production.
357
+
358
+ ---
359
+
360
+ ## Storage modes
361
+
362
+ | `options.storage` | Behavior | Default in |
363
+ |---|---|---|
364
+ | `'local'` | Persist to `localStorage` | Script-tag mode |
365
+ | `'memory'` | RAM-only — wipes on reload | Programmatic API (`new Vizu()`) |
366
+ | `'none'` | No-op storage; host owns hydration via events + `setComments` | (opt-in) |
367
+ | `StorageAdapter` | Your own load/save/clear | (opt-in) |
368
+
369
+ ---
370
+
371
+ ## API quick reference
372
+
373
+ ```ts
374
+ // Lifecycle
375
+ vizu.enable(); vizu.disable(); vizu.toggle(); vizu.isEnabled();
376
+ vizu.destroy(); // full teardown + clear all listeners
377
+
378
+ // Comments
379
+ vizu.getComments(): VizuComment[];
380
+ vizu.setComments(comments, { persist?: boolean }): Promise<void>;
381
+ vizu.clearAll(): Promise<void>;
382
+
383
+ // User
384
+ vizu.setUser(user | null);
385
+ vizu.getUser(): VizuUser | null;
386
+
387
+ // Actions
388
+ vizu.addAction({ id, label, onClick, variant?, title?, visibleWhen? });
389
+ vizu.removeAction(id);
390
+ vizu.invokeAction(id); // programmatically trigger
391
+ vizu.getActions(): VizuAction[];
392
+
393
+ // Events
394
+ vizu.on(event, handler) → off();
395
+ vizu.off(event, handler);
396
+
397
+ // Helpers
398
+ vizu.snapshotHtml(): string; // page HTML with Vizu's own UI stripped (for sending to LLMs)
399
+ ```
400
+
401
+ ---
402
+
403
+ ## Element fingerprints
404
+
405
+ Each comment carries an `ElementFingerprint` that survives edits:
406
+
407
+ ```ts
408
+ {
409
+ selector: string; // CSS selector path
410
+ parentSelector: string;
411
+ tagName: string;
412
+ textSnippet: string; // first 80 chars of innerText
413
+ siblingIndex: number;
414
+ attributes: {
415
+ id?: string;
416
+ classList?: string[];
417
+ role?: string;
418
+ ariaLabel?: string;
419
+ dataKey?: string; // from data-vizu-key="…" — explicit pin
420
+ };
421
+ }
422
+ ```
423
+
424
+ Re-anchoring tries: `data-vizu-key` → `id` → full selector → parent + tag + sibling-index → fuzzy text match. Add `data-vizu-key="something-stable"` to elements you want pinned permanently — it wins all matching.
425
+
426
+ ## License
427
+
428
+ MIT