This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Lookout is an Obsidian plugin for surveying wide content: pan/zoom Mermaid diagrams, fit-to-frame, and open diagrams or wide tables full-screen. It is written in TypeScript (src/main.ts) and bundled to main.js by esbuild (the official obsidian-sample-plugin toolchain). main.js is build output — gitignored, rebuilt in CI, attached to releases — so never edit main.js; edit src/main.ts and styles.css. Obsidian loads main.js/manifest.json/styles.css from the plugin folder root, so esbuild emits the bundle there and there is no dist/. tsc is used only to type-check (--noEmit); esbuild does the transpile/bundle. The only runtime dependency is obsidian (host-provided, marked external).
npm ci # install dev deps (first time / CI)
npm run dev # esbuild watch: rebuild main.js on save (local dev)
npm run build # tsc --noEmit (type-check) + esbuild production bundle
npm run lint # eslint-plugin-obsidianmd (Obsidian compliance)
npm run validate # manifest/versions consistency + required filesThe full CI gate (run before every PR), on Node 20:
npm ci
npm run lint # Obsidian plugin-guideline lint (eslint-plugin-obsidianmd)
npm run build # type-check + bundle -> main.js
node --check main.js
node scripts/validate.mjsThere are no unit tests — behavioural verification is manual in a real vault (see below). tsconfig.json is strict with strictPropertyInitialization off (fields init in _build()); see docs/DEVELOPMENT.md.
Obsidian compliance is enforced by lint, not memory. eslint-plugin-obsidianmd (the same ruleset the Obsidian reviewer runs) gates CI, so write code that passes it: no innerHTML; activeDocument/activeWindow not document/window; setCssStyles/setCssProps (available on both HTMLElement and SVGElement) instead of direct .style.x =; .instanceOf(HTMLElement) not instanceof; auto-bound arrow-function fields for event handlers. Releases attest provenance for main.js/styles.css via actions/attest-build-provenance.
Obsidian runs the plugin from <vault>/.obsidian/plugins/lookout/. Symlink the repo there and run the watch build; reload the plugin (toggle off/on, or the Reload app without saving command) after each rebuild:
ln -s "$(pwd)" /path/to/test-vault/.obsidian/plugins/lookout
npm run dev # rebuilds main.js on every saveA note containing a wide Mermaid diagram and a wide table exercises both features. Always test in both Reading view and Live Preview — they render content into different DOM containers and have historically diverged (see the table-processing guards in src/main.ts).
Everything lives in src/main.ts (~900 lines), three classes:
LookoutPlugin(thedefaultexport) — lifecycle and discovery. It finds rendered Mermaid<svg>s (.mermaid svg, svg[id^="mermaid-"]) and<table>s anywhere in the document and wraps each in a view. Discovery is driven by several overlapping triggers because Mermaid renders asynchronously and Obsidian re-renders panes on navigation:onLayoutReady, workspace events (layout-change,active-leaf-change,file-open), aregisterMarkdownPostProcessor, and aMutationObserverondocument.body. All of these funnel throughqueueScan()(debounced ~120ms) →scan()→scanWithin(root)→process()/processTable().DiagramView— one pan/zoom controller per diagram. The same class serves both the inline frame (wraps the live svg in place) and full-screen (wraps a clone in a fixed overlay). It owns the transform math:_measure,fit,actualSize,zoomTo/zoomBy,_panBounds/_clampPan, and_render.TableView— far simpler; tables keep native horizontal scroll and only gain a full-screen button. Full-screen clones the table into a maximized scroll overlay.
Because discovery fires repeatedly on the same DOM, every processor must be idempotent. Each handled element is stamped with the PROCESSED attribute and re-skipped; processors also bail early if the element is already inside Lookout's own wrappers (.lookout-viewport, .lookout-fs, .lookout-table-host). When adding any new element discovery, preserve this stamp-and-skip pattern or scans will duplicate views.
processTable carries hard-won guards: it enhances tables under .markdown-rendered or .markdown-source-view (Live Preview renders tables as a CM widget), but skips any table containing [contenteditable="true"] — that is the table the user is actively editing, whose DOM Obsidian owns.
Lookout moves Obsidian-owned nodes (the svg, the table) into its own wrappers and must restore them on destroy()/onunload. When overriding Obsidian's built-in styles in styles.css, win on selector specificity rather than !important — e.g. qualify with the host class (.mermaid.lookout-host) or scope under a container. The codebase deliberately avoids !important.
- Keep the dependency surface minimal.
obsidianis the only runtime dependency (host-provided,external— never bundled). Don't add runtime dependencies without a strong reason; dev dependencies stay limited to the TypeScript/esbuild toolchain. Type with the real Obsidian types, and keeptsc --noEmit(CI) green. - Follow Obsidian's code guidelines. No
innerHTML/outerHTML— build DOM nodes via the API (icons are constructed withcreateElementNSinsvgIcon()). Clean up listeners/observers/timers ondestroy()/onunload(). - Visual language is fixed: quiet Obsidian theme surfaces (
var(--background-*),var(--text-*)) plus a single survey-cyan accent (--lookout-accent) reserved for focus and the active zoom gauge. Do not introduce new colors. Icons are inline lucide-style SVG (1.75px stroke) built viasvgIcon(). - User-facing strings (command names,
Noticetext) are in Korean. - Respect
prefers-reduced-motion(theREDUCED_MOTIONflag / the reduced-motion media block) when adding animation.
- Default branch is
dev— branch off it (feat/*,fix/*,docs/*,chore/*) and open PRs againstdev, nevermain. Use Conventional Commits. mainonly receives merges fromdevorhotfix/*. Releases are automatic on merge tomain: the workflow readsversionfrommanifest.jsonand pushes a bare-version tag (novprefix — Obsidian convention).- A version bump must touch three files in lockstep (validated by
scripts/validate.mjs):manifest.json(version),versions.json("<version>": "<minAppVersion>", which must equalmanifest.minAppVersion), andCHANGELOG.md(moveUnreleasednotes into a dated section).