@unhingged/vizu-core
v0.1.21
Published
Drop-in DOM commenting — highlight any element, leave comments anchored to it, survive redeploys via a 6-rung fingerprint matcher.
Maintainers
Readme
@vizu/core
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.
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.
npm install @vizu/coreInstall via <script>
<script
src="https://unpkg.com/@vizu/core/dist/vizu.min.js"
data-shortcut="mod+shift+e"
data-namespace="my-site"
data-version="v1"
data-start-enabled="true"
data-user-name="Jane Doe"
data-user-avatar="https://example.com/jane.jpg">
</script>
<script>
// Vizu is at window.__vizu — register your actions here
window.__vizu.addAction({
id: 'copy',
label: 'Copy',
variant: 'primary',
onClick: (ctx) => ctx.copyToClipboard(JSON.stringify(ctx.comments, null, 2)),
});
window.__vizu.on('comment:added', ({ comment }) => {
fetch('/api/comments', { method: 'POST', body: JSON.stringify(comment) });
});
</script>Defaults in <script> mode: data-storage="local" (localStorage), no actions registered.
What it does — and what it doesn't
| Vizu does | Vizu does NOT |
|---|---|
| Renders the highlight, marker, popover, sidebar, pill | Persist comments anywhere by default (you wire that) |
| Captures DOM-anchored comments with fingerprints that survive edits | Format prompts / payloads (you build whatever shape you want) |
| Emits typed events for everything | Send anything to a backend |
| Exposes a setUser / setComments API for hydration | Manage auth or sessions |
| Lets you register pill actions | Ship "Copy as prompt" / "JSON" / "Clear" buttons |
Integration
Plain JS / Vanilla
import { Vizu } from '@vizu/core';
const vizu = new Vizu({
namespace: 'my-site',
pageVersion: 'home-v1',
shortcut: 'mod+shift+e',
user: { id: '123', name: 'Jane Doe', avatarUrl: '/jane.jpg' },
// storage defaults to 'memory' in the programmatic API — host owns persistence
});
// 1. Listen for new comments → POST to your backend
vizu.on('comment:added', async ({ comment }) => {
await fetch('/api/comments', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(comment),
});
});
vizu.on('comment:removed', async ({ id }) => {
await fetch(`/api/comments/${id}`, { method: 'DELETE' });
});
// 2. Hydrate from your backend on load
const existing = await fetch('/api/comments?ns=my-site').then((r) => r.json());
await vizu.setComments(existing, { persist: false });
// 3. Register a pill action that builds your own prompt format
vizu.addAction({
id: 'send-to-llm',
label: 'Send to LLM',
variant: 'primary',
onClick: async (ctx) => {
const res = await fetch('/api/iterate', {
method: 'POST',
body: JSON.stringify({
comments: ctx.comments,
pageHtml: ctx.pageHtml,
version: ctx.pageVersion,
}),
});
const { newHtml } = await res.json();
document.documentElement.innerHTML = newHtml;
ctx.toast('Page iterated');
},
visibleWhen: ({ commentsCount }) => commentsCount > 0,
});
vizu.enable();React (via @vizu/react)
'use client';
import {
VizuProvider, useVizu, useComments, useVizuUser,
useVizuEvent, useVizuAction,
} from '@vizu/react';
function App() {
return (
<VizuProvider options={{ namespace: 'my-app', startEnabled: true }}>
<Annotations />
<YourPage />
</VizuProvider>
);
}
function Annotations() {
const [, setUser] = useVizuUser();
const session = useSession();
useEffect(() => {
if (session) setUser({ id: session.userId, name: session.name, avatarUrl: session.avatar });
}, [session, setUser]);
useVizuEvent('comment:added', ({ comment }) =>
fetch('/api/comments', { method: 'POST', body: JSON.stringify(comment) }),
);
useVizuAction({
id: 'send',
label: 'Send to backend',
variant: 'primary',
onClick: (ctx) => fetch('/api/iterate', { method: 'POST', body: JSON.stringify(ctx) }),
visibleWhen: ({ commentsCount }) => commentsCount > 0,
});
const comments = useComments();
return <CommentsBadge count={comments.length} />;
}Vue 3
A thin composable is the cleanest shape. Until @vizu/vue ships, write your own:
// composables/useVizu.ts
import { onMounted, onUnmounted, ref, type Ref } from 'vue';
import { Vizu, type VizuOptions, type VizuComment } from '@vizu/core';
export function useVizu(options: VizuOptions) {
const vizu = new Vizu(options);
const comments: Ref<VizuComment[]> = ref([]);
const refresh = () => { comments.value = vizu.getComments(); };
const offs: Array<() => void> = [];
onMounted(() => {
vizu.enable();
offs.push(
vizu.on('comment:added', refresh),
vizu.on('comment:removed', refresh),
vizu.on('comments:cleared', refresh),
vizu.on('comments:set', refresh),
vizu.on('comments:loaded', refresh),
);
refresh();
});
onUnmounted(() => {
offs.forEach((o) => o());
vizu.destroy();
});
return { vizu, comments };
}Use in a component:
<script setup lang="ts">
import { useVizu } from './composables/useVizu';
const { vizu, comments } = useVizu({ namespace: 'my-vue-app', startEnabled: true });
vizu.on('comment:added', ({ comment }) => fetch('/api/comments', { method: 'POST', body: JSON.stringify(comment) }));
</script>
<template>
<div>{{ comments.length }} comments</div>
</template>Angular
Provide Vizu via a service so identity + listeners are global:
// vizu.service.ts
import { Injectable, NgZone, OnDestroy } from '@angular/core';
import { Vizu, type VizuOptions } from '@vizu/core';
@Injectable({ providedIn: 'root' })
export class VizuService implements OnDestroy {
readonly vizu: Vizu;
private offs: Array<() => void> = [];
constructor(private zone: NgZone) {
// Construct outside the zone so Vizu's own DOM listeners don't trigger CD on every mouse move
this.vizu = this.zone.runOutsideAngular(() => new Vizu({
namespace: 'my-ng-app',
startEnabled: true,
} satisfies VizuOptions));
// Pipe events back into the zone if your handlers update Angular state
this.offs.push(this.vizu.on('comment:added', (payload) =>
this.zone.run(() => fetch('/api/comments', { method: 'POST', body: JSON.stringify(payload.comment) })),
));
}
setUser(name: string, avatarUrl?: string) {
this.vizu.setUser({ name, avatarUrl });
}
ngOnDestroy() {
this.offs.forEach((o) => o());
this.vizu.destroy();
}
}Use it in a component:
@Component({...})
export class AppComponent {
constructor(private vizuService: VizuService) {
this.vizuService.vizu.addAction({
id: 'send',
label: 'Send to API',
variant: 'primary',
onClick: (ctx) => fetch('/api/iterate', { method: 'POST', body: JSON.stringify(ctx) }),
});
}
}Other frameworks
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.
Events
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.
| Event | Payload | When it fires |
|---|---|---|
| enabled | {} | After vizu.enable() |
| disabled | {} | After vizu.disable() |
| mounted | {} | After the UI is in the DOM |
| unmounted | {} | After the UI is removed |
| comment:added | { comment: VizuComment } | User saved a new comment |
| comment:removed | { id: string, comment: VizuComment } | User clicked delete |
| comments:cleared | { previous: VizuComment[] } | clearAll() resolved |
| comments:set | { comments: VizuComment[] } | setComments(...) resolved (hydration) |
| comments:loaded | { comments: VizuComment[] } | Storage adapter finished initial load() |
| element:selected | { target: Element, fingerprint: ElementFingerprint } | User clicked a commentable element |
| element:deselected | {} | Popover closed |
| sidebar:opened | {} | Sidebar drawer opened |
| sidebar:closed | {} | Sidebar drawer closed |
| action:invoked | { id: string } | Pill action clicked (before its onClick runs) |
| user:changed | { user: VizuUser \| null } | setUser(...) called |
Subscription API
const off = vizu.on('comment:added', ({ comment }) => { /* ... */ });
off(); // unsubscribe
vizu.off('comment:added', handler); // alternativeon() returns an unsubscribe function. Use it in cleanup paths (React useEffect return, Vue onUnmounted, Angular ngOnDestroy).
Supplying current user data
Three ways:
1. At construction
new Vizu({ user: { id: '123', name: 'Jane Doe', avatarUrl: '/jane.jpg' } });2. Imperatively (re-renders the pill chip + popover author line)
vizu.setUser({ id: '123', name: 'Jane Doe', avatarUrl: '/jane.jpg' });
// or clear
vizu.setUser(null);3. Script-tag data attributes
<script src="vizu.min.js" data-user-name="Jane" data-user-id="123" data-user-avatar="/jane.jpg"></script>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.
VizuUser shape:
{
id?: string;
name: string;
avatarUrl?: string;
email?: string;
meta?: Record<string, unknown>; // free-form host extras (team, role, etc.)
}Loading existing comments
Vizu doesn't ship a server adapter — bring your own. Two patterns:
Listen + push (recommended for live apps)
// On load, hydrate from your API
const initial = await fetch('/api/comments?ns=my-site').then(r => r.json());
await vizu.setComments(initial, { persist: false });
// Then mirror every change to the backend
vizu.on('comment:added', ({ comment }) => fetch('/api/comments', { method: 'POST', body: JSON.stringify(comment) }));
vizu.on('comment:removed', ({ id }) => fetch(`/api/comments/${id}`, { method: 'DELETE' }));
vizu.on('comments:cleared', () => fetch('/api/comments?ns=my-site', { method: 'DELETE' }));Custom StorageAdapter
import { Vizu, type StorageAdapter, type VizuComment } from '@vizu/core';
const remote: StorageAdapter = {
async load(ns) { const r = await fetch(`/api/comments?ns=${ns}`); return r.json(); },
async save(ns, c) { await fetch(`/api/comments?ns=${ns}`, { method: 'PUT', body: JSON.stringify(c) }); },
async clear(ns) { await fetch(`/api/comments?ns=${ns}`, { method: 'DELETE' }); },
};
new Vizu({ storage: remote, namespace: 'my-site' });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.
Storage modes
| options.storage | Behavior | Default in |
|---|---|---|
| 'local' | Persist to localStorage | Script-tag mode |
| 'memory' | RAM-only — wipes on reload | Programmatic API (new Vizu()) |
| 'none' | No-op storage; host owns hydration via events + setComments | (opt-in) |
| StorageAdapter | Your own load/save/clear | (opt-in) |
API quick reference
// Lifecycle
vizu.enable(); vizu.disable(); vizu.toggle(); vizu.isEnabled();
vizu.destroy(); // full teardown + clear all listeners
// Comments
vizu.getComments(): VizuComment[];
vizu.setComments(comments, { persist?: boolean }): Promise<void>;
vizu.clearAll(): Promise<void>;
// User
vizu.setUser(user | null);
vizu.getUser(): VizuUser | null;
// Actions
vizu.addAction({ id, label, onClick, variant?, title?, visibleWhen? });
vizu.removeAction(id);
vizu.invokeAction(id); // programmatically trigger
vizu.getActions(): VizuAction[];
// Events
vizu.on(event, handler) → off();
vizu.off(event, handler);
// Helpers
vizu.snapshotHtml(): string; // page HTML with Vizu's own UI stripped (for sending to LLMs)Element fingerprints
Each comment carries an ElementFingerprint that survives edits:
{
selector: string; // CSS selector path
parentSelector: string;
tagName: string;
textSnippet: string; // first 80 chars of innerText
siblingIndex: number;
attributes: {
id?: string;
classList?: string[];
role?: string;
ariaLabel?: string;
dataKey?: string; // from data-vizu-key="…" — explicit pin
};
}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.
License
MIT
