Status: Partially built. The P1 core has landed (GB-1038): the renderer/differ contract, the zod-validated manifest, symlink-aware discovery under
~/.glassbox/plugins/, fail-soft dynamic-import()activation, the priority/specificity dispatcher, the feature-flag kill-switch, and the review-note artifact integration with its code-block fallback — all insrc/plugins/, loaded at server startup. The file-diff-viewer integration has also shipped (GB-1042,src/plugins/fileView.ts) — so both FR-29.2 integration points are wired — as has the developer guide (GB-1041,docs/plugin-development-guide.md). The first reference plugin has shipped too — Graphviz.dot/.gv→ SVG (plugins/graphviz/, GB-1044), and desktop delivery (GB-1039) — bundled plugins build todist/plugins/, ship in the sidecar, and auto-install into~/.glassbox/plugins/at startup (freshness + dismiss-list) — and the management UI (GB-1040) — a Settings Plugins tab to list / enable-disable (global + per-project) / install / uninstall, manifest preferences (GB-1047/1054), and a native folder picker (GB-1048). The other two diagram renderers have also shipped as separately-installable, opt-in plugins: Mermaid (plugins/mermaid/, GB-1045; localmmdc/puppeteer subprocess) and PlantUML (plugins/plantuml/, GB-1046; localjava -jarsubprocess). Beyond renderers/differs, an additive image-decoder capability (imageDecoders, FR-29.19, GB-1063) lets a plugin decode bytes → RGBA so the perceptual diff (doc 26 P2) can score formats core can't; its first reference plugin, image-codecs (WebP/AVIF via jSquash WASM,plugins/image-codecs/, GB-1064), has shipped too. A committed fixture-plugin e2e (GB-1043) now drives both render paths (file-diff + review-note artifact) end-to-end with a real installed plugin, and a management-tab UI e2e (GB-1070) now drives the Settings → Plugins tab (list / enable-disable / preferences / config-layout actions / UI elements / opt-in install) — it caught
- fixed a real bug where a
<select>preference never saved from the UI. See §29.8.
Glassbox stays lean and local-first by keeping heavy, format-specific code out of the base install and desktop bundle. But some content types are worth rendering richly — diagram source as an actual diagram (Mermaid / Graphviz / PlantUML), notebooks, CAD, rich logs, proprietary formats. Each of those pulls in a multi-MB renderer that does not belong in core.
This document defines a plugin boundary so specialized content renderers (single file/blob → view) and differs (old vs new → diff view) live outside core and are installed opt-in. Core ships the contract, the loader, and the graceful fallbacks; specialized visualizers ship as installable plugins.
Glassbox already had the seed of this pattern — the image-diff component and the
former WASM SVG rasterizer (@resvg/resvg-wasm, since replaced by the live-<img>
SVG path). This generalizes that one-off into an extensible API. The design
deliberately mirrors the plugin system already shipping in the sibling Hot
Sheet app (~/Documents/hotsheet, its docs/18-plugins.md + src/plugins/):
directory-based discovery, a zod-validated manifest as the single trust boundary,
an activate(context) lifecycle that returns a capability, self-contained
esbuild-bundled plugins loaded by dynamic import(), bundled/official plugins
auto-installed into the user's plugin directory, in-process full trust, and
fail-soft loading. Hot Sheet's capability is a TicketingBackend; ours is a
content renderer / differ. Everything else transfers.
- FR-29.1 — Plugin boundary. Specialized renderers and differs shall live outside core and be installed opt-in. Core shall ship only the contract, the loader, the dispatcher, and the fallbacks. Installing a plugin shall be the only way heavy format-specific code enters a Glassbox install.
- FR-29.2 — One API, two integration points. A single plugin API shall serve both the file diff viewer (arbitrary content types, doc 4 / doc 18) and review-note artifacts (doc 20 §20.5). A plugin that renders a content type shall render it in both places; there is no separate artifact-only or diff-only plugin kind.
Scope — why image and SVG support stay core, not plugins. A recurring question is whether the built-in image/SVG viewer should itself be reframed as a content plugin. It should not, for four reasons — recorded here so the decision isn't re-litigated:
- They are first-class git content types, not specialized/opt-in ones. A binary image (or SVG) diff is ordinary review, not a rare format a user opts into. The plugin enablement model is opt-in install + disable + uninstall (FR-29.15 / FR-29.16); routing core image review through it would let a user uninstall image support and break an ordinary diff.
- The contract is the wrong shape. A plugin is
renderer/differ → RenderedView {svg|html}, delivered inertly — "specialized content → a view". Image support is not a leaf renderer: it is an interactive multi-mode comparison viewer (Metadata / A / B / Side-by-Side / Difference / Slice, doc 4 / doc 24 / doc 28) with zoom/pan, drawn-region feedback (region_data, doc 23), perceptual scoring (review_files.difference_score+ most-different-first sort, doc 26), a ground-truth manifest mode, and git old/new byte retrieval. The plugin API models none of that, and a plugin has no business owning DB columns, routes, and a client subsystem.- There is no heavy dependency to externalize. The lean-core rationale (FR-29.1, NFR-29.1) is to keep multi-MB format libraries (Mermaid, PlantUML) out of core. Image/SVG has none: SVGs are served as raw bytes and rendered live in an
<img>(the@resvgWASM rasterizer was removed — see doc 4), and the perceptual-diff libraries (pixelmatch/pngjs/jpeg-js) are pure JS that tsup bundles into core. Nothing needs to move out.- SVG is the plugin→viewer bridge, so the viewer must be core. A plugin's output is SVG, which the built-in image/SVG pipeline serves as
image/svg+xmland displays through the full comparison viewer (a plugin-rendered file such as.dotis treated exactly like an.svg; see FR-29.2 and the file-diff-viewer integration below). Making the viewer a plugin would invert this layering — plugins produce content; the built-in viewer displays it — and create a bootstrapping problem (what renders the plugin's own SVG?).The plugin-shaped opportunity here is the opposite direction: extending image support to formats core can't handle (a plugin that decodes WebP/AVIF/HEIC to RGBA for the perceptual diff, or renders an exotic format to a preview) — an additive, opt-in capability, tracked separately, that never replaces the built-in path.
- FR-29.3 — Plugin directory. Plugins shall be discovered from a single
global directory,
~/.glassbox/plugins/, with one subdirectory per plugin. Discovery shall follow symlinks that resolve to a directory, so a plugin can be installed by symlinking its source tree in (used by "install from disk"). - FR-29.4 — Manifest and validation. Each plugin shall carry a
manifest.json(or apackage.jsonwith aglassboxfield) declaring at least its id, name, version, entry point, and the content types it handles. The manifest shall be validated by a zod schema at load time; that validation is the single trust boundary of the loader. A plugin whose manifest fails validation is skipped (see fail-soft, FR-29.6). - FR-29.5 — Self-contained bundles. A plugin's entry point shall be a single
self-contained ESM file that carries its own dependencies (produced by an
esbuild-style
--bundle). The loader shall resolve nothing against the host'snode_modules; it only dynamic-import()s the one entry file byfile://URL. This is the property that lets user-installed plugins load identically in dev, in a global npm install, and inside the packaged desktop app whose sidecarnode_modulesis frozen (see FR-29.7). - FR-29.6 — Fail-soft activation. The loader shall dynamic-
import()each plugin's entry and call itsactivate(context)lifecycle function, registering whatever renderers/differs it returns. A plugin that throws on import or activation, is missing its entry, or fails manifest validation shall be recorded with an error status and skipped — it shall never crash server startup or disable other plugins. - FR-29.6a — Subprocess renders shall be time-bounded. A renderer backed by a local subprocess (the Mermaid and PlantUML plugins) shall impose a wall-clock ceiling on each render and fall back to the code block when it elapses, killing the child. Fail-soft otherwise has a hole: a subprocess that starts but never exits leaves the render promise pending forever and hangs the request awaiting it — strictly worse than the fallback, and precisely the shape a wedged headless browser or JVM takes in a restricted environment.
- FR-29.7 — Desktop delivery. Plugins shall work in the packaged Tauri
desktop app, not only CLI/npm installs. Because the sidecar's
node_modulesis frozen (an end user cannotnpm installinto a.app), the delivery model is: first-party/official plugins are bundled inside the app and, on startup, auto-installed into~/.glassbox/plugins/with a version + content-hash freshness check (re-install when missing, older, or same-version-but-byte- different) and a dismiss-list so a user's uninstall of a bundled plugin sticks. Users may also install from disk (a native folder picker that symlinks the chosen directory into~/.glassbox/plugins/). Combined with the self-contained bundle rule (FR-29.5), this makes a frozen sidecar irrelevant to plugin loading. A plugin may setautoInstall: falsein its manifest to be separately installable — it is built (and may ship in the bundle) but is not auto-installed, so a plugin with a system requirement (e.g. PlantUML, which needs a JRE) is never forced on users; they opt in (GB-1046).installBundledPluginsskips it. - FR-29.20 — Opt-in install from the UI, with readiness checks + guided setup.
A separately-installable (
autoInstall: false) bundled plugin shall be installable from Settings → Plugins with a single click — the desktop app has no CLI, so asetup.mjsisn't reachable there. The "Available to install" list enumerates the opt-in bundled plugins not yet installed. Clicking Install shall: (1) check system readiness — run each manifest-declared requirement's probe (command <checkArgs>, e.g.java -version,npm --version); (2) auto-fix what it can — copy the plugin out of the bundle and run the provisioning steps whose prerequisites are met (fetcha file — always runnable;npm-install— only whennpmis present); (3) for anything it can't do (a missing JRE, an absentnpm, a failed download), return specific instructions — the requirement's remediationhint, the failed/skipped step, and a fallback CLI command — so the user can finish and click Install again (idempotent; a re-install completes the remaining steps and preserves already- provisioned assets). The install descriptor is manifest-declared (install: { requirements[], provision[], cliHint }); a plugin with no descriptor is self-contained (install = copy). The whole action runs server-side in the local Node sidecar (filesystem + network +child_process), so it works identically in the browser and the desktop app with no native code (GB-1069). The image-codecs plugin is self-contained; PlantUML declares ajavarequirement- a jar
fetch; Mermaid declares annpmrequirement + annpm-install(a Chromium download, so it warns).
- a jar
- FR-29.8 — Content matching. A plugin shall declare the content types it handles by extension, MIME type, and/or a content sniff (a predicate over the leading bytes). The dispatcher shall select, for a given file/artifact, the highest-priority registered handler that matches; ties resolve to the most specific match (sniff > MIME > extension) then plugin load order.
- FR-29.9 — Renderer contract. A renderer shall be a function
render(input) → view, whereinputis the content bytes/text plus metadata (path, MIME, and which side it is — old/new/single), andviewis safe, inert HTML or SVG (see NFR-29.2). This is the single-file/blob → view path used for an added/deleted file, a single-image-style view, and a review-note artifact. The host normalizes SVG output to have an intrinsic size (src/plugins/svgSize.tsensureIntrinsicSvgSize, applied at therenderContentchoke point): both render paths present plugin SVG through an<img>, where a root with only aviewBoxand a missing or percentagewidth/height(e.g. Mermaid'swidth="100%"output) has no intrinsic dimensions and collapses to ~0×0 — the host injects absolute dimensions from the viewBox so every plugin, first- or third-party, renders visibly without each one reimplementing the fix. - FR-29.10 — Differ contract. A differ shall be a function
diff(old, new) → view, whereoldandneware each the side's bytes/text plus metadata, andviewis a safe diff view. This is the old-vs-new path used for a modified file. A plugin may provide a differ, a renderer, or both; when a plugin handles a modified file but supplies only a renderer, the viewer shows the two sides rendered independently (as the image comparison modes already do). - FR-29.11 — Registration. A plugin's
activate(context)shall return its registration —{ renderers?, differs?, imageDecoders?, reviewHooks? }— or nothing. A plugin that returns none of these is a valid no-op (e.g. a plugin that only contributes a preference or a UI affordance). Registration is additive into the in-memory registry the dispatcher reads. - FR-29.12 — Plugin context + preferences.
activateshall receive aPluginContextgiving the plugin a scoped logger and access to its own persisted configuration (getSetting/setSetting). A plugin declares user-facing preferences in its manifest (key/label/typeof string·number·boolean·select /default/description/options/scopeof global·project); the Settings → Plugins tab renders them and auto-saves, andgetSetting(key)returns the stored value (or the declared default). Non-secret values persist in the global config (~/.glassbox/config.json) or the project settings (.glassbox/settings.json) per the preference's scope (src/plugins/settings.ts, GB-1047). Setting a preference reloads the plugin so it re-reads the value (the referencegraphvizplugin exposes a layout-enginepreference). Secret preferences (secret: true) are stored in the OS keychain (accountplugin-<id>-<key>, the same store as API keys, doc 14 §14.4), never in config; the API/UI never return a secret's value — only whether it is configured (masked password input, GB-1054). - FR-29.19 — Image-decoder capability (bytes → RGBA). A plugin may
contribute one or more image decoders —
{ name, match, priority?, decode(input) }wheredecode({ bytes, path }) → DecodedImage | nullandDecodedImage = { width, height, data: RGBA }— via the additiveimageDecodersregistration key. Decoders are matched + tie-broken by the same(priority, specificity)rule as renderers (FR-29.8) and dispatched throughdecodeImageWithPlugin(bytes, path, mime?)(src/plugins/index.ts), which returnsnullfail-soft when the subsystem is disabled, no decoder matches, or a decoder returns null / throws. This lets the perceptual diff (doc 26 §26 P2,src/ground-truth/perceptual-diff.ts) score ground-truth image pairs in formats core can't decode: core decodes PNG/JPEG (pngjs/jpeg-js) synchronously, and only when core can't doescomparePerceptual(now async) consult an installed decoder before returningundecodable. The capability is purely additive — it does not touch the renderer/differ contract — so heavy/rare codecs (WebP/AVIF WASM) stay out of core and opt in by install (NFR-29.1). The first reference decoder plugin —plugins/image-codecs/(WebP/AVIF via jSquash WASM, GB-1064) — has shipped: it registers.webp/.avifdecoders, is separately installable (autoInstall: false, ~1.8 MB of codec WASM), and makes those ground-truth pairs scorable. At launch the ground-truth branch initializes the plugin subsystem before scoring so an installed decoder is available (idempotent with the server's later init).
- FR-29.13 — Server-side by default. Heavy renderers shall run server-side
and return safe HTML/SVG to the client, keeping the client IIFE bundle lean and
the app offline (the precedent is the SVG path: rendered server-side / served as
bytes, displayed in a native
<img>that neither executes scripts nor loads external resources). A plugin may additionally ship light client-side assets served from a per-plugin static route, but that is optional and out of scope for the first implementation.
- FR-29.14 — Graceful fallback. When no plugin matches a content type, or the
matching plugin errors, the current behavior shall be unchanged: text and
diagram source render as the existing collapsible code block, images render as
<img>, and everything else uses today's default view. Where a well-known content type would be handled by an available-but-not-installed plugin, the fallback view may show a non-blocking "install X to render" hint. No network fetch and no CDN are ever used to satisfy a missing plugin (local-first).
- FR-29.15 — Opt-in install is the trust boundary; no runtime sandbox. Plugins execute code in-process, server-side, at the application's own trust level. There is no per-plugin sandbox or subprocess isolation. This is consistent with doc 14's threat model (the local machine is trusted; the server binds to loopback only) and with the fact that installing a plugin is an explicit, deliberate act by the user — the same trust decision as adding any npm dependency. Manifest zod-validation (FR-29.4) is the load-time boundary; fail-soft loading (FR-29.6) contains buggy plugins. Plugin secrets, if any, are keychain-backed (FR-29.12).
- FR-29.16 — Enablement (enabled by default; two disable scopes). A plugin's
code is installed globally, and an installed plugin is enabled by default.
It becomes inactive only when disabled, and there are two independent disable
lists: global (disabled for every project, stored in
~/.glassbox/config.json) and per-project (disabled for one repo, stored in.glassbox/settings.json). A plugin is enabled iff it is in neither list. Global takes precedence: a globally-disabled plugin is inactive everywhere and reports scopeglobaleven if also project-disabled (there is no project-level re-enable of a globally-disabled plugin). So the four states are: uninstalled (unknown to the system), installed & enabled, installed & globally disabled, and installed & disabled for this project. Changing a disable list takes effect on the next request (the subsystem reloads).
Outbound network is a plugin's choice, not the host's. Glassbox itself binds to loopback and is local-first (doc 14). Because plugins run in-process at full trust, a plugin can make outbound network calls — nothing in the contract prevents it, and installing it is the trust decision. This matters most for the general (non-content) capabilities: a review lifecycle hook (doc 31) that phones out — to post results to an issue tracker, notify a chat, trigger CI — shifts the app's default local-only posture. That is a deliberate, documented consequence of installing such a plugin; the host never initiates network I/O on a plugin's behalf. Prefer keeping plugins offline unless the user has knowingly installed one whose purpose is an external integration.
- FR-29.17 — Diagram-source rendering. The first reference plugin shall be
diagram-source rendering (Graphviz / Mermaid / PlantUML), registering a
renderer for the diagram extensions that renders server-side to SVG, with
the existing code-block view as the fallback for an uninstalled plugin or an
unsupported syntax. It proves the renderer contract end-to-end across both
integration points (a diagram file in the diff viewer and a diagram-source
review-note artifact). Built per-renderer, simplest first: Graphviz
(
.dot/.gv) has shipped as the first reference plugin (plugins/graphviz/, GB-1044) — server-side via@viz-js/viz(WebAssembly Graphviz, no DOM, no external process), which fits the server-side-SVG contract most cleanly. Mermaid (.mmd/.mermaid, needs a DOM — GB-1045) and PlantUML (.puml, needs Java — GB-1046) are the split siblings. Mermaid ships as a separately-installable, opt-in plugin (plugins/mermaid/, GB-1045), following the same model as PlantUML: Mermaid is fundamentally a browser library (it measures text via the DOM'sgetBBox), so there is no pure-JS/WASM engine and every maintained Node renderer drives a headless browser. It renders.mmd→ SVG by spawning a localmmdc(@mermaid-js/mermaid-cliover puppeteer/Chromium) subprocess — offline / local-first, nothing sent to a network render service. Because it needs a headless browser it is not auto-bundled (autoInstall: false→ skipped byinstallBundledPlugins), so core stays lean and no one is forced to have Chromium; asetuphelper installs@mermaid-js/mermaid-cli+ puppeteer into the plugin's install dir (fetching a Chromium on demand — not committed or shipped in the bundle). Ifmmdcis absent or a render fails, the renderer returns an empty view and the committed code-block fallback applies — nothing regresses. (MERMAID_PUPPETEER_CONFIGpasses a puppeteer config through tommdc -pfor locked-down / rootless environments.) PlantUML ships as a separately-installable, opt-in plugin (plugins/plantuml/, GB-1046): it renders.puml→ SVG by spawning a localjava -jar plantuml.jarsubprocess (PlantUML has no pure-JS/WASM engine; the only alternative — a network encoder offloading toplantuml.com— was rejected as it breaks local-first). A local Java subprocess is local-first, and because the plugin is not auto-bundled (autoInstall: falsein its manifest → skipped byinstallBundledPlugins), core stays lean and no one is forced to have Java — the user opts in. It requires a JRE +plantuml.jar(documented system requirements; asetuphelper checks Java + fetches the jar into the plugin dir). If Java or the jar is absent, or a render fails, the renderer returns an empty view and the committed code-block fallback (source shown, readable) applies — nothing regresses.
- FR-29.18 — Config layout, dynamic labels, and actions. Beyond the flat
preference list (FR-29.12), a plugin may declare an optional manifest
configLayout— an ordered, recursive list of items that arranges its Settings → Plugins UI (src/plugins/manifest.ts,ConfigLayoutItemSchema). Item types:preference(renders a declared preference bykey),divider,spacer,label(static or dynamic status text with a semanticcolortone —default/success/error/warning/transient),button(a caption + anactionid, optionalprimarystyle), andgroup(a collapsible titled section that nests items;collapsedsets the initial state, preserved across re-renders by native<details>). WhenconfigLayoutis omitted the preferences render as a flat list (back-compat). Abuttoninvokes the plugin's optionalonAction(actionId, context)(src/plugins/types.ts) viaPOST /api/plugins/:id/action; inside it the plugin callscontext.updateConfigLabel(labelId, text, color?)to reflect status back into alabel. Label overrides live in an in-memory map inloader.ts(keyedpluginId:labelId, cleared on uninstall);describeInstalledPluginsresolves each label's effective text/color (manifest default merged with any override) into the plugin'sconfigLabels, so the action's response — the refreshed plugin list, per the "every mutation returns the list" convention — carries the new status with no separate polling endpoint. The client renderer issrc/client/settings/pluginsTab.tsx; the host owns the tone→CSS mapping so labels stay consistent across plugins. Thegraphvizplugin's Rendering group + Test renderer button (renders a trivial graph, sets an OK/error status label) is the worked example. Plugin output stays content-only (NFR-29.2) — buttons carry no plugin-supplied HTML/icons.
- NFR-29.1 — Lean core, offline, no CDN. Core shall not gain any format-specific renderer dependency. All plugin code and assets are local; no plugin path may reach out to a CDN or remote host to render (local-first, consistent with doc 14).
- NFR-29.2 — Safe output. A renderer/differ's returned HTML/SVG shall be inert
with respect to the content it renders: script-free and without external
resource loads. SVG output shall be delivered so it cannot execute scripts
(e.g. via
<img>or an equivalently neutralizing path), matching how Glassbox already serves live SVGs. Plugins are trusted (FR-29.15), but their output over untrusted review content must not become an injection vector. - NFR-29.3 — Zero-plugin no-op. With no plugins installed (the default), the
subsystem shall add no measurable cost and no behavior change: discovery over an
empty/absent
~/.glassbox/plugins/is cheap, the dispatcher always falls through to the built-in views, and the client bundle is unchanged. - NFR-29.4 — Feature flag / kill-switch. The whole subsystem shall sit behind
a build/kill-switch flag (mirroring Hot Sheet's
PLUGINS_ENABLED) so it can be disabled wholesale — skipping discovery, loading, dispatch, and any plugin UI — without removing code.
The P1 core has shipped (GB-1038); its FR/NFR units carry real tests in
docs/testing/feature-coverage.json. The units not yet realized remain justified
waived entries there until their follow-up lands, at which point the waiver is
replaced by an asserting test.
The build is decomposed into follow-up tickets:
- Content-plugin core (GB-1038, shipped) — manifest schema + validation,
discovery, fail-soft activation, the
ContentRenderer/ContentDiffer/PluginContextcontract, the content-type dispatcher, the review-note artifact integration + fallback, and the feature flag. Lives insrc/plugins/. - File-diff-viewer integration (GB-1042 + GB-1052, shipped) — the second
integration point. A file a plugin renders to SVG is treated like an SVG
file: it gets the Code | Rendered toggle, and the Rendered view routes
its per-side SVG through the existing image viewer, so zoom + A / B /
Side-by-Side / Difference / Slice all apply (GB-1052).
src/plugins/fileView.ts(renderPluginSvgSiderenders one side;pluginRendersFilegates); the image route serves the rendered SVG asimage/svg+xml; the/file/:idview=renderedbranch builds thedata-is-svg<ImageDiff>;/filesflags such files (pluginRendered) so the client shows the toggle. The Code side is the normal text diff of the source. Gated by a cheap path pre-check (no content read without an installed handler). Scope: text content types → SVG; binary content types (raw bytes) and non-SVG (HTML) plugin output are follow-ups. - Desktop delivery (GB-1039, shipped) —
scripts/build-plugins.mjsbuilds each plugin todist/plugins/<id>/;build-sidecar.shcopies that into the sidecar (server/plugins);src/plugins/install.ts(installBundledPlugins) seeds~/.glassbox/plugins/at startup with a version + content-hash freshness check and a dismiss-list, plusinstallPluginFromDisk(symlink) /uninstallPluginmechanisms for the management UI. Realizes FR-29.5 / FR-29.7. (The Tauri folder-picker UI that drives install-from-disk is GB-1040.) - Management UI (GB-1040, shipped) — a Settings Plugins tab
(
src/client/settings/pluginsTab.tsx): lists installed plugins with status (active / disabled / failed), per-plugin global and per-project disable toggles (FR-29.16), install-from-a-folder (path) and uninstall, backed byGET/POST/DELETE /api/plugins(src/routes/api/plugins.ts). Manifest-declared preferences (FR-29.12, GB-1047) render here too. On desktop a Browse… button opens a native folder picker (GB-1048, thepick_plugin_folderTauri command); the text-path field is the browser/CLI fallback. - Developer guide (GB-1041, shipped) —
docs/plugin-development-guide.mdplus a standalone, copy-pastetypes.tsso third parties can build plugins without depending on the Glassbox package. - First reference plugin (GB-1044, shipped) — the Graphviz
.dot/.gvplugin (plugins/graphviz/), server-side to SVG via@viz-js/viz(WASM). Built byscripts/build-plugins.mjs(npm run build:plugins) into a self-containedplugins/graphviz/index.js. Split siblings both shipped as separately-installable, opt-in plugins: Mermaid (plugins/mermaid/, GB-1045; localmmdc/puppeteer subprocess) and PlantUML (plugins/plantuml/, GB-1046; localjava -jarsubprocess). - Image-decoder capability (GB-1063, shipped) — the additive
imageDecodersregistration key (FR-29.19):ImageDecoderinsrc/plugins/types.ts, registered/dispatched byContentPluginRegistry(addImageDecoders/findImageDecoder), exposed viadecodeImageWithPlugin(bytes, path, mime?)insrc/plugins/index.ts, and consulted bycomparePerceptual(now async) insrc/ground-truth/perceptual-diff.tswhen core can't decode a format. The ground-truth launch path initializes plugins before scoring. Purely additive; no change to renderer/differ. - Reference decoder plugin: image-codecs (GB-1064, shipped) —
plugins/image-codecs/registers WebP + AVIFimageDecoders(bytes → RGBA) via the jSquash WASM codecs (@jsquash/webp,@jsquash/avif), esbuild-bundled into the self-containedindex.js(the.wasminlined via thebinaryloader added toscripts/build-plugins.mjs; the codec bytes are injected via aWasmLoaderso the decoders unit-test in plain Node). No system dep, but separately installable (autoInstall: false,setup.mjscopies the self-contained build) to keep ~1.8 MB of codec WASM out of the default install. It contributes no renderer — browsers display WebP/AVIF natively; it only adds the decode-to-RGBA the perceptual diff needs.@jsquash/*are devDependencies bundled into the plugin, NOT core external deps (like graphviz's@viz-js/viz). - Committed fixture-plugin e2e (GB-1043, shipped) — a Playwright project
(
chromium-plugin,tests/e2e/plugin-render.test.ts) boots a--diffserver with a real fixture content plugin (tests/fixtures/plugin/fixture-diagram/, handling.fdiag) copied into an isolatedGLASSBOX_CONFIG_DIRbefore startup, plus a committed.pr-notes/note (tests/fixtures/plugin-diff-notes/). It drives both FR-29.2 integration points end-to-end: the file-diff path (the Code|Rendered toggle → the plugin's SVG servedimage/svg+xml) and the artifact path (a review-note.fdiagartifact → an inlinedata:image/svg+xml<img>). - Management-tab UI e2e (GB-1070, shipped) —
tests/e2e/plugin-manage.test.ts(samechromium-pluginserver) drives the Settings → Plugins tab against the installed fixture plugin (which now declares a preference + a config-layout Test button + a diff-toolbar UI element) and a self-contained opt-in fixture in the fixture bundle (GLASSBOX_BUNDLED_PLUGINS_DIR): the installed list + status dot, the global enable/disable toggle (removing + restoring the plugin's UI element), aselectpreference (render + save + persist-across-reload), the config-layout Test-button → status-label round-trip, a diff-toolbar UI element (render + click → toast), and the Available-to-install → Install → ready → uninstall flow. It caught- drove the fix for a real bug (
asInputthrew on a<select>in the preference-change delegate, so no select preference — incl. graphvizengine— saved from the UI) and a UX gap (uninstalling a bundled opt-in plugin now returns it to the Available list). The install-from-an-arbitrary-folder path, the per-project (vs global) enablement scope, and the stateful UI controls (toggle/switch/segmented) remain in the manual plan.
- drove the fix for a real bug (
Shipped in P1 (GB-1038):
-
Loader + registry + dispatcher —
src/plugins/(types.tscontract,manifest.tszod schema,loader.tsdiscovery + fail-soft activation,registry.tsmatching + priority dispatch,index.tsthe process-global registry +renderContent/diffContent+ startupinitContentPlugins), mirroring~/Documents/hotsheet/src/plugins/. The kill-switch issrc/feature-flags.ts(PLUGINS_ENABLED). Loaded at startup fromsrc/server.ts. -
Artifact integration —
src/plugins/artifacts.ts(renderNoteArtifacts) offers each text/diagram-source review-note artifact to the dispatcher; a match attaches inertrenderedSvg/renderedHtmlto theReviewNoteArtifactview, whichsrc/components/diffView.tsxrenders (SVG via an<img>data URI) in place of the code block. Called from the/file/:fileIdroute (src/routes/pages.tsx). Zero-plugin installs are a no-op (unchanged behavior). -
File-diff-viewer integration (GB-1052) —
src/plugins/fileView.ts(renderPluginSvgSide) renders one side to SVG; the image route (src/routes/api/image.ts) serves it asimage/svg+xml; the/file/:idview=renderedbranch (src/routes/pages.tsx) builds thedata-is-svg<ImageDiff>(dims from the rendered SVG);/filesflags plugin-rendered files (pluginRendered) and the client (src/client/diff/index.tsx,stores/index.ts) shows the Code/Rendered toggle + routes Rendered through the image viewer. The whole ImageDiff + zoom/slice/difference stack is reused unchanged — SVG is already a first-class image source. -
Desktop delivery —
scripts/build-plugins.mjs(npm run build:plugins) esbuilds each plugin todist/plugins/<id>/;scripts/build-sidecar.shcopies it into the sidecar (server/plugins);src/plugins/install.ts(installBundledPlugins, run frominitContentPluginsbefore discovery) seeds~/.glassbox/plugins/with a version + content-hash freshness check +dismissed-plugins.json;installPluginFromDisk/uninstallPluginback the management UI. -
Enablement + management UI —
src/plugins/enablement.ts(global + per-project disable lists, global-precedence) gates the loader (LoadedPlugin.statusgainsdisabled);initContentPlugins(repoRoot)/reloadContentPluginsapply it anddescribeInstalledPluginsreports it. The API issrc/routes/api/plugins.ts(GET/POST/DELETE /api/plugins,src/api/plugins.ts); the UI issrc/client/settings/pluginsTab.tsx. Per-repo state persists via the sharedsrc/project-settings-store.ts. -
Preferences (FR-29.12, GB-1047) — the manifest
preferencesschema (src/plugins/manifest.ts), the value store (src/plugins/settings.ts, global config / project settings per scope),PluginContext.getSetting/setSettingwired inloader.tsmakeContext(manifest, repoRoot), thePOST /api/plugins/:id/preferencessetter (reloads the plugin), and the Plugins-tab rendering. Thegraphvizplugin'senginepreference is the worked example. -
Config layout + dynamic labels + actions (FR-29.18, GB-1059) — the optional manifest
configLayout(ConfigLayoutItemSchemainsrc/plugins/manifest.ts) arranges preferences into collapsible groups / dividers / spacers / status labels / action buttons;PluginContext.updateConfigLabel+ the plugin'sonAction(src/plugins/types.ts, run viaPOST /api/plugins/:id/action,runPluginActioninindex.ts) drive dynamic status labels, folded into each plugin'sconfigLabelsin the list response (no polling endpoint). Rendered bysrc/client/settings/pluginsTab.tsx; thegraphvizplugin's Rendering group + Test renderer button is the worked example.
Shipped (native folder picker, GB-1048): the pick_plugin_folder Tauri command
(src-tauri/src/lib.rs, tauri-plugin-dialog, granted in
capabilities/remote-localhost.json + build.rs) opens a native folder dialog;
the Plugins tab shows a Browse… button under Tauri that installs the picked
folder. Compile-verified (cargo check); the dialog opening itself needs a
desktop smoke-test.