@llui/devmode-annotate
v0.4.7
Published
LLui dev-mode HUD — drop annotated notes from the running app into the shared notebook for the LLM to consume
Maintainers
Readme
@llui/devmode-annotate
A HUD that lets you drop annotated notes from a running LLui app into a shared on-disk notebook — picked up automatically by the LLM via @llui/mcp.
What it does
Mounts a floating 📝 button (bottom-right, draggable). Clicking it (or Cmd/Ctrl+Shift+A) opens a composer: a plain Markdown textarea plus the capture tools — draw a rectangle, pick an element under the cursor, attach a screenshot, record an interaction trace to replay. Submitting POSTs the note to the @llui/vite-plugin middleware at /_llui/notes, which writes a markdown file under .llui/notes/session-…/ with full metadata (URL, viewport, route, component path under the cursor, scope state, recent messages, LLui versions).
The HUD also browses the notebook, files notes as tasks, shows the LLM's replies and proposed fixes, and answers LLM-initiated capture requests over the dev server's SSE channel. Both sides read and write the same directory.
Install
The HUD is an optional, consumer-provided package — @llui/vite-plugin deliberately does not depend on it. Add the standalone core yourself:
pnpm add -D @llui/devmode-annotateThe core has no Lexical dependency. To upgrade the textarea to the rich Markdown surface, install the optional editor too:
pnpm add -D @llui/devmode-annotate @llui/devmode-annotate-editorIn development the Vite plugin detects the editor package and registers it before mounting the HUD. Manual mounts opt in with import '@llui/devmode-annotate-editor' before calling mountAnnotateHud().
That is all the dev-server setup there is: the plugin resolves the package from your project root and injects a <script type="module"> that mounts the HUD, via transformIndexHtml in dev. A build never runs that hook, so a production bundle contains no reference to this package at all — no app-entry import, nothing to tree-shake. (Injection silently no-ops when the package isn't installed.) See Opting out / customizing.
For a live app that ships the HUD deliberately, install it as a regular dependency and wire it yourself — next section.
Shipping it in a live app
The HUD is not free: even the standalone core uses html-to-image and fflate for capture/export. The optional editor adds Lexical. Keep either graph off the initial production path until an authorized user activates it.
Use @llui/devmode-annotate/install. It registers the activation trigger and import()s the HUD only when the trigger fires, so the bundler splits the whole HUD into a chunk nobody fetches until it is opened:
// src/main.ts
import { installAnnotateHud } from '@llui/devmode-annotate/install'
// Behind the host's own authorization — this is a live app.
if (currentUser.isStaff) installAnnotateHud()installAnnotateHud(opts) takes every mountAnnotateHud option plus trigger (default true, the Cmd/Ctrl+Shift+A bootstrap listener), and defaults allowProduction: true + isolate: true (shadow-DOM style isolation). It returns { activate, dispose }: activate() resolves to the live AnnotateHudHandle and is idempotent, dispose() drops the bootstrap listener.
Do not import mountAnnotateHud from the barrel in a production entry. The mount gate (import.meta.env.DEV, unless allowProduction) is a runtime check, so no bundler can drop the core module graph. test/entry-boundaries.test.ts pins the properties the lazy path rests on: src/install.ts reaches the HUD only through an erased import type and a dynamic import(), the core graph reaches no editor or Lexical module, and the store entry never reaches the HUD at all.
This is deliberately the production-entry guidance fixed by #116, not a replacement for it: #63 changes the editor package boundary, while ./install remains the production-safe core entry.
To keep the rich editor lazy too, disable the installer's built-in trigger and load the registration immediately before activation:
import { installAnnotateHud } from '@llui/devmode-annotate/install'
const hud = installAnnotateHud({ trigger: false })
async function activateRichHud() {
await import('@llui/devmode-annotate-editor')
return hud.activate()
}Use under the dev server
The plugin already mounts it. To mount it yourself in a dev-only entry, keep the import dynamic so the build can drop it:
// src/main.ts
if (import.meta.env.DEV) {
void import('@llui/devmode-annotate').then((m) => m.mountAnnotateHud())
}A static import { mountAnnotateHud } from '@llui/devmode-annotate' at the top of your entry ships the HUD in every build, dev gate or not.
Without a dev server
The HUD talks to a NotesStore, not to /_llui/* directly. Pass store to run it anywhere:
indexedDbStore()— the notebook lives in the browser; notes and screenshots persist locally and the HUD's Export button produces a.zipin the canonical on-disk layout.httpStore({ baseUrl, headers })— a host backend speaking the same wire protocol.- Your own adapter implementing
NotesStore(includingdispose(), which the HUD calls fromdestroy()to release object URLs and connections).
destroy() disposes the store it was given, not only one it created — it has to, because an inline installAnnotateHud({ store: indexedDbStore() }) leaves nobody else holding a reference and object URLs are never garbage-collected. So the HUD takes over that instance: indexedDbStore revokes every screenshot URL it handed out, and httpStore closes every live EventSource. If your own code also drives a store, give the HUD a separate instance — they are cheap and share the same backing storage.
Import them from @llui/devmode-annotate/stores, not from the package barrel — the barrel is the HUD, so naming a store there would pull the capture UI into your entry chunk and undo the lazy install:
import { installAnnotateHud } from '@llui/devmode-annotate/install'
import { indexedDbStore } from '@llui/devmode-annotate/stores'
installAnnotateHud({ store: indexedDbStore() })The store entry is eager (you construct the store up front), contains no editor or Lexical edge, and leaves the HUD itself waiting for activation.
Opting out / customizing
The plugin owns both the notebook endpoint and the injected HUD:
// vite.config.ts
import { defineConfig } from 'vite'
import llui from '@llui/vite-plugin'
export default defineConfig({
plugins: [
llui({
devmodeAnnotate: false, // opt out entirely
// or: devmodeAnnotate: {
// notesDir: 'tmp/notes',
// captureTimeoutMs: 60_000,
// hud: false, // keep the endpoint, skip the HUD injection
// }
}),
],
})API
Full signatures: llui.dev/api/devmode-annotate.
mountAnnotateHud(opts?) → AnnotateHudHandle— mount now. Idempotent: a second call (including one re-entered from inside the first) returns the handle of the one live HUD.destroy()tears down every listener, timer, subscription and node it created.installAnnotateHud(opts?) → { activate, dispose }— the lazy production entry, at@llui/devmode-annotate/install.registerAnnotateEditor(registration) → dispose— optional editor seam, also published at@llui/devmode-annotate/editor; core falls back to its textarea when nothing is registered.devServerStore/httpStore/indexedDbStore+ theNotesStoretypes — at@llui/devmode-annotate/stores(also re-exported from the barrel, for code that has already paid for it).- Handle:
open/close/destroy/setProse/submit/drawRect/handleCaptureRequest/setIntent/replayRepro/exportBundle. - Notable options:
store,allowProduction,isolate,hidden,redact(per-channel state/repro/screenshot redaction hooks),captureDebug,repro,elementPick,autoCaptureOnError,solveEnabled.
Keyboard
Cmd/Ctrl+Shift+A— open the HUD modalEscape— close it
What ends up on disk
After submitting "edit button copy is wrong":
.llui/notes/
session-2026-05-23-1432/
001-human-text-edit-button-copy-wrong.md
001-human-text-edit-button-copy-wrong.png # when the note carries a screenshot
status.jsonl # task status transitionsThe .md file carries the prose plus a frontmatter block with URL, viewport, route, component path, LLui versions — everything the LLM needs to act on the note without round-trips. The format is @llui/notes-format; the design notes live in the devmode-annotate proposal (current-state.md is the one kept current).
