Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

This book documents the current Lapis Notes architecture as implemented in this monorepo on 19 April 2026. It is a current-state description, not a future-state design.

The repository is organized around four primary layers:

  • a browser-first workspace shell in packages/workspace
  • an Obsidian-like runtime and compatibility layer in packages/api
  • a shared Svelte UI kit in packages/ui
  • several feature and plugin packages in packages/plugins

Use this spec to understand where behavior lives today, trace boot and data flows, and capture follow-up decisions as ADRs.

Where the codebase is incomplete or ambiguous, the pages call that out directly. The current desktop packaging is one example: checked-in build artifacts exist, but the source package shape is not yet fully represented in the workspace metadata.

Intent

This spec exists to make the current codebase legible before the next round of feature and platform work.

Its goals are:

  1. document the package boundaries in the monorepo
  2. explain runtime ownership for app state, workspace state, storage, configuration, metadata, and plugins
  3. describe how the shipped workspace is assembled from shared packages and feature plugins
  4. create a home for ADRs so future architectural decisions have context

This spec is not trying to be a full API reference for every export in @lapis-notes/api, nor a user manual for product features. It is an architecture and implementation map for contributors.

Glossary

TermMeaning in this repo
AppThe root runtime object from packages/api/src/lib/context.svelte.ts. It owns workspace, vault, configuration, commands, metadata, and plugin management.
VaultThe file-backed note store exposed through API storage abstractions. New browser-local vaults use OPFS by default; legacy/manual setups can still use Lightning FS.
OPFS vaultA browser-local vault whose canonical file bytes live in the origin private file system.
Real-folder vaultA vault backed by a user-selected directory through the File System Access API.
Vault profileBrowser-persisted startup state that records which vault backend to reopen and the stable vault id used for scoped IndexedDB data.
Sidecar statsIndexedDB metadata used to supplement browser file handles with vault stat fields such as creation time and folder modified time.
WorkspaceThe runtime model for panes, splits, tabs, leaves, and views. It is not just the repo root.
LeafA single visible view host inside the workspace tree. Leaves can be duplicated, split, and persisted in layout state.
ViewA runtime screen attached to a leaf, such as text, markdown, file explorer, or search.
Core pluginA plugin loaded directly by the workspace shell during boot, for example the app, file explorer, markdown, search, and bases plugins.
Community pluginA plugin discovered from /.obsidian/plugins and loaded through PluginManager.
Configuration schemaJSON-schema-like definitions registered into the app configuration system and rendered in the settings UI.
Metadata cacheThe app service that tracks parsed file metadata and emits change events used by search and metadata views.
Data adapterThe storage backend used by the vault and directory watcher.

System Overview

Lapis Notes is a layered Svelte application that reproduces a large part of the Obsidian runtime model across shared web and desktop hosts. The current source tree is browser-first, and the approved product targets are a web/PWA host and an Electron desktop host. The key design choice is that the workspace app does not own most domain behavior; it mounts the renderer shell, creates the root App instance, and delegates to runtime services and plugins.

The decision to adopt Electron as the first-party native desktop shell is recorded in ADR-003: Electron-First Desktop Shell.

Layer Diagram

user input
  → host shell (web/PWA or Electron desktop)
     → @lapis-notes/workspace (renderer shell)
        → @lapis-notes/api (runtime kernel)
           → Vault, Workspace, Configuration, Commands, Metadata, Plugins
        → @lapis-notes/ui (design system)
        → packaged feature plugins (markdown, search, bases, ...)
        → community plugins from /.obsidian/plugins

The approved cross-runtime plugin direction is recorded in ADR-001: Plugin Runtime Rework (desktop runtime target superseded in part by ADR-003).

Layers

LayerPackageRole
Monorepo rootpackage.json, pnpm-workspace.yamlBuild orchestration. PNPM workspaces with pnpm 10.12.3.
Runtime kernelpackages/apiApp model, workspace tree, storage abstractions, plugin lifecycle, commands, settings, metadata cache, editor/view infrastructure, event system, and compatibility surface.
Design systempackages/uiReusable Svelte components wrapping Bits UI, paneforge, svelte-sonner, and TanStack Table. Tailwind-styled. No domain logic.
Application shellpackages/workspaceVisible renderer shell. Boots the web/PWA host directly today and remains the shared renderer layer for both web and desktop hosts.
Feature pluginspackages/plugins/*Markdown editing (including inline list callouts), SQLite/AppDatabase-backed full-text search, structured data views (Bases/PEaQL), grammar checking (Harper WASM).
Desktop shellpackages/desktop-electronFirst-party Electron desktop host. Owns app lifecycle, native window behaviour, IPC command handlers, and desktop-only runtime adapters.

Technology Stack

ConcernTechnology
UI frameworkSvelte 5 (runes: $state, $effect, $derived)
LanguageTypeScript
BuildVite
StylingTailwind CSS 4 with oklch color system
EditorCodeMirror 6
Package managerpnpm 10.12.3 (workspace protocol)
VCSJujutsu (colocated with Git)

Architectural Principles

  1. App owns services. The App class is the single root that constructs and holds the workspace, vault, plugin manager, commands, configuration, metadata cache, and all registries.

  2. Plugins are first-class. Built-in features and community plugins use the same Plugin base class, runtime states, and PluginManager. Core plugins are registered before discovery, then activated through the same manager as community plugins.

  3. File-first data flow. Storage updates propagate into metadata indexing, then into higher-level features like search and views. The vault is the source of truth.

  4. Event-driven. Almost all classes extend EventDispatcher (wrapping EventEmitter3). Changes propagate via typed events rather than direct mutation observers.

  5. Adapter-driven storage. OPFS is the default for new browser-local vaults. File System Access API covers user-selected real folders in web runtimes. Native hosts can add adapters without rewriting the runtime kernel.

  6. Thin shells. The workspace app is composition code. Host shells stage the runtime, render the layout, and delegate domain behavior to the API layer and plugins.

  7. Obsidian compatibility. The API surface intentionally mirrors Obsidian’s plugin API. compat.ts, parity tests, and the moment shim maintain upstream compatibility. Community plugins can require('obsidian') and get @lapis-notes/api.

Monorepo Structure

The workspace is a pnpm monorepo. pnpm-workspace.yaml includes both packages/* and packages/plugins/*, so the nested plugin packages participate in local workspace resolution.

PathRoleNotes
package.jsonRoot orchestrationDefines top-level build, lint, test, and format commands.
packages/apiShared runtimeOwns the application model and most Obsidian-like primitives.
packages/uiShared design systemWraps Bits UI and Tailwind-based components for the rest of the repo.
packages/diffmergeDiff / merge UISvelte 5 two- and three-pane merge editors and line-diff helpers (vendored from mismerge); consumed as @lapis-notes/diffmerge. See Diffmerge.
packages/notebook/coreShared notebook contractsOwns notebook output protocol types and pure lapis output helpers extracted from the bundled notebook plugin.
packages/notebook/duckdbShared notebook DuckDB contractsOwns reusable DuckDB runtime contracts, query-result types, DuckDB type mapping, and pure query normalization helpers.
packages/notebook/duckdb-assetsShared notebook DuckDB assetsOwns browser/PWA DuckDB-Wasm and DuckDB worker asset URL resolution for notebook DuckDB runtimes.
packages/notebook/inputShared notebook input helpersOwns host-neutral Observable-style viewof transforms, DOM runtime loading, input node binding, and callback-driven view runtime helpers consumed by notebook runtimes.
packages/notebook/stdlibShared notebook builtin contractsOwns current Lapis builtin names, documented bare notebook globals, dependency-global validation helpers, and source-level runtime feature detection.
packages/notebook/workerShared notebook worker contractsOwns generic worker transport message contracts for notebook runtimes.
packages/webBrowser/PWA hostOwns the installable browser host, manifest/service worker generation, cross-origin-isolation headers, and first-load offline shell caching around the shared renderer.
packages/lapis.mdPublic docs siteOwns the static Astro + Starlight site for lapis.md: landing page, user help (/help/), and developer docs (/developers/), styled with shared UI theme tokens.
packages/workspaceRenderer shellOwns the shared Svelte renderer, vault bootstrap, layout shell, and bundled-plugin composition that host packages mount.
packages/plugins/plugin-markdownFirst-party feature pluginRegisters markdown, media, properties, and outline views plus editor behavior.
packages/plugins/plugin-graphFirst-party feature pluginAdds global and local graph views driven by the metadata cache and a D3 force layout.
packages/plugins/plugin-searchFirst-party feature pluginMaintains the search view and routes indexing/query work through AppDatabase and the per-vault SQLite/IndexedDB search state.
packages/plugins/plugin-basesFirst-party feature pluginAdds .bases and .base views and structured data presentation.
packages/plugins/plugin-spellcheckerObsidian-targeted pluginHarper-based grammar and spell checking plugin for Obsidian.
packages/desktop-electronFirst-party desktop hostElectron desktop shell that mounts @lapis-notes/workspace. First-party native target per ADR-003.
docsMiscellaneous documentationCurrently separate from the architecture spec.
patchespatch-package overridesHolds dependency patches such as decode-named-character-reference.

Important split in the repo

There are two plugin ecosystems present at once:

  1. first-party plugins built against @lapis-notes/api
  2. colocated plugins that still target the upstream obsidian package directly

That split is important because only the first group participates in the current workspace app boot sequence.

The approved host targets are web/PWA and Electron desktop. The source tree represents the shared browser renderer, the installable web/PWA host, and the first-party Electron desktop shell. See Desktop Host, Desktop Electron Package, and ADR-003: Electron-First Desktop Shell for the authoritative desktop-host direction.

Licensing Model

The repository is multi-licensed. The root LICENSE.md summarizes the current package categories, and each workspace package declares its SPDX license in package.json.

AreaLicense
Core app packages, host packages, shared UI, notebook support, language service, and bundled first-party pluginsAGPL-3.0-or-later
Plugin SDK/API package (packages/api)Apache-2.0
Public documentation and website package (packages/lapis.md)CC-BY-4.0
Vendored diff/merge package and Obsidian-targeted compatibility plugin packages where declaredMIT
Lapis Notes name, logos, icons, domains, and related marksTrademark rights reserved

Third-party dependencies and vendored files keep their own notices. File-level notices take precedence for the files they cover.

Runtime Boot Sequence

The current source-visible browser/PWA boot path starts in packages/web/src/main.ts and progresses through host registration, vault selection, app construction, and sequential service initialization for the shared renderer shell. packages/workspace/src/main.ts remains a thin dev-shell entry that calls the same shared bootstrap helper.

Phase 1 — Host Entry Point (packages/web/src/main.ts)

  1. Calls mountWorkspaceApp() from packages/workspace/src/lib/components/app/bootstrap.ts to mount VaultBootstrap.svelte to DOM element #app.
  2. Loads the shared renderer imports through that bootstrap helper: web fonts, app CSS, bundled core plugins, and buffered bootstrap timing measurements. External official plugins are not imported by the default workspace bundle.
  3. Registers browser/PWA host behavior by calling registerWebPwa(), which wires service-worker registration, install-prompt handling, and simple host-level installed/offline-ready state markers.

At build time, packages/web/vite.config.ts also adds the web app manifest, icons, cross-origin-isolation headers, and a generated service worker that precaches the built asset graph for offline startup. The host raises Workbox’s per-file precache limit to 50 MiB so large browser runtime bundles such as SQLite, DuckDB, PDFium, and notebook assets stay inside the offline shell instead of silently dropping out of precache.

Notebook is an external official plugin in this phase. The default workspace bundle does not import notebook code; .notebook.md files route through the official on-demand install flow or an already installed lapis-notebook handler.

The approved hybrid-search direction follows the same rule: the bundled search plugin may register views and commands during boot, but SQLite vector search, local embedding model initialization, and background indexing remain behind the app-database worker and must not make renderer startup pay the cold model-load cost.

Phase 2 — Vault Bootstrap (VaultBootstrap.svelte)

A state machine with six phases:

PhaseAction
loadingAttempts to restore a previous vault profile from CurrentVaultProfile in browser storage.
chooseUser selects vault type: OPFS (new browser-local), File System Access (real folder), or Legacy (Lightning FS).
permissionFor File System Access vaults, requests readwrite permission on the directory handle.
blockedFallback state used only when a competing tab owns the per-vault SQLite database and delegation is unavailable.
readyVault session resolved. Transitions to App.svelte.
errorDisplays error with recovery options.

The vault adapter and app database resolution step is deliberately outside App construction. The File System Access API requires a transient user activation (gesture) to pick or re-authorize a folder, so the shell must present that choice before the runtime starts. Browser bootstrap wraps the result in a VaultSession containing { runtime, profile, vaultAdapter, appDatabase }.

The built-in Open vault command returns to this chooser by clearing the stored current vault profile and reloading the browser shell.

Vault bootstrap also records timing measurements for profile restore, profile opening, and createVaultSession() so delays that happen before App.svelte mounts show up alongside the later task-runner spans.

For browser/PWA sessions, createVaultSession() prefers a SQLite WASM app database persisted under OPFS only when the browser exposes OPFS and the host is cross-origin isolated (SharedArrayBuffer + Atomics available under COOP/COEP). The browser shell now coordinates SQLite ownership per vault before it mounts App.svelte: it takes a Web Lock named from the vault id and resolves whether the tab will be an owner, proxy, or blocked session, but it defers the expensive appDatabase.open() work until the mounted shell begins background metadata hydration. Competing tabs no longer silently switch to a second generated-state store. Instead, when BroadcastChannel is available, the secondary tab receives a coordinated AppDatabase proxy that forwards requests to the owner tab over a per-vault RPC channel while it waits to take ownership later. The blocked shell state remains as a narrow fallback only when delegation is unavailable. IndexedDB fallback remains the compatibility path when Web Locks are unavailable, when OPFS / cross-origin-isolation prerequisites are missing, or when the eventual owner still fails SQLite WASM / OPFS startup and falls back during open(). The browser SQLite runtime is hosted behind a dedicated worker, so App.svelte and the rest of the UI talk to the app database through an async message boundary instead of executing SQLite directly on the UI thread. A future desktop host may short-circuit parts of this UI, but it must preserve the same phase ordering once control passes into App.svelte.

Phase 3 — App Construction (App.svelte)

  1. setApplicationState(app) constructs the root App instance from @lapis-notes/api and injects it into Svelte context. New boot paths pass a VaultSession; compatibility paths may still pass only a DataAdapter.
  2. Registers shared configuration schemas (appearance, editor).
  3. Registers the default "empty" view type.
  4. Installs a browser telemetry controller on app.telemetry, including OpenTelemetry span creation plus web-vitals and longtask observers when enabled. The controller keeps a stable app.telemetry reference while allowing the external official lapis-telemetry plugin, when installed and enabled, to override and persist its configuration through the Core plugins settings flow after activation, and it can optionally restore the diagnostics buffer from vault-scoped IndexedDB before the diagnostics view opens.
  5. Sets up watchers: color scheme, vault events, metadata tracking.

Phase 4 — Sequential Task Runner

A TaskRunner executes startup tasks in strict order:

StepTaskService
1Load file systemapp.vault.load() — initializes file tree from adapter
2Load configurationapp.configuration.load() — restores persisted settings and canonical plugin compatibility data from app.json
3Register core pluginsRegisters bundled plugin constructors with PluginManager
4Load pluginsapp.plugins.registerDependencies(deps) then app.plugins.loadPlugins() to read installed provenance, discover external plugins, and activate bundled, configured official, and configured community plugins. Session Safe Mode can suppress optional core/official activation separately from community activation
5Load layoutapp.workspace.loadLayout() — restores split/tab/leaf tree unless session Safe Mode explicitly skips layout restore

During plugin registration and later runtime configuration updates, the plugin manager also mirrors canonical app.json pluginData entries back into the legacy Obsidian plugin settings files so the compatibility layout stays current even when app.json changes externally.

Each step displays a progress message via the TaskRunner.message reactive property. The loading screen also renders the ordered task list with the active step highlighted so startup no longer appears as a single generic spinner label.

If a boot task throws, App.svelte now switches from the loading spinner to an explicit startup-failure surface that shows the failing step, the error detail, and recovery actions such as reload, session-scoped Safe Mode entry, generated-state rebuilds, and targeted plugin disable. The layout step also carries an explicit timeout (currently 20 seconds) so a stalled restore no longer looks like a perpetual Loading layout message.

Safe Mode is intentionally session-scoped. The recovery surface writes a temporary policy record into browser session storage, reloads, and lets the next boot skip specific risky startup work such as configured community-plugin activation, layout restore, or notebook execution. Once the user restarts normally, those temporary flags are cleared instead of becoming persistent vault configuration.

Bundled plugin activation is still synchronous at this phase, but heavyweight plugin internals may defer their own execution transports until first use. External official plugins such as Notebook, Canvas, Graph, Markdown Lint, PDF, Slides, and Telemetry activate only after installation and explicit enablement.

The shell wraps the overall task runner in app.boot and each startup step in app.boot.task spans so regressions can be attributed to a specific initialization phase. Slow spans automatically enrich themselves with workspace and plugin snapshot attributes before export or debug logging.

When App.svelte attaches telemetry, it first flushes the earlier bootstrap measurements from the shared app bootstrap helper and VaultBootstrap.svelte, then starts the existing app.boot and app.boot.task spans for the sequential runner itself.

Phase 5 — Workspace Rendering

After the task runner completes:

  1. Keyboard event handler installed: forwards keydown to active leaf’s scope, records app.lastEvent.
  2. Console logger registered only when window.__LAPIS_CONSOLE_LOGGING__ or localStorage["lapis.logging.console"] explicitly enables it.
  3. Schedules background metadata hydration on the next animation frame after the shell mounts. app.metadataCache.load() opens AppDatabase, restores the cached metadata snapshot from the app database when available, falls back to the legacy browser snapshot when needed, and degrades to an empty in-memory cache when delegated proxy reads time out.
  4. Renders the visible workspace:
    • Sidebar.Root — Three-column layout (ribbon + left dock, center split, right dock).
    • Commands — Global command palette (Cmd+P / Ctrl+P).
    • StatusBar — Bottom status bar (plugins inject content here). When the session is using delegated SQLite ownership, the shell also renders a built-in icon-only DB Proxy button that opens a short explanatory popover on click, and removes it once the coordinated database promotes that tab to local SQLite ownership.
    • Tooltip — Global tooltip handler observing data-tooltip attributes via MutationObserver (500 ms default delay).
    • ModeWatcher — Dark/light theme synchronization.
    • Toaster — Toast notification container.
  5. Shell teardown disposes the telemetry service so browser performance observers do not linger across app unmounts.

Ordering Implications

  • Shell before metadata: Layout and plugins can exist before metadata is fully ready. Metadata-driven features must tolerate boot-time lag and respond to the later metadataCache.loaded event.
  • Plugins before layout: Bundled plugins and configured external plugins complete activation before layout restoration. This ensures restored leaves can find their view constructors when the owning plugin is installed and enabled.
  • Lazy app-database open: The app database opens on the first post-mount generated-state consumer, normally background metadata hydration. It still stores generated per-vault metadata and search state separately from canonical files.
  • Vault before canonical config/plugins/layout: The file tree must be loaded before configuration (stored in .obsidian/), plugins (loaded from .obsidian/plugins/), and layout.
  • Watchers early: Color scheme, vault, and metadata watchers are set up during construction, before the task runner starts, so they catch events from the first task onward.

Plugin Dependency Injection

Before PluginManager activates community plugins, the workspace registers a dependency bag (deps.ts) so dynamically loaded plugin code can require():

  • obsidian@lapis-notes/api
  • @codemirror/* → CodeMirror packages
  • luxon, zod, clsx, bits-ui

This bridges the local runtime with community-style plugin code that was written for the Obsidian API.

Approved Host Direction

The accepted target architecture adds host-level setup around this sequence, but it does not change the ordering guarantees above. See ADR-001: Plugin Runtime Rework and ADR-003: Electron-First Desktop Shell.

  1. Resolve the active runtime (web-pwa or electron-desktop) after vault bootstrap and carry it in VaultSession.
  2. Construct any host-specific plugin execution registry before community plugin activation.
  3. Preserve the invariant that community plugin activation completes before layout restoration.

The current implementation has the storage/session seam needed for host-specific setup, but does not yet provide the plugin host abstraction. All plugin evaluation still happens inside the active renderer/runtime process.

Desktop Shell

Lapis Notes supports two host shells:

  • a web/PWA host built directly from @lapis-notes/workspace
  • an Electron desktop host that wraps the same renderer/runtime packages

The decision to adopt Electron as the first-party native desktop shell is recorded in ADR-003: Electron-First Desktop Shell. The approved target architecture for the plugin boundary across those hosts is recorded in ADR-001: Plugin Runtime Rework (desktop runtime target superseded in part by ADR-003) and refined in Community Plugin Host Boundary.

Current state in the repo

  • packages/workspace is the shared renderer shell.
  • packages/web is the installable browser/PWA host around that shared shell.
  • packages/desktop-electron is the first-party Electron desktop host package. It owns Electron app lifecycle, native window behaviour, IPC command handlers, and desktop-only runtime adapters. That host-owned window policy applies to secondary app windows as well as the initial window, so Electron child windows opened through the shared workspace popout path inherit the same shell chrome, preload, and shell-metric configuration as the main window. Those popouts do not boot a second shared runtime; instead, the main app-host window remains the single renderer runtime and focused popout windows route native menu actions and inbound app URLs back through that owner window.

Approved boundary

The accepted shell split is:

  1. packages/workspace remains the shared renderer shell across all runtime targets.
  2. The Electron desktop shell owns native runtime metadata, native capability bridging, and the future community-plugin host boundary via the Node.js main process.
  3. Core domain behavior stays in packages/api, packages/workspace, or first-party plugins unless a concern is truly host-specific.

Plugin host implications

Today, community plugins still execute inside the active renderer/runtime process. The approved direction does not change the on-disk plugin format, but it does move shell-specific isolation and native capability brokering into the host layer:

  • web/PWA keeps a constrained renderer-hosted community-plugin path
  • Electron desktop gains the stronger host boundary for community plugins via Node.js worker/child-process capabilities

Those runtime-specific behaviors are split into implementation slices on the owning package and contract pages, with Plugin Runtime items summarized in Plugin Runtime.

Shell reference shape

The Electron desktop shell follows a standard Electron + Vite + Svelte pattern:

  • a renderer package with a fixed Vite dev port (1421) and static build output
  • an Electron main process (src-electron/main.ts) that creates BrowserWindow, registers IPC handlers, and drives native menus
  • a context-isolated preload script (src-electron/preload.ts) that exposes the native bridge to the renderer via contextBridge.exposeInMainWorld
  • the renderer entry (src/main.ts) reads the preload bridge and calls setNativeDesktopBridge() before calling mountWorkspaceApp()

Lapis borrows this shape while keeping the shared @lapis-notes/workspace renderer bootstrap in place.

API Package (@lapis-notes/api)

The API package is the foundational library that every other package depends on. It defines the runtime services, data structures, view hierarchy, plugin contract, and event system that together form the application kernel.

Source: packages/api/src/lib/

It also owns the shared internal-link formatting helpers that metadata and file-management surfaces use to serialize Obsidian-style vault links from a target path plus source note context, and FileManager routes rename-time backlink rewrites through those same helpers so link updates follow the configured file-link formatting rules for markdown notes and other vault files. MetadataCache resolves those short link paths back to markdown notes and other vault files, including bare filenames and unique suffix paths for embeds.

FileManager.processFrontMatter() also preserves unrelated keys when callers mutate frontmatter before metadata indexing has populated a cache entry by seeding writes from parsed file content instead of rewriting from an empty cache object.

The package also maintains a curated docs-only export surface at packages/api/src/lib/docs-api.ts for the public lapis.md TypeScript API reference. Public TSDoc on that surface is the source of truth for generated summaries, parameter descriptions, and return descriptions published under /developers/api-reference/.

App — Central Service Container

The App class (in context.svelte.ts) owns every runtime service. It is the single root object that plugins, views, and the workspace shell receive.

Plugin Distribution Contracts

The API package exposes plugin-distribution primitives for the Full Registry V1 installer path. This module is the shared contract surface for registry metadata, .lapis-plugin bundles, signed release manifests, installed provenance state, canonical JSON, SHA-256 verification, Ed25519 signature verification, DEFLATE-capable bundle extraction, compatibility checks, safe plugin release paths, and reserved plugin-id policy.

The distribution layer treats official status as verified external provenance, not as plugin-controlled manifest content. Manifests loaded from .obsidian/plugins/<plugin-id>/manifest.json therefore remain community-style runtime inputs unless a verified installer records official provenance in .obsidian/installed-plugins.json.

The official registry client remains separate from PluginManager. It fetches signed index and detail metadata from a locked registry source, verifies inline Ed25519 signatures with embedded trusted keys, normalizes relative metadata URLs against the registry index URL, rejects reserved-id policy violations, and can serve a stale adapter-backed cache from .obsidian/plugin-registry-cache.json when network refresh fails. It also fetches and verifies the signed revoked.json policy document for official sources and caches revocation state beside catalog metadata.

Verified installation is also owned by the distribution module rather than the runtime plugin manager. The installer downloads one official .lapis-plugin bundle, verifies the catalog bundle SHA-256 and size, extracts release.signed.json, verifies the signed release manifest, checks every embedded file’s path, size, and SHA-256 hash, rejects unsigned extra bundled files and manifest ID/version mismatches, stages files under .obsidian/plugins/.installing/, swaps them into the Obsidian-compatible plugin folder, and records provenance in .obsidian/installed-plugins.json. Manual installBundle() uses the same verified official bundle path for a local .lapis-plugin file. Bundle entries may be stored or DEFLATE-compressed; the signed release manifest remains stored as the first archive entry. The default distribution manager selects bundleVerification: "auto" for web installs, which verifies bundle hash/size, signature, and signed files in a module worker when available and falls back to the main-thread verifier. Tests and non-web hosts can force bundleVerification: "main-thread". Progress events include verifying-bundle before signed-file verification, and extraction, verification, and staging report processed byte/file progress when available. Fresh registry installs and manual .lapis-plugin installs enable the plugin by default when a plugin manager is present; update paths pass the previous enabled state explicitly so disabled plugins stay disabled. Uninstall removes plugin code and the enabled community-plugin entry while preserving data.json by default. Install and update operations accept a cooperative AbortSignal and emit phase-based progress events. Bundle downloads report aggregate byte progress when the response stream or fallback body size is available, so callers can surface determinate status-bar progress without weakening signature, size, or hash verification.

The distribution layer also validates signed runtime metadata for official ESM-only external releases before staging. It compares signed release runtime metadata with packaged manifest.json Lapis entries, rejects official CommonJS entries and fallbackPath, checks module-format file extensions, scans entry sources for bare imports, and validates declared or scanned shared dependencies against the generated host-module catalogue. Official failures are hard install errors; manual and community compatibility paths can retain CommonJS/fallback metadata and surface the same diagnostics as warnings on installed provenance records.

PluginDistributionManager applies verified revocation policy to installed official records, lists compatible updates, surfaces incompatible latest releases with compatibility reasons, and treats revoked installed versions as a distinct state that can be disabled or updated to a compatible replacement. Verified updates reuse the same staged installer path as fresh installs and preserve the plugin’s enabled state when the runtime reports it as enabled. Update progress and cancellation use the same installer signal and progress event contract as fresh installs.

The package also owns the loader-side contracts used by the structured ESM/CommonJS transition. lapis-extension.ts accepts structured lapis.runtime.entries metadata beside the legacy runtime strings, plugin-runtime-entry.ts contains the shared selector for workspace, Electron renderer, and Electron sidecar entry choice, plugin-dependency-resolver.ts defines the shared dependency resolver seam, and plugin-dependency-scanner.ts provides conservative runtime diagnostics for bare require, import, dynamic import(), and re-export specifiers. Generated host-module registry metadata under src/lib/generated/ is consumed by diagnostics and distribution validation so the app does not maintain a second dependency allowlist.

plugin-asset-server.ts defines the plugin module URL shapes used by renderer ESM loading, including hash-bearing /__lapis/plugins/... routes for web/PWA and lapis-plugin://... URLs for Electron renderer, plus path normalization, MIME type selection, and installed-file size/hash verification helpers. The package does not serve those URLs itself; host-specific asset serving remains owned by the web and Electron packages. When a host provides PluginAssetServer, PluginManager uses a hybrid execution host that imports structured ESM entries from verified module URLs, keeps CommonJS and sidecar entries on their existing hosts, and falls back to CommonJS only when the ESM descriptor declares fallbackPath. Community plugin diagnostics include the selected host and module format, fallback entry and fallback-used status, reload-on-update policy, asset URL mode and URL, declared/scanned shared dependencies, undeclared and missing shared dependencies, plus deprecated and private host-module usage.

Properties

PropertyTypePurpose
i18nLocalizationManagerInternationalization
sessionVaultSession | undefinedRuntime/vault/database session resolved by the shell
workspaceWorkspaceLayout, leaves, view registry
vaultVaultFile system access
appDatabaseAppDatabasePer-vault generated metadata/search/app state
pluginsPluginManagerPlugin lifecycle
pluginDistributionPluginDistributionManagerVerified plugin registry, install, update, uninstall, and provenance state
internalPluginscompatibility facadeEnabled bundled-plugin lookup for Obsidian-style plugin compatibility
scopeScopeRoot keyboard scope
keymapKeymapScope stack manager
settingsAppSettingsSettings groups container
editorsMap<string, Editor>Open editors by ID
editorSuggestMap<string, EditorSuggestManager>Autocomplete managers
configurationConfigurationSchemaJSON-schema config system
fileManagerFileManagerFile operation helpers

Browser vault sessions now resolve coordinated SQLite ownership before App construction, but defer the actual appDatabase.open() work until the mounted workspace shell begins background metadata hydration so the coordinated database boundary retains SQLite-to-fallback handling without blocking first paint. Desktop sessions use the same VaultSession boundary to resolve a native folder adapter plus a native app database bridge, keeping generated state in host-owned native SQLite storage outside the selected vault while preserving the same AppDatabase contract that browser runtimes use. Saved VaultProfile records may also carry optional demo metadata (source, fixtureVersion) so renderer flows such as the bundled demo workspace can remain identifiable across chooser restores and reset commands; browser OPFS recreation preserves that demo metadata when reopening an existing saved profile. The neutral native desktop bridge also carries host-advertised platform details and a service capability registry for resource, database, search, notebook, model, plugin-sidecar, file-watch, notifications, and file-system action providers. The vault contract exposes async resource URLs with explicit revocation so browser adapters can return blob URLs and native adapters can return host-owned URLs for read-only media surfaces. | markdownPostProcessor | MarkdownPostProcessor[] | Sorted post-processors | | markdownCodeBlockPostProcessor | Record<string, Function> | Language-keyed code processors | | metadataTypeManager | MetadataTypeManager | Frontmatter type tracking | | commands | CommandManager | Command registry and palette | | contextKeys | ContextKeyService | Declarative context-key store and when evaluator | | statusBar | StatusBarManager | Declarative status bar contribution registry with stable-order in-place updates | | urls | AppUrlService | Lapis/Obsidian-style app URL parser, opener, and plugin protocol-handler registry | | metadataCache | MetadataCache | File metadata index | | notifications | NotificationManager | In-app notification records and progress handles | | embedRegistry | EmbedRegistry | Embed view handlers | | lastEvent | Event | Last UI event | | renderContext | MarkdownFileInfo | Markdown render context | | secretStorage | SecretStorage | Encrypted key-value store | | languageServices | LanguageServiceManager | Host-neutral diagnostics, completion, hover, definition, and code-action providers |

Key Methods

StatusBarManager owns the API-side registry used by the workspace shell. Items can be registered declaratively, gated by when expressions against App.contextKeys, and updated in place through upsertItem() without losing their original registration order. That lets first-party contributors such as notifications change text, tooltip, command, icon, and visibility as background state changes while Plugin.addStatusBarItem() remains the raw-DOM compatibility escape hatch.

AppUrlService owns app URL parsing and dispatch. It accepts lapis://open?vault=...&file=..., the existing lapis-notes:// alias, installed-PWA web+lapis:// launches routed through the web host, Obsidian-style vault and absolute-path shorthands, and plugin-registered custom actions. The built-in open action validates that vault-targeted URLs refer to the current vault by id or name, resolves markdown paths with optional .md omission, opens non-markdown files through the normal workspace file-view routing, and keeps custom action handlers reversible through plugin unload.

  • openFile(file, opts?) — Opens a file in the workspace by creating or reusing a main-area leaf.
  • registerEditorExtension(ext, viewType?) — Adds a CodeMirror extension globally or scoped to a view type.
  • registerMarkdownPostProcessor(processor, sortOrder?) — Inserts a post-processor in sorted order.
  • registerMarkdownCodeBlockProcessor(language, handler, sortOrder?) — Registers a fenced-code-block renderer.

Notifications And Progress

App owns a host-neutral NotificationManager at app.notifications. The manager is the single runtime API for in-app messages, persisted notification history, and long-running progress. The required bundled notifications plugin owns the visible renderer surfaces; callers do not talk to Svelte, svelte-sonner, browser notification APIs, or desktop host APIs directly.

Public operations:

  • notify(options) records an information, warning, or error notification and optionally persists it.
  • createProgress(options) creates a progress handle for long-running work.
  • withProgress(options, task) creates a progress handle, passes a cooperative cancellation signal/token to the task, and completes or fails the handle when the task settles.
  • list(), markRead(id), clear(id), and clearAll() manage the current notification history.

Progress reporting accepts both determinate counters (current / total) and VS Code-style incremental reports (increment, where increments accumulate toward 100). If neither form is available the progress item is indeterminate. Progress handles carry title, source, message, location (status, notification, or silent), cancellability, terminal state, and an AbortSignal for cooperative cancellation. The manager never preempts synchronous plugin code; cancellation is delivered to work that checks the signal between natural async boundaries.

Notification history is generated per-vault app state. Durable notifications are persisted through AppDatabase, while live progress is session state unless the caller asks to persist terminal success or failure. Desktop hosts may mirror selected durable records to native OS notification APIs through the neutral native bridge; the shared app.notifications API remains renderer- and host-neutral.

Context Injection

The App instance is provided to Svelte components via setApplicationState(app) / useApplicationState() using Svelte context, avoiding prop-drilling.


Workspace — Layout and View Management

The Workspace class (in workspace.svelte.ts) manages the split-pane layout, tab groups, leaves, and the view type registry.

Layout Tree

Workspace
├── rootSplit: WorkspaceSplit   (center)
├── leftSplit: WorkspaceSplit   (left sidebar)
└── rightSplit: WorkspaceSplit  (right sidebar)
    └── children: (WorkspaceSplit | WorkspaceTabs)[]
        └── WorkspaceTabs
            └── children: (WorkspaceLeaf | WorkspaceSidebarGroup)[]
                ├── WorkspaceLeaf
                │   └── view: View
                └── WorkspaceSidebarGroup
                    └── children: WorkspaceLeaf[]
                        └── view: View

WorkspaceSplit

Represents a vertical or horizontal partition:

  • children: (WorkspaceSplit | WorkspaceTabs)[] — Nested splits or tab groups.
  • sizes: number[] — Proportional sizes for resizable panes.
  • type: "vertical" | "horizontal" — Split direction.
  • parent — Parent split reference.

WorkspaceTabs

A group of tabbed workspace children:

  • children: (WorkspaceLeaf | WorkspaceSidebarGroup)[] — Leaves in normal tab groups, and sidebar groups where the tab group belongs to a sidedock.
  • currentTab: number — Active tab index (reactive via $state).
  • stacked: boolean — Stacked vs. tabbed display mode.
  • addLeaf(leaf), removeLeaf(leaf) — Manage children.

WorkspaceSidebarGroup

A sidebar-only container that behaves like a VS Code-style view container while keeping each child as a real WorkspaceLeaf:

  • id, name, icon — Stable group identity and display metadata.
  • children: WorkspaceLeaf[] — Ordered child leaves mounted as collapsible panels by the workspace shell.
  • hiddenLeafIds — Persisted child visibility state; hidden leaves remain in the group.
  • collapsed — Persisted per-child collapsed panel state.
  • panelSizes — Optional persisted per-child panel proportions used by the workspace shell when grouped sidebar panels are resized.

WorkspaceLeaf

A single pane that hosts a view:

  • view: View | null — The currently mounted view.
  • containerEl: HTMLElement — DOM container.
  • id: string — Unique identifier.
  • openFile(file, opts?) — Opens a file using the view type registry. When no registered handler exists, it may mount the plugin-install-prompt fallback for verified official registry matches.
  • setViewState(state) — Switches or configures the view.
  • getViewState() — Serializes view state for layout persistence.

WorkspaceRibbon

Left-side icon strip. addRibbonIcon(icon, title, callback) adds items.

WorkspaceSidedock

Wraps a WorkspaceSplit with show/hide/collapse semantics for left and right sidebars.

View Type Registry

workspace.registerView(type: string, creator: (leaf) => View)
workspace.registerEditorView(contribution)
workspace.registerSidebarView(type: string, options)
workspace.registerExtensions(exts: string[], viewType: string)

Extensions (e.g., .md or .notebook.md) map to view types. registerEditorView() registers stable editor-view metadata used by settings and association resolution; registerExtensions() also backfills compatibility metadata for older registrations. When a file is opened, the workspace first checks workspace.editorAssociations for matching VS Code-style glob patterns, then falls back to registered editor-view filename patterns, and finally falls back to the longest matching registered suffix. The resolved view type is looked up in the constructor registry and mounted in the leaf.

registerSidebarView(type, options) records default sidebar placement metadata for non-file views. ensureSideLeaf(type, options) can use those defaults, or explicit options such as side, group, groupTitle, groupIcon, and hidden, to create the leaf in a normal sidebar tab or under a WorkspaceSidebarGroup. Plugin.registerSidebarView(type, creator, options) wraps view registration and placement cleanup for plugin-owned sidebar contributions.

Default file navigation targets the root split. workspace.getLeaf(false), workspace.openLinkText(...), and app.openFile(file) reuse the active main-area leaf or selected root tab, and create a root leaf when none exists. They do not reuse an active sidebar leaf; sidebar views are opened through explicit sidebar APIs such as ensureSideLeaf(), getLeftLeaf(), and getRightLeaf().

Layout Persistence

getLayout() → JSON, loadLayout(json), requestSaveLayout() (debounced). The JSON captures the full tree: splits, tabs, leaves, sidebar groups, hidden/collapsed/resized group panel state, and view states. Old flat sidebar tab JSON still loads without group entries, and grouped sidebar JSON without panelSizes falls back to equal panel proportions.

Key Workspace Methods

MethodPurpose
loadLayout(json)Restores layout from persisted JSON
changeLayout(layout)Applies a new layout tree
getLayout() / toJson()Serializes current layout
revealLeaf(leaf)Activates and scrolls to a leaf
registerView(type, creator)Registers a view type constructor
registerEditorView(contribution)Registers stable file-editor metadata for settings and association routing
registerSidebarView(type, options)Registers default sidedock placement for a view type
registerExtensions(exts, viewType)Maps file suffixes to view types
getLeaf(newLeaf?)Returns the active floating/popout leaf or root leaf for default file navigation, or creates explicit panes
determineViewType(ext)Resolves an extension or view type alias → view type
iterateRootLeaves(callback)Visits every leaf in the root split
updateOptions()Re-applies editor extensions to all editors
requestSaveLayout()Debounced layout persistence
moveWorkspaceChildToFloating()Moves a leaf/sidebar item into an in-app floating pane
moveWorkspaceChildToPopout()Moves a leaf/sidebar item into a host-backed popout window when supported
  • registerHoverLinkSource(id, info) stores hover-preview source registrations for plugin compatibility and supports unload cleanup through the plugin lifecycle helpers.
  • openPopoutLeaf() and moveLeafToPopout() now route through a host popout capability. Browser and Electron hosts open a separate Window/Document that still renders the shared WorkspaceWindow tree, while unsupported hosts raise an explicit error without detaching the source leaf. Drag-out layout gestures still use the separate in-app floating-pane path.
  • Floating WorkspaceWindow entries track normal, collapsed, minimized, and maximized display state. Collapsed and minimized state serialize with the live workspace layout; maximized state is renderer-only and restores as normal after reload.

View Hierarchy

Defined in view.svelte.ts. All views extend the Component base class for lifecycle management.

Component
└── View
    └── ItemView
        └── FileView
            ├── TextFileView
            │   └── MarkdownView
            └── EditableFileView

Component (base)

  • children: Component[] — Child lifecycle management.
  • containerEl: HTMLElement — Root DOM element.
  • loaded: boolean — Lifecycle state.
  • load() / unload() — Lifecycle hooks.
  • register(cleanup) — Registers teardown callbacks.
  • registerEvent(ref) — Auto-cleaned event subscriptions.
  • registerDomEvent(el, type, handler) — Auto-cleaned DOM listeners.
  • registerInterval(id) — Auto-cleared intervals.

View

Abstract base for all views:

  • icon: string — Lucide icon identifier.
  • leaf: WorkspaceLeaf — Owning leaf.
  • scope?: Scope — Keyboard scope (pushed when leaf is active).
  • abstract getViewType(): string
  • abstract getDisplayText(): string
  • getState() / setState(state) — Serialization for layout persistence.

ItemView

Adds a toolbar actions bar:

  • actions: Array<{icon, title, callback}> — Toolbar buttons.
  • contentEl: HTMLElement — Read-only content container below the toolbar.
  • addAction(icon, title, callback) — Registers a toolbar button.

FileView

Associates a view with a file:

  • file: TFile | null — The backing file.
  • abstract onLoadFile(file): Promise<void> — Called when a file is loaded.
  • abstract onUnloadFile(file): Promise<void> — Called when file is unloaded.
  • abstract onRename(file): Promise<void> — Called on file rename.
  • abstract canAcceptExtension(ext: string): boolean — Declares supported extensions.

TextFileView

Text editor base backed by CodeMirror 6:

  • editor: Editor — CodeMirror wrapper instance.
  • data: string — Current document content.
  • adapter: DataAdapter — Deprecated compatibility accessor for direct adapter access.
  • abstract getViewData(): string, setViewData(data, clear?), clear() — Subclass data hooks.
  • save(clear?) — Writes content through app.vault.modify().
  • onLoadFile(file) — Reads through app.vault.read(), then sets editor extensions.
  • onRename(file) — Updates file reference.

MarkdownView

Extends TextFileView with dual-mode rendering:

  • previewMode: MarkdownPreviewView — Preview renderer.
  • currentMode: MarkdownSubView — Source / preview / live-preview state.
  • hoverPopover: HoverPopover | null — Page hover preview.

EmptyView

Placeholder for new tabs. View type "empty". Mounts EmptyViewComponent with create/open/recent/close actions. Uses localized display text via t("base/empty-view", "New Tab"). When WorkspaceLeaf.setViewState() receives an unsupported view type, the workspace falls back to this view, preserves the original requested type in the view state under state.__missingViewType, and displays that identifier in the empty view title.


Plugin System

Plugin Base Class

Every plugin extends Plugin (which extends Component):

  • basePath: string — Plugin folder path.
  • manifest: PluginManifest — Parsed manifest.json.
  • enabled: boolean — Current state.
  • hostMode: string — Community plugin execution host used for diagnostics.
  • requestedCapabilities / grantedCapabilities — Runtime capability declarations and broker decisions surfaced for hosted plugin diagnostics.
  • lastFailureMessage / failureCount — In-memory diagnostic history across failed enable, disable, and restart attempts.
  • app: App — App instance.
  • abstract onload(): Promise<void> | void — Initialization hook.
  • onunload(): Promise<void> | void — Cleanup hook.
  • enable() / disable() — Toggle with "enable" / "disable" event emission.

Baseline Obsidian-style manifests remain valid. External manifests may additionally declare supportedRuntimes, requiredCapabilities, and executionHints; PluginManager treats missing declarations as compatible, but rejects explicit runtime or capability requirements before evaluation when the selected host cannot satisfy them.

External Plugin Diagnostics

PluginManager keeps external plugin diagnostics separately from loaded plugin instances so preflight and evaluation failures remain visible even when no Plugin object could be registered. Diagnostics include plugin ID/name, manifest summary metadata (version, author, and description), source (official or community), provenance, selected host mode, activation mode (code, manifest-only, or not-activated), the selected runtime entry when known, requested capabilities, granted capabilities, lifecycle/preflight state, and the last failure message. Indexed Lapis-extension metadata remains available alongside those diagnostics so the workspace UI can show permission declarations, granted app permissions, and trusted-desktop warnings without depending on a loaded Plugin instance. Runtime-entry preflight also validates that Lapis code entries stay within the plugin root and use supported JavaScript file extensions before evaluation is attempted. The manager exposes the current host ID, a full diagnostics list, per-plugin lookup, and restartPlugin(pluginId) so the workspace UI can retry failed plugins without first removing them from the external enablement configuration.

Registration API

Plugins register functionality during onload():

MethodPurpose
addCommand(cmd)Register command (auto-prefixed with plugin ID and name)
removeCommand(id)Unregister command
registerShortcut(handler)Register keyboard binding
registerView(type, creator)Register a view type constructor
registerSidebarView(type, creator, options)Register a view plus default sidedock placement
registerEditorExtension(ext, viewType?)CodeMirror extension (plain or factory)
registerMarkdownPostProcessor(proc, order?)HTML post-processing
registerMarkdownCodeBlockProcessor(lang, handler, order?)Fenced code blocks
registerEditorSuggest(suggest, viewType?)Autocomplete provider
registerExtensions(exts, viewType?)File extension → view mapping
registerTypeWidget(widget)Custom frontmatter editor widget
registerMetadataProcessor(processor, ext?)Custom metadata parser
registerHoverLinkSource(id, info)Hover preview source
registerObsidianProtocolHandler(action, handler)obsidian:// URI handler
registerCliHandler(name, prefix, callback)CLI commands
registerBasesView(viewId, registration)Contribute Bases views through the shared Bases registry
addRibbonIcon(icon, title, callback)Left ribbon button
addStatusBarItem()Status bar HTML element
addSettingTab(tab)Settings panel grouped by runtime source: bundled/official under “Core plugins”, community/manual under “Community plugins”
loadData() / saveData(data)Persistent plugin storage via canonical app.json pluginData, mirrored to legacy Obsidian plugin files

Suggest Helpers

AbstractInputSuggest, SuggestModal, and FuzzySuggestModal provide the shared Obsidian-style suggestion helpers used by plugins and editor-adjacent UI:

  • input/modal suggestion lists refresh from the current query string
  • arrow keys move the active item
  • Enter and Tab choose the active item
  • Escape closes the suggestion UI
  • instruction rows render when provided through setInstructions(...)

Plugin Manifest

interface PluginManifest {
  id: string;
  name: string;
  author: string;
  version: string; // semver
  minAppVersion: string; // semver
  description: string;
  dir?: string; // plugin folder in vault
  authorUrl?: string;
  isDesktopOnly?: boolean;
  supportedRuntimes?: string[];
  requiredCapabilities?: HostedPluginCapability[];
  executionHints?: Record<string, unknown>;
  lapis?: LapisExtensionManifest;
}

PluginManager

Owns the full plugin lifecycle. Extends EventDispatcher.

Properties:

  • plugins: Map<string, Plugin> — Loaded plugins keyed by ID.
  • lapisExtensions: LapisIndexedExtension[] — Manifest-indexed Lapis extensions keyed by discovery, including manifest-only extensions that did not instantiate plugin code.
  • dependencies: Record<string, any> — Module dependencies for require() injection.
  • pluginsPath: string"/.obsidian/plugins".
  • adapter: DataAdapter — File system.

Lifecycle:

  1. loadPlugins() — Ensures the plugin folder exists, reads installed provenance from .obsidian/installed-plugins.json, discovers external plugin manifests, reads community-plugins.json, then activates bundled plugins, configured official plugins, and configured community plugins before emitting "plugins-loaded".
  2. loadPlugin(path) — Loads a single external plugin. Parses manifest.json, resolves source/provenance from verified installed state or caller options, validates/classifies optional lapis metadata, indexes Lapis contributions before code evaluation, runs manifest preflight, and evaluates the selected runtime entry when an Obsidian-compatible or hybrid plugin needs code. Multi-file CommonJS plugin builds can use relative require() calls for local .js, .cjs, and .mjs chunks under the plugin folder. Structured ESM entries load through the host-provided plugin asset URL server when available. Manifest-only Lapis extensions may be indexed without a Plugin instance. Emits "plugin-loaded" only for instantiated plugins.
  3. enablePlugin(id) — For instantiated plugins, calls plugin.enable(), injects registered bundled core-plugin CSS or reads external styles.css if present, and appends a <style id="plugin-css-{id}"> element to <head>. For manifest-only Lapis extensions, installs declarative commands and configuration schemas without evaluating code and records diagnostics for indexed contribution kinds that are not installable yet. Saves enabled state to community-plugins.json for official/community external plugins or core-plugins.json for bundled/system plugins as appropriate. Emits "plugin-enabled" only for instantiated plugins.
  4. disablePlugin(id) — For instantiated plugins, calls plugin.disable() and removes the CSS element. For manifest-only Lapis extensions, disposes manager-installed command/configuration effects. Saves state. Emits "plugin-disabled" only for instantiated plugins.

Dynamic Import:

External plugin JavaScript evaluation runs through CommunityPluginExecutionHost. The default RendererCommunityPluginExecutionHost loads CommonJS chunks as strings, wraps each evaluated module in new Function('exports', 'module', 'require', ...), and executes it in the renderer. The injected renderer require() resolves relative local chunks from the same plugin folder and external modules from the registered dependency resolver. Unknown modules throw an error. When a host supplies PluginAssetServer, PluginManager installs a hybrid execution host: structured ESM entries are imported from version/hash plugin asset URLs, CommonJS entries continue through the CommonJS host, Electron sidecar entries stay delegated to the sidecar host, and ESM-to-CommonJS fallback is attempted only when the selected runtime entry declares fallbackPath. The manager records whether fallback was actually used, the asset URL mode and URL used for renderer ESM imports, and the selected runtime’s reload requirement so the settings UI can explain loader behavior after activation. The native sidecar bridge receives the same selected runtime metadata and wraps sidecar evaluation failures with host, format, and entry context while preserving the original dependency or local-graph diagnostic.

Hosted Plugin Capabilities:

HostedPluginCapabilityFacade defines the initial structured, serializable capability surface for sidecar-hosted community plugins: vault read/write, plugin data, commands, notices, settings reads/surfaces, metadata queries, approved event subscriptions, and logging. The facade forwards every call to a HostedPluginCapabilityBroker request instead of exposing the live renderer App graph, DOM objects, or Node.js primitives.

Events: plugins-loaded, plugin-loaded, plugin-enabled, plugin-disabled, plugin-error, css-change.

Current boundary: PluginManager is still the lifecycle owner. It performs manifest preflight, CSS loading, source/provenance classification, and failure reporting in the active renderer/runtime process. External plugin code evaluation is delegated to the configured execution host; the default renderer host still evaluates CommonJS modules in process.

Approved direction: ADR-001: Plugin Runtime Rework defines the runtime-neutral execution-host and extension-manifest direction. The current implementation now covers classification, Lapis namespace validation, contribution indexing, activation-event lazy loading, generated extension state, manifest-only command/configuration installation, scoped language-service provider registration, bundled or code-backed system-extension registration, and host-selected workspace or trusted-desktop code entry loading with per-plugin Electron-sidecar hardening. The remaining runtime work is limited to richer manifest-only coverage plus follow-on runtime expansion such as browserWorker and multi-host hybrid activation.


Storage — Vault, Session, and App Database

DataAdapter Interface

Compatibility contract for path-based file system operations:

interface DataAdapter {
  getName(): string;
  exists(path): Promise<boolean>;
  stat(path): Promise<Stat | null>; // {type, ctime, mtime, size?}
  read(path): Promise<string>;
  readBinary(path): Promise<ArrayBuffer>;
  write(path, data, opts?): Promise<void>;
  writeBinary(path, data, opts?): Promise<void>;
  append(path, data, opts?): Promise<void>;
  appendBinary(path, data, opts?): Promise<void>;
  process(path, fn: (data) => string, opts?): Promise<string>;
  list(path): Promise<{ files: string[]; folders: string[] }>;
  mkdir(path, opts?): Promise<void>;
  rmdir(path, recursive): Promise<void>;
  remove(path): Promise<void>;
  rename(path, newPath): Promise<void>;
  copy(path, newPath): Promise<void>;
  getResourcePath(path): string; // For <img src>
  trashSystem(path): Promise<boolean>; // OS trash
  trashLocal(path): Promise<void>; // .trash folder
}

VaultAdapter is the preferred storage contract for new runtime work. It extends DataAdapter today and can expose optional capabilities such as persistence, user-visible files, permission requirements, native watching, resource URLs, and system trash support. DataAdapter remains exported for Obsidian-style plugin compatibility and older call sites.

VaultSession

VaultSession groups the runtime-selected storage objects that App needs:

interface VaultSession {
  runtime: "web-pwa" | "electron-desktop" | "test";
  profile?: VaultProfile;
  vaultAdapter: VaultAdapter;
  appDatabase?: AppDatabase;
  appDatabaseState: {
    status: "ready" | "blocked";
    mode:
      | "sqlite-owner"
      | "sqlite-proxy"
      | "sqlite-blocked"
      | "indexeddb-fallback"
      | "memory-fallback";
    lockSupported: boolean;
    message?: string;
  };
  awaitAppDatabase?: (options?: {
    signal?: AbortSignal;
  }) => Promise<VaultSession>;
}

The browser workspace resolves this before constructing App. When a competing tab already owns the per-vault SQLite OPFS database, the preferred browser path now returns a ready session backed by a coordinated proxy AppDatabase that forwards requests to the owner tab over BroadcastChannel and can later promote itself to owner when the lock becomes available. The blocked state remains as a fallback only when delegation cannot be established. Adapter-only construction is still supported for compatibility and tests; in that path App creates a default app database from the adapter’s vault ID.

AppDatabase

AppDatabase stores per-vault generated state separately from canonical vault files. The contract covers schema migration, metadata snapshots, indexed file records, links, tags, properties, search document records, notebook output and scalar input snapshots with per-cell source fingerprints, explicit FTS rebuild support, cheap search-index stats, optional search-index batching for refresh-heavy backends, and snippet-rich search query access with raw lexical/vector scores plus explainable Reciprocal Rank Fusion breakdowns for hybrid retrieval.

MetadataCache.load() opens the app database and restores the app-database snapshot first. If that snapshot is missing, it reads the portable generated backup at .lapis/cache/metadata-cache.json, validates the versioned envelope, applies the snapshot, and hydrates AppDatabase with both the snapshot and per-file indexed metadata records. Legacy ScopedVaultStore metadata snapshots remain a final migration fallback. When no valid state exists, metadata loading runs the first-open rebuild and emits loaded only after that rebuild persists a fresh snapshot and backup.

Current implementations:

  • SqliteWasmAppDatabase — SQLite WASM using OPFS persistence for browser/PWA sessions when available. The public class proxies all SQLite execution through a dedicated worker while mirroring app state in the main thread, and search-document upserts now sync the worker-prepared document back into that mirror instead of re-running search preparation on the renderer.
  • IndexedDbAppDatabase — browser fallback backed by ScopedVaultStore(vaultId, "app-database").
  • MemoryAppDatabase — tests and runtimes without IndexedDB.
  • NativeDesktopAppDatabase — native SQLite generated-state contract for the Electron desktop host.

In browser bundles, the SQLite WASM implementation resolves sqlite3.wasm through the package’s exported asset URL so bundlers serve the binary instead of falling through to an HTML route.

The SQLite schema includes schema_meta, files, metadata, links, tags, properties, search_docs, and search_fts. SQLite is not canonical note storage; deleting the app database should only require rebuilding metadata and search from files exposed by the vault adapter. Search queries return document hits plus explicit snippet/range metadata and score explanations so UI layers do not need a separate tokenization/highlight engine. The search contract now also exposes incremental search-index stats plus explicit beginSearchIndexingBatch() / endSearchIndexingBatch() hooks so refresh-heavy backends can batch expensive persistence work. In browser/PWA sessions, SQLite ownership is coordinated per vault with Web Locks and BroadcastChannel heartbeats; only the owner tab starts the SQLite worker, while secondary tabs proxy AppDatabase requests to that owner when delegation is available and fall back to the blocked state only when delegation cannot be established. The shared Transformers.js embedding provider keeps browser-managed Cache API caching in browser/PWA runtimes, but disables that cache path inside native desktop renderers so corrupted persisted Chromium cache state cannot wedge semantic-search initialization.

Browser Adapters

  • OpfsVaultAdapter — Origin Private File System. Works offline, no permission prompts. Default for new browser-local vaults.
  • BrowserHandleVaultAdapter — File System Access API. Opens user-selected real folders via picker. Requires transient user activation.
  • OPFS and File System Access handle-backed browser adapters retry recoverable NotFoundError, NotReadableError, NoModificationAllowedError, InvalidStateError, and ENOENT failures on reads, writes, deletes, and directory listings, and normal writes truncate existing files before replacing their contents.
  • getAdapterVaultId(adapter) — Returns a stable identifier for scoped storage.
  • ScopedVaultStore(vaultId, scope) — Scoped browser storage still used for vault profiles, legacy metadata fallback, and the IndexedDB app-database fallback.

File Model

TAbstractFile { path, parent, vault, baseName, extension, name }
├── TFile { stat: FileStats {ctime, mtime, size} }
└── TFolder { children: TAbstractFile[], iterateAll(cb) }

TAbstractFile.copy(props) creates a clone with property overrides.

Vault Class

Extends EventDispatcher. Owns the file tree:

  • adapter: DataAdapter — Active backend.
  • files: Record<string, TAbstractFile> — In-memory file tree cache.
  • cache: FileCache — LRU in-memory cache.
  • configDir: string".obsidian".

File Operations: create, createBinary, createFolder, mkpath (recursive), modify, modifyBinary, process (read-modify-write), delete, trash, rename, copy.

Tree Operations: load, loadPath, reload, getRoot, getAbstractFileByPath, getFileByPath, exists, stat, list.

Events: load, create, modify, delete, rename, all (any event with type discriminator).

DirectoryWatcher

Polls or observes the adapter for changes: watch(path, opts){close()}. Events: create, modify, delete, error, all.

Path Utilities

normalizePath, joinPath, splitPath, dirname, basename — all browser-safe, no Node.js dependency.


MetadataCache — File Indexing

The MetadataCache (in cache.svelte.ts) indexes every file’s structural content.

CachedMetadata

interface CachedMetadata {
  frontmatter?: FrontMatterCache;
  frontmatterPosition?: Pos;
  links?: LinkCache[]; // [[wikilinks]]
  embeds?: EmbedCache[]; // ![[embeds]]
  tags?: TagCache[]; // #tags
  headings?: HeadingCache[]; // Headings with level (1-6)
  sections?: SectionCache[]; // Block-level sections
  listItems?: ListItemCache[]; // List items with optional task state
  footnotes?: FootnoteCache[]; // [^ref] footnotes
  blocks?: Record<string, BlockCache>; // ^block-ids
}

Every cache item carries a Pos { start: Loc, end: Loc } where Loc = { line, col, offset }.

Cache Items

TypeFields
HeadingCacheheading: string, level: 1-6
LinkCachelink, original, displayText?
TagCachetag
FootnoteCacheid
BlockCacheid
ListItemCacheid?, task?, parent: number
SectionCacheid?, type
  • getFirstLinkpathDest(linkpath, sourcePath) — Resolves [[link]] to a TFile. Tries exact path, .md suffix, .markdown suffix, then basename match.
  • resolvedLinks and unresolvedLinks — Adjacency maps: Record<string, Record<string, number>>.
  • resolveSubpath(cache, subpath) — Resolves #heading, ^block, [^footnote] references within a file.

Metadata Processors

Plugins register custom MetadataProcessor objects per file extension:

interface MetadataProcessor {
  read(data: string, ctx: { cache; file }): Promise<CachedMetadata>;
  write(cache: CachedMetadata): string;
}

Persistence

Caches are persisted through app.appDatabase keyed by vault ID. File tracking by {mtime, size, hash} is preserved, and parsed metadata is also decomposed into app-database file, link, tag, and property records. Persistence remains debounced at 500 ms. A legacy ScopedVaultStore cache can be read as a migration fallback when no app-database snapshot exists.

Events: changed(file, data, cache), deleted(file, prevCache), loaded.


MetadataTypeManager

Tracks frontmatter property types across all files:

  • types: Record<string, MetadataTypeDef> — Persisted to types.json in config folder.
  • properties: Record<string, MetadataTypeProperty> — Runtime index with { name, type, count, files: Set<string> }.
  • registeredTypeWidgets: Record<string, TypeWidget> — Plugin-provided custom renderers.

Type Inference: Number, Boolean, Tags, Aliases, Date (YYYY-MM-DD), DateTime (ISO 8601), Multitext (primitive arrays), Array, Object, Text (fallback).

Methods: load(), save() (debounced), setType(field, type), registerTypeWidget(widget), getAllProperties(), getValues(key), processChange(file, cache), processDelete(file), trackChanges(), plus exact top-level bulk rename, delete, and type-change helpers used by All Properties. Exact-key bulk helpers preserve literal dotted keys such as note.status instead of treating them as lodash paths.


Editor — CodeMirror 6 Integration

The Editor class (in editor.svelte.ts) wraps a CodeMirror 6 EditorView:

  • view: EditorView — CM6 instance.
  • file: TFile | null — Backing file.
  • extensions: Extension[] — Active extensions.
  • id: string — Unique ID (UUID).
  • getValue() / setValue(content) — Full document access.
  • replaceContent(content) — Efficient partial replace via dispatch.
  • getLine(line) — Gets line text.
  • somethingSelected() — Has selection?
  • save() — Writes to vault (debounced 500 ms).
  • refresh() — Requests CM6 measure/layout.
  • updateExtensions(extensions?, context?) — Reconfigures CM6 extensions.
  • trackChanges(callback?) — Syncs external file changes to the editor; returns unsubscribe.

Events: change(data: string).

The shared NoteEditor renders its inline file title inside the editor scroll area immediately before the .cm-editor-content editor host. Desktop visibility follows appearence.interface.showInlineTitle, while mobile workspace display mode forces the same in-scroll title on so file-backed mobile titles scroll with the document instead of separate shell chrome.

The shared class-based syntax highlighter used by API-owned editors emits the existing CodeMirror cm-* token classes together with compatible hljs-* token classes for tags that map cleanly onto the bundled highlight.js theme vocabulary. This lets non-markdown editors reuse highlight.js token styling without dropping the existing CodeMirror selectors.

CM6 State Fields

State fields make app context available inside extensions:

  • editorViewField: StateField<MarkdownFileInfo>{ app, file, editor }.
  • editorEditorField: StateField<EditorView> — Editor instance.
  • editorLivePreviewField: StateField<boolean> — Live preview mode flag.

Language Services

App.languageServices owns provider registration and serializable document routing for editor-adjacent language features. Providers implement the shared LanguageServiceProvider contract with metadata (id, languages, runtime, priority, and capabilities) plus optional diagnostics, completion, hover, definition, code-action, and dispose hooks. Consumers pass VirtualDocument payloads containing only URI, language id, version, and text, plus serializable positions/ranges and app-contributed global declaration snapshots.

For a given { languageId, capability } pair providers are consulted in descending priority; diagnostics and code-action helpers merge outputs only from providers that share the maximal priority among registered matches so a host-native Electron sidecar (priority > 0) excludes duplicate diagnostics from bundled worker backends without unregistering them. Completions stop at the earliest provider that yields items; hover behaves similarly while definitions bail out once a provider responds with locations.

The API package also exports CodeMirror adapters under @lapis-notes/api/editor/language-service:

  • diagnostics through async lint integration via lapisCodeMirrorLint() and enriched mapToLapisLintDiagnostic() output (Svelte lapis-lint-tooltip with markdownlint rule links, copy, View Problem, language-service code actions), plus optional lintGutter()
  • completions through @codemirror/autocomplete, with shared defaults and tooltip theming alignment via lapisCodeMirrorAutocomplete() (root export @lapis-notes/api and extensions/autocomplete) composed with @lapis-notes/ui/codemirror-autocomplete.css
  • lint tooltip and inline-problem chrome through workspace-loaded CodeMirror CSS (packages/workspace/src/lib/styles/codemirror-lint.css); inner hover layout lives in the Svelte component under extensions/lint/
  • vault-driven completion helpers (vaultFilesMatchingPathSubstring, media filters, isImageEmbedLinkPath) for wiki-link sources in first-party plugins
  • hover through hoverTooltip
  • languageServiceDocumentContext, a Facet for non-file-backed editors such as notebook cell editors that need to resolve a virtual document and map positions/ranges

Adapters only call app.languageServices; markdownlint, TypeScript, future Electron-native providers, and LSP transports stay behind the provider contract.


Supporting Systems

Internationalization

LocalizationManager wraps i18next:

  • t(namespace, key, params?) / t(key, params?) — Translate with flexible signature.
  • plural(count, ns, key, params?) — Plural forms.
  • setLocale(locale) — Switch language.
  • addResourceBundle(ns, locale, translations, deep?, overwrite?) — Load translations.
  • currentLocale, availableLocales — Reactive state.

Search Utilities

  • prepareFuzzySearch(query)(text) => SearchResult | null — Tries simple match first, falls back to fuzzy.
  • prepareSimpleSearch(query) — Substring only.
  • SearchResult: { score: number, matches: [start, end][] }.
  • renderMatches(el, text, matches) / renderResults(el, text, result) — Highlights match ranges in DOM.
  • useTextHighlight(el, { query, value }) — Svelte action that highlights literal query text with text nodes and suggestion-highlight spans without assigning raw HTML.
  • sortSearchResults(results) — Sorts by score descending.
  • SuggestModal<T>, FuzzySuggestModal<T> — Modal bases for fuzzy search UIs.

History

HistoryManager<T> — Navigation stack:

  • pushState(state) / replaceState(state) / updateState(partial).
  • back() / forward() / go(steps).
  • hasForward / hasBackward — Computed navigation availability.
  • maxSize: number — Default 20.

Menu with MenuItem children:

  • addItem(callback), addSeparator(section?), setTitle(title), setSection(section).
  • showAtPosition({x, y, width?, overlap?, left?}), popover(), dropdown().
  • setOpen(open), hide(), and close() synchronize renderer menu state and run onHide when an open menu transitions closed.
  • search(value) — Filter items.
  • MenuItem supports: setTitle, setIcon, setChecked, setDisabled, setWarning, setSection, onClick.
  • Shared menu rendering trims separators that would otherwise land at the start or end of the rendered menu after dynamic construction or filtering.
  • Shared context, dropdown, and popover renderers use the same drawer-backed presentation on mobile-width viewports while preserving desktop popup behavior.

Popover

HoverPopover (extends Component) with lifecycle states: Hidden → Showing → Shown → Hiding.

DOM Enhancements

enhance.ts patches Node and HTMLElement prototypes:

  • Node: detach, empty, insertAfter, appendText, createEl, createDiv, createSpan, createSvg, doc, win.
  • Element: find, findAll, getText, setText, addClass, removeClass, toggleClass, hasClass, setAttr, getAttr, matchParent, getCssPropertyValue.
  • HTMLElement: show, hide, toggle, toggleVisibility, on (delegated events), off, onClickEvent.

Embed Registry

Maps file extensions to embed view constructors:

class EmbedRegistry {
  embedByExtension: Record<string, EmbedView>;
  register(extension: string, view: EmbedView): () => void;
  get(extension: string): EmbedView | null;
}
type EmbedView = (props: {
  app;
  containerEl;
  state;
}) => void | { destroy?: () => void | Promise<void> };

Bundled and community plugins use this registry to supply plugin-owned inline renderers for markdown file embeds without widening the shared HTML sanitization rules. The markdown package looks up a renderer by file extension for non-image embeds and, when one exists, mounts it into the embed container and runs the returned destroy handle on teardown.

Icon System

  • registerIconPacks(loaders: IconLoader[]) — Registers icon sets.
  • getSvg(iconName, customisations?) — Returns SVG element. Built-in Lucide icons (24px, 150+).
  • Prefixed lookup: lucide:name, custom:name. Prefix optional for Lucide.
  • IconLoader: { name, loader? | icons? } — Async or inline icon packs.
  • parseCodiconLabel(label) — Splits declarative label text into text, codicon, and registry icon segments for $(token) syntax.
  • isLabelIconAvailable(iconName) — Cached async lookup for registry icons used during label rendering fallback.
  • Label tokens: unqualified $(play) (codicon font, then registry fallback), qualified $(lucide:file-text) (registry SVG only), optional ~spin modifier. Malformed tokens remain literal text in the rendered label.

Utilities

  • apiVersion: "1.12.3" — Current API version.
  • cn(...inputs) — Tailwind class merging (clsx + tailwind-merge).
  • debounce(cb, timeout?, resetTimer?) — Returns Debouncer with cancel() and run().
  • uniqueId(salt?) / md5(string) — MD5-based hashing.
  • requireApiVersion(version) — Semver compatibility check.
  • stripHeadingForLink(text) — Normalizes heading text for anchor matching.

Tasks and Workers

  • Tasks — Promise collection: add(cb), addPromise(p), isEmpty(), promise() waits for all.
  • PromiseWorker — Wraps Worker with typed request/response. postMessage<T>(type, data, timeout?) returns a promise (default 10 s timeout). Message format: {id, type, data}{id, success, result?, error?}.

Markdown Rendering

  • MarkdownPostProcessor: (el, ctx) => Promise<void> | void — Sorted by sortOrder (default 0).
  • MarkdownPostProcessorContext: { docId, sourcePath, frontmatter, addChild(child), getSectionInfo(el) }.
  • MarkdownRenderChild (extends Component) — Lifecycle wrapper for rendered elements.
  • MarkdownRenderer.render(app, markdown, el, sourcePath, component) — Static rendering API.
  • MarkdownPreviewRenderer — Static registration for post-processors.

Settings UI Components

settings.svelte.ts provides a builder API for settings panels:

Components: TextComponent, ToggleComponent, SliderComponent, SelectComponent, ButtonComponent, ExtraButtonComponent, ProgressBarComponent, DropdownComponent, TextAreaComponent, ColorPickerComponent, IconPickerComponent.

Structure: AppSettingsSettingGroupSetting → components. Each setting has setName, setDesc, addToggle, addText, addSlider, addSelect, addButton, addColorPicker, etc.

Modals: Modal base with open(), close(), onOpen(), onClose(), containerEl.

SettingTab: Abstract class with display() and containerEl for custom settings panels.

Compatibility Surface

compat.ts provides:

  • SecretComponent — Password input field.
  • Protocol types: CliHandler, CliFlags, ObsidianProtocolHandler, ObsidianProtocolData.
  • moment — Luxon-compatible date library shim for Obsidian API compatibility.

App State and Workspace Model

App — Root State Owner

The App class (packages/api/src/lib/context.svelte.ts) is the root owner for all application state. It constructs and holds every runtime service.

State Ownership Map

OwnerResponsibilities
AppWorkspace, vault, configuration, settings, command manager, metadata cache, plugin manager, localization, editor registries, embed registry, secret storage, telemetry service, session-scoped Safe Mode policy
WorkspaceSplit-tree layout, active leaf, transient root-tab focus mode, view type registry, editor-view metadata registry, association routing, extension-to-type fallback mapping, sidebar state
WorkspaceSplitNested horizontal or vertical layout branches with proportional sizing
WorkspaceTabsGrouped leaves or sidebar groups sharing a tab strip, current tab index, stacked mode
WorkspaceSidebarGroupSidebar-only view containers with ordered child leaves plus hidden, collapsed, and resized panel state
WorkspaceLeafSingle view host, navigation history, container element
View subclassesRendering and behavior for a specific content type

Reactivity Model

Files ending in .svelte.ts use Svelte 5 runes for reactive state:

  • $state for mutable reactive properties (e.g., WorkspaceTabs.currentTab, Menu.open, CommandManager.open).
  • $derived for computed values.
  • $effect for side effects on state changes.

This means the workspace model is reactive: UI components automatically re-render when model properties change.

Open-File Flow

When app.openFile(file) runs:

  1. Tries to reuse an already-open matching leaf in the focused command host, then falls back to the active main-area leaf.
  2. If the active leaf is in a sidebar, falls back to the selected root-split leaf instead of replacing the sidebar view.
  3. If no main-area leaf exists, creates a new WorkspaceLeaf in the root split.
  4. Determines view type from workspace.editorAssociations, registered editor-view filename patterns, or the registered extension fallback.
  5. Looks up the view constructor from the registry.
  6. If no registered constructor exists, checks verified official registry contribution summaries for an on-demand install prompt.
  7. Creates and mounts the view in the leaf.
  8. The leaf’s setViewState() triggers visual update.

When a leaf replaces one TextFileView with another, the previous editor instance is destroyed after the old view unloads. This keeps repeated file switches from accumulating detached CodeMirror editors in memory and turning later navigations into progressively slower reloads.

File navigation is a mutation of the workspace model first, then a visual update. Default file navigation APIs such as app.openFile(file), workspace.openLinkText(...), and workspace.getLeaf(false) target the main root split unless a floating or popout command host currently owns focus. When the focused host already has that file open, app.openFile(file) reselects and reveals the existing leaf instead of reopening the file in another pane. Sidebar APIs such as ensureSideLeaf(), getLeftLeaf(), and getRightLeaf() remain the explicit way to create or reuse sidebar leaves, so clicking a file link from search, tags, backlinks, or another sidebar surface does not replace the sidebar panel even when no main tab is currently selected.

The current implementation also traces this path through app.telemetry. app.openFile(), WorkspaceLeaf.setViewState(), WorkspaceLeaf.openFile(), TextFileView.onLoadFile(), and selected editor reconfiguration steps emit nested spans so slow opens can be attributed to state restore, file I/O, editor state creation, or view mounting. Slow spans capture a lightweight workspace snapshot including active view type, sidebar visibility, leaf counts, enabled plugin count, and a DOM node count bucket.

The browser telemetry service also publishes a small reactive diagnostics snapshot on app.telemetry.diagnostics. It keeps bounded ring buffers for recent slow spans and recent measurements (web-vitals plus longtask) so the diagnostics core plugin can expose a local workspace view without depending on an OTLP backend or browser devtools.

App exposes emulateMobile(enable?) for devtools use: it persists workspace.mobile.mode as always when enable is true or omitted, or as never when enable is false, then reloads the renderer when window is available.

App now also carries a session-scoped safeMode record. It captures temporary startup recovery policy such as disabling community plugins, skipping saved layout restore, or blocking notebook execution for the next reload, plus the last preserved startup failure summary when the user entered Safe Mode from a recovery surface. The workspace shell mirrors this state into context keys so commands and plugins can cheaply gate behavior like notebook execution without consulting renderer-only state.

Workspace.activeLeaf is treated as an in-tree selection, not just a cached pointer. If a close, unload, or layout restore leaves the cached leaf detached from the current split tree, the workspace falls back to the selected leaf from the live tab tree instead of exposing the stale reference to callers. Workspace.activeRootLeaf applies the same validation while only considering the root split, which keeps sidebar-originated default file navigation out of the sidebars without changing focus tracking for sidebar views. When the active leaf belongs to an in-app floating pane or host-backed popout, default file navigation stays in that same floating window so rendered-note link clicks do not jump back to the main root split.

The workspace model also carries a presentation-only displayMode state with values desktop and mobile. This is not a second layout tree and it is not serialized into workspace.json; it exists so the renderer shell and plugins can react to whether the current shell is presenting the shared layout as a desktop or mobile workspace. setDisplayMode() emits a typed display-mode-change event when the mode actually changes, while isMobileMode is the convenience getter used by mobile-aware shell logic. Plugins should treat this as a presentation hint rather than a layout fork: mobile mode can hide visible split chrome, show only one active leaf at a time, and route sidebar-owned leaves through alternative navigation surfaces without changing the underlying workspace tree.

Mobile and adaptive shells use public leaf-control helpers instead of reaching into private tab-selection internals. getOpenLeafEntries() walks the main split, left and right sidebars, and floating or popout windows to expose a flat list of open leaves with region metadata, active state, selected-in-parent state, file path where applicable, and parent tab, group, or window identity. activateLeaf() selects the correct parent tab or sidebar group, unhides a grouped sidebar leaf when needed, and brings floating or popout windows to the front without requiring shell code to understand the workspace tree. The companion closeLeafAndSelectFallback() helper preserves normal close behavior while choosing a sensible remaining leaf when an adaptive shell closes the active leaf.

The workspace also exposes the owning Document for the focused command host. API-owned modal wrappers use that document’s body as their dialog portal target, so popup-triggered dialogs render inside the popup window instead of falling back to the parent renderer document.

The workspace model also exposes getVisibleHintTargets() for bundled keyboard navigation overlays. It scans workspace.containerEl for visible enabled nodes marked with data-hint-target, normalizes the associated data-hint-* metadata into runtime records, and returns only targets whose DOM rects are present in the current shell. This keeps chrome-local opt-in metadata in the workspace renderer while leaving hint-label generation and activation behavior to the owning plugin.

Layout Model

The layout tree is recursive:

WorkspaceSplit (vertical | horizontal)
├── WorkspaceSplit (nested)
│   └── ...
└── WorkspaceTabs
    ├── WorkspaceLeaf (view: MarkdownView)
    ├── WorkspaceLeaf (view: EmptyView)
    └── WorkspaceSidebarGroup
        ├── WorkspaceLeaf (view: SearchView)
        └── WorkspaceLeaf (view: GraphView)

Workspace components in packages/workspace render this tree; they do not own an independent layout model.

Layout Serialization

workspace.getLayout() serializes the full tree to JSON:

  • Split types, children, sizes.
  • Tab group state (current tab, stacked mode).
  • Sidebar group state (id, name, icon, child leaves, hidden child IDs, collapsed child panels, and resized child panel proportions).
  • Leaf view states (view type, file path, scroll position, etc.).

workspace.loadLayout(json) reconstructs the tree from JSON. workspace.changeLayout(json) uses the same object-restore path for already loaded layout snapshots, including required view-plugin activation, active leaf restoration, layout-ready notification, and live layout persistence after a successful replacement. Flat sidebar layouts without WorkspaceSidebarGroup entries remain valid, and grouped sidebar layouts round-trip without changing the underlying child leaves. Grouped sidebar panel sizes are optional, so older layouts without panelSizes keep loading with equal panel proportions. requestSaveLayout() debounces persistence to the vault and is queued when restorable workspace state changes, including layout mutations, grouped sidebar panel resizing, sidebar visibility changes, and leaf view or file opens.

Presentation-only workspace state such as displayMode is intentionally kept out of this serialized layout contract, so switching between desktop and mobile shells does not rewrite split sizes, sidebar widths, or other persisted layout state merely because the renderer chose a different presentation.

The workspace model also keeps a transient focusMode record for promoted root tab groups. enterFocusMode(leaf) only accepts leaves that still belong to the root split, exitFocusMode() clears the active promotion without mutating the split tree, and restore paths such as restoreLayoutJson() or focused-leaf teardown clear the transient state before rehydrating layout. Focus mode is intentionally not serialized into .obsidian/workspace.json, so reload always returns to the normal shell layout.

Committed workspace drag/drop mutations now route through shared model helpers instead of direct renderer-side split-tree edits. moveWorkspaceChildToTabIndex() owns top-tab and top-level sidebar reorder, dropWorkspaceItemOnTabs() owns center drops plus edge split drops for leaves and files, and moveLeafToSidebarGroupIndex() / moveLeafToSidebarGroup() own grouped-sidebar panel reorders and group ingestion. Those helpers fire cancelable layout-will-drop events before mutating layout, emit layout-did-drop after success, and finish with a single layout-change marked with source: "drag-drop". When a drop operation needs to clone or open a leaf as part of the mutation, the workspace suppresses transient nested save requests so listeners observe only the final committed drag/drop change.

Command and Keyboard System

Command Registration

Commands are registered with app.commands.registerCommand(cmd):

interface Command {
  id: string; // "plugin:command-id"
  name: string; // "Plugin: Command Name"
  icon?: string;
  repeatable?: boolean;
  callback?: () => any; // Global
  checkCallback?: (checking) => boolean | void;
  editorCallback?: (editor, ctx) => any;
  editorCheckCallback?: (checking, editor, ctx) => boolean | void;
  hotkeys?: Hotkey[]; // Default keybindings
}

Commands with editorCallback are stored separately in editorCommands for editor-context execution.

Keyboard Routing

  1. App.svelte captures keydown events at the document level.
  2. Records app.lastEvent.
  3. Gives any additional overlay scopes first chance to handle the event.
  4. When transient workspace focus mode is active, handles bare Escape at the shell boundary before leaf scopes so focused editors cannot swallow the exit action.
  5. Forwards remaining events to the active leaf’s Scope.
  6. Scope.handleEvent(evt) checks key bindings, calls handlers. Return false to preventDefault.
  7. Falls back to command lookup via commandsFor(hotkey) using effective hotkeys from defaults or .obsidian/hotkeys.json overrides.
  8. Executes the first available matching command through executeCommand() so when, checkCallback, and editor-scoped command rules still apply.

Keymap

Keymap manages a stack of Scope objects. pushScope(scope) activates, popScope(scope) deactivates. Events process scopes in reverse (LIFO) order.

Scope.register() also supports wildcard capture for modal overlays: key = null matches any non-modifier key at the requested modifier specificity, and modifiers = null matches regardless of modifiers. This is used by the bundled fmode plugin to capture typed hint characters without registering one binding per letter.

Modifier handling:

  • "Mod" → Meta on Mac, Ctrl on Windows/Linux.
  • "Ctrl" → Ctrl always.
  • "Meta" → Meta (Cmd on Mac, Win on others).
  • Keymap.isModifier(evt, mod) / Keymap.isModEvent(evt) for platform-aware checks.

Left and right docks are represented as sidebar state attached to the workspace model. The Workspace exposes leftSplit and rightSplit as WorkspaceSplit instances. The Svelte shell’s Sidebar.Provider and SidebarState consume these to size and render the visible docks.

WorkspaceSidedock wraps each split with show/hide semantics. Visibility is toggled via commands (app:toggle-left-sidebar, app:toggle-right-sidebar).

Those sidebar and leaf counts now also feed the slow-span snapshot path so hot-page regressions can be correlated with current shell state rather than only with the triggering interaction.

When a sidedock leaf is split, the workspace inserts a nested horizontal split inside the existing sidedock branch so the original tab group and the new leaf stay in the sidebar tree instead of replacing each other.

Sidebar views may also be grouped. Workspace.registerSidebarView() stores default placement metadata for a view type, and Workspace.ensureSideLeaf() accepts explicit placement options for side, group identity, group display metadata, and initial hidden state. Groups are model objects, not plugin-owned composite views, so traversal, active-leaf restore, getLeavesOfType(), and layout persistence continue to operate on the real child leaves. The workspace model also exposes helpers to find/create groups and convert sidebar leaves to a group or a group back to normal sidebar tabs.

Hover Preview and Popouts

The workspace keeps hover-link source registrations in-memory so plugins can register them during onload() and rely on component cleanup to remove them on unload.

openPopoutLeaf() and moveLeafToPopout() now request a host-backed popout window instead of reusing the in-app floating overlay path. When the current host registers popout support, the workspace creates a separate Window/Document, mounts the selected WorkspaceWindow tree there, mirrors theme and plugin styles into that document, and removes the popout from layout state when the secondary window closes. When the host does not support popouts, these APIs raise an explicit unsupported error and leave the original leaf attached.

In-app floating panes and host-backed popouts share the WorkspaceWindow model. moveWorkspaceChildToFloating() moves leaves, sidebar tabs, sidebar groups, or grouped sidebar panel leaves into an in-app floating pane, while moveWorkspaceChildToPopout() moves the same item types through the host popout path when supportsPopoutWindows() is true. Floating panes also carry display state: normal, collapsed, minimized, and maximized. Only collapsed and minimized persist in workspace layout JSON; maximized is a transient renderer state that restores as normal after reload. Workspace.getLeaf() returns the active floating or popout leaf for default navigation before falling back to activeRootLeaf, preserving local navigation inside secondary windows while keeping sidebars from becoming the default file target.

The internal moveWorkspaceChildToWindow() helper builds and commits the destination WorkspaceWindow tree to the floating container before detaching the item from its source. This ordering ensures the moved item always has a reachable parent: if the host popout open fails, the item was never detached and remains in the source tree.

Layout Normalization

Before the workspace model is hydrated from persisted JSON, the raw input is passed through normalizeWorkspaceJson(). This pure function repairs common corruption that can accumulate in long-lived .obsidian/workspace.json files:

  • Sizes arrays whose length does not match their sibling children array, or that contain non-finite values, are repaired by padding or trimming to match and replacing invalid values with an equal-weight default.
  • currentTab indices that are out of range or non-integer are clamped to 0.
  • Empty floating windows (no leaf content) are dropped before the model is hydrated.
  • Popout-mode windows cannot be restored across sessions and are also dropped.
  • Stale sidebar group metadatahiddenLeafIds, collapsed, and panelSizes entries that refer to leaf ids no longer present in the group’s children — are stripped.
  • Unknown or malformed child node types are silently dropped; valid siblings are kept.
  • Missing required fields are filled with safe defaults (e.g. generated ids, "16rem" sidedock width, "vertical" split direction).

The normalizer is applied on every call to restoreLayoutJson(), which is the shared entry point for both loadLayout() (file-based restore on boot) and changeLayout() (programmatic restore from an arbitrary object).

Configuration State

See Configuration and Settings for the full schema-driven configuration system.

UI Package (@lapis-notes/ui)

The UI package is the shared design-system layer. It wraps headless component libraries (Bits UI, paneforge, svelte-sonner) in Tailwind-styled Svelte components that the workspace shell and API-level components share.

Source: packages/ui/src/lib/

Design Principles

  • Presentation only — no app state, workspace behavior, plugin lifecycle, storage, or metadata.
  • Thin wrappers over headless primitives with consistent Tailwind styling.
  • Variant-based APIs using tailwind-variants and class-variance-authority.
  • Composable: most complex components are exported as namespaced sub-components (e.g., Dialog.Root, Dialog.Content).
  • Plugin-specific chrome, selector namespaces, and stylesheet ownership stay in the owning plugin package. Shared UI components should remain generic building blocks instead of accumulating plugin-specific override rules.
  • Theme-first styling: shared components consume theme.css semantic variables and prefer hairline borders, 4-8px radii, visible neutral hover states through --background-modifier-hover, selected neutral states through --background-modifier-active-hover, and minimal shadows so workspace and plugin chrome inherit the same Obsidian-aligned surface system.

Theme Tokens

theme.css is the canonical shared palette and Tailwind token bridge entrypoint. Internally it imports grouped partials from src/lib/styles/theme/ for the light palette, dark overrides, semantic aliases, shared foundations, document/content roles, shared surfaces, and Tailwind bridge tokens. Dark mode is the primary target and follows the attached Obsidian default ladder: #1e1e1e background, #242424 card/alternate surface, #262626 secondary/sidebar surfaces, #363636 hairline/sidebar borders, #3f3f3f strong/input borders and sidebar hover surfaces, #dadada text, and #b3b3b3 muted text. Accent roles resolve through --color-accent*; components use semantic --interactive-accent, --text-accent, and --text-on-accent instead of raw --accent-interactive.

Light mode remains supported as a restrained structural mirror. Existing font imports and the mono font stack remain unchanged.

The shared theme continues to expose shadcn/Tailwind variables (--background, --card, --primary, --border, etc.), Obsidian-compatible aliases (--background-primary, --background-modifier-border, --text-normal, --text-muted, --interactive-accent, --text-on-accent), and compatibility helpers (--color-base-*, --color-accent-*, --mono-rgb-*, theme-level selection and shadow tokens, the blur/raised surface chain, and form-field hover helpers) so workspace chrome, first-party plugins, and community-style plugin CSS inherit a single token source.

Theme ownership also includes class-family compatibility: the shared token layer must resolve correctly for both dark/light and theme-dark/theme-light classes when the runtime applies them on either html or body.

The cross-package Style System page is the durable contract for effective tokens, shape language, plugin inheritance, and update rules.

Utilities

  • cn(...inputs) — Class merging via clsx + tailwind-merge.
  • fuzzySearch(items, query, { keys }) — Shared Fuse.js-backed fuzzy ranking helper for batch filtering when Command.Root uses shouldFilter={false}.
  • fuzzyMatchScore(value, query, keywords?) / createFuzzyMatchScore() — Shared per-item Fuse scorer for menus, tags, metadata autocomplete, and other ranked filter lists.
  • commandFuzzyFilter / createCommandFuzzyFilter() — Aliases of the per-item scorer for bits-ui Command.Root filter props.
  • WithoutChild<T>, WithoutChildren<T>, WithoutChildrenOrChild<T> — Type utilities for removing slot props.
  • WithElementRef<T, U> — Adds bindable ref property.

Hooks

IsMobile

IsMobile extends MediaQuery. Constructor takes breakpoint (default 768px). Provides reactive mobile detection.

Component Catalog

Layout Components

ComponentBacking LibrarySub-componentsKey Props
Accordionbits-uiRoot, Item, Trigger, Contentvalue, onValueChange
Collapsiblebits-uiRoot, Trigger, Contentopen (bindable)
ResizablepaneforgePaneGroup, Pane, Handledirection; Handle keeps a constant hit area and thickens its visible separator line from 2px to 4px in var(--interactive-accent) on hover/focus-visible
Sidebarcustom25+ sub-componentsopen, side, variant, collapsible; Rail uses the same hover/focus accent-line treatment
Sheetbits-ui DialogRoot, Trigger, Overlay, Content, Header, Title, Footer, Closeside (“top”/“bottom”/“left”/“right”)
CardcustomRoot, Header, Title, Description, Content, Footer, Action
ItemcustomRoot, Content, Title, Description, Actionsvariant (default/outline/muted), size (default/sm)

Form Controls

ComponentBacking LibrarySub-componentsKey Props
ButtoncustomRoot onlyvariant (default/destructive/outline/secondary/ghost/link), size (default/sm/xs/lg/icon); neutral variants hover through --background-modifier-hover
InputcustomRoot onlyref, value (bindable), type
TextareacustomRoot onlyref, value (bindable), field-sizing support
Checkboxbits-uiRoot onlychecked (bindable), indeterminate (bindable)
Switchbits-uiRoot onlychecked (bindable)
Sliderbits-uiRoot onlyvalue (bindable array), orientation; thumb labels render as tooltip-like callouts with bottom arrows on hover/focus
Selectbits-uiRoot, Trigger, Content, Item, Group, GroupHeading, Label, Separator, ScrollUp, ScrollDownsize on Trigger
Togglebits-uiRoot onlypressed (bindable), variant, size; hover uses --background-modifier-hover, pressed uses --background-modifier-active-hover
Toggle Groupbits-uiRoot, Item
Labelbits-uiRoot onlyStandard form label
SearchcustomRoot onlyref, value (bindable), integrated clear button
Color PickercustomRoot only

Overlay Components

ComponentBacking LibrarySub-componentsNotes
Dialogbits-uiRoot, Trigger, Portal, Overlay, Content, Header, Title, Description, Footer, CloseshowCloseButton prop on Content
Dropdown Menubits-ui15 sub-components including Sub, CheckboxItem, RadioItem, RadioGroup
Context Menubits-ui15 sub-components (mirrors Dropdown Menu)
Popoverbits-uiRoot, Trigger, Content, Close
Hover Cardbits-ui LinkPreviewRoot, Trigger, Content
Tooltipbits-uiRoot, Provider, Trigger, Content, Portal
Modalcustom + Dialogopen (bindable), title, contentSupports DocumentFragment content
Date Time Picker Dialogcustom + Dialog/InputRoot onlyModal date/time entry with apply, cancel, and clear actions
Commandbits-uiRoot, Dialog, Input, List, Empty, Group, Item, Separator, Shortcut, LinkItem + Loading, GroupHeadingCommand palette building blocks
AutocompletecustomRoot, List, Item, Empty, FooterPresentational list surfaces (data-slot) aligned with Command/Popover; CodeMirror completion DOM uses @lapis-notes/ui/codemirror-autocomplete.css
CodeMirror lint CSSworkspace-ownedLint range, marker, panel, custom tooltip shell, and inline-problem chrome live in the workspace app stylesheet while consuming UI theme tokens

Trigger-backed overlay wrappers now carry a shared popup-document fallback for their portal target. When callers do not pass explicit portalProps, the shared Dropdown Menu, Context Menu, Hover Card, Tooltip, Popover, and Select wrappers portal into the active trigger element’s ownerDocument.body rather than the ambient root document. That keeps popup-hosted overlays attached to the window that raised them without requiring every popup-aware call site to wire its own portal target.

Data Display

ComponentNotes
TableRoot, Header, Body, Footer, Row, Cell, Head, Caption
Table DnDShared row/column reorder primitives for plugin table previews (@lapis-notes/ui/table-dnd, @lapis-notes/ui/table-dnd/sensors, @lapis-notes/ui/table-dnd/utils): tableReorderSensors, TableDragGrip, dropIndicatorClasses, parseTableDragData, resolveTableDragTargetIndex
Data TableTanStack Table integration via createSvelteTable(options). Helper: FlexRender, renderComponent, renderSnippet, mergeObjects
Badgevariant (default/secondary/destructive/outline), renders as <a> or <span>
TagShared Obsidian-style tag/chip renderer exported from @lapis-notes/ui/tag; used by Markdown previews, Bases, and editable metadata property chips
Progressvalue, max (default 100), animated CSS transform
SkeletonPulsing placeholder animation
SeparatorSimple visual divider
ComponentNotes
BreadcrumbRoot, List, Item, Link, Page, Separator, Ellipsis
Scroll Areabits-ui. orientation (“vertical”/“horizontal”/“both”), customizable scrollbar classes

Feedback

ComponentNotes
Sonner (Toaster)Wraps svelte-sonner. Auto-syncs theme with mode-watcher. Custom CSS variables for theming.
AlertRoot, Title, Description. variant (“default”/“destructive”)

Validation And Exports

The package builds with svelte-package, copies theme.css, styles.css, and codemirror-autocomplete.css into dist, and exposes component subpaths through package exports. Consumers import components directly or through the package index.

During local validation, the package’s Svelte/TypeScript config maps @lapis-notes/ui self-imports and component subpaths back to src/lib so svelte-check runs in a clean checkout without relying on generated dist/ declarations.

The Sidebar is the most complex component group, designed for the workspace shell’s dock panels:

State Management

  • SidebarState class — open, openMobile, state (“expanded”/“collapsed”), isMobile.
  • useSidebar() — Context hook for accessing sidebar state.
  • toggle(), setOpen(), setOpenMobile(), handleShortcutKeydown().
  • Keyboard shortcut: Cmd+B / Ctrl+B.

Constants

  • SIDEBAR_WIDTH = "16rem", SIDEBAR_WIDTH_MOBILE = "18rem", SIDEBAR_WIDTH_ICON = "3rem".
  • SIDEBAR_COOKIE_NAME = "sidebar:state", SIDEBAR_COOKIE_MAX_AGE = 7 days.

Components

  • Provider — Sets up state, CSS variables for widths.
  • Root — Conditionally renders as Sheet on mobile.
  • Content, Header, Footer, Inset — Layout sections.
  • Menu, MenuItem, MenuButton — Menu items with variants (size, variant), tooltip support, icon collapse.
  • MenuSub, MenuSubButton, MenuSubItem — Nested menus.
  • MenuAction, MenuBadge, MenuSkeleton — Auxiliary menu elements.
  • Group, GroupLabel, GroupContent, GroupAction — Section grouping.
  • Input, Rail, Trigger, Separator — Utility components.
  • Rail shares the same hover/focus-visible accent-line treatment as Resizable.Handle: the visible separator line stays centered, grows from 2px to 4px, and switches to var(--interactive-accent) without changing the drag hit target.
  • NestedProvider — For nested sidebar contexts (e.g., right sidebar inside left sidebar).

Dependencies

  • bits-ui — Headless Svelte components.
  • paneforge — Resizable pane library.
  • svelte-sonner — Toast notifications.
  • @tanstack/table-core — Data table engine.
  • mode-watcher — Dark/light theme detection.
  • tailwind-variants / class-variance-authority — Variant styling.
  • lucide-svelte — Icons (SearchIcon, X, ChevronDown, Check, Minus used internally).
  • @dnd-kit/dom, @dnd-kit/svelte — Table row/column reorder sensors and grip helpers (table-dnd); presentation-only, no vault or document model logic.

Diffmerge Package (@lapis-notes/diffmerge)

@lapis-notes/diffmerge provides Svelte 5 diff and three-way merge UI: DiffMerge2 (two-pane) and DiffMerge3 (three-pane), plus line/block diff helpers, editor chrome, and themed CSS.

Source: packages/diffmerge/src/lib/

Provenance

The implementation is vendored from the mismerge / mergeweave core library (MIT). This monorepo keeps a single package that combines former core sources with the former adapter-svelte packaging model (plain Svelte components, not custom elements). React and Solid adapters are not included.

Exports

The package is built with @sveltejs/package. Consumers should depend on workspace:* and use:

ExportPurpose
@lapis-notes/diffmergeDiffMerge2, DiffMerge3, types, and colors helpers from the root entry
@lapis-notes/diffmerge/styles.cssBase styles
@lapis-notes/diffmerge/light.css / dark.cssTheme variants
@lapis-notes/diffmerge/colorsEditor color constants

Import the CSS entry that matches the host app’s theme in the same way other packages expose side-effect CSS (see Plugin CSS Contract).

Boundary Rules

  • Peer: svelte ^5.0.0.
  • Runtime deps: diff, esm-env, nanoid (aligned with other packages using diff 9.x).
  • Components are ordinary Svelte exports: hosts mount <DiffMerge2 … /> / <DiffMerge3 … /> like other local components; there is no custom-element bundle in this package.

Integration status

The package is buildable and tested in isolation. No active product surface currently depends on it; the History compare tab uses CodeMirror’s unified merge extension instead.

Package-local summary

For a short index maintained next to the code, see packages/diffmerge/spec.md.

Language Service Package (@lapis-notes/language-service)

@lapis-notes/language-service owns first-party browser language-service providers that are too heavy for @lapis-notes/api or editor plugins. The shared API package owns the contracts and CodeMirror adapters; this package supplies concrete worker-backed providers.

Source: packages/language-service/src/

Providers

  • createMarkdownLanguageServiceProvider() starts a markdownlint Web Worker and returns a LanguageServiceProvider for single-document Markdown diagnostics and fix actions. The worker statically imports markdownlint runtime entry points so browser/PWA builds bundle them instead of leaving bare module specifiers for runtime resolution.
  • @lapis-notes/language-service/markdown exports the markdown provider factories so the external Markdown Lint plugin can import only markdownlint support without bundling unrelated providers.
  • createNativeMarkdownLanguageServiceProvider() creates a bridge-backed Markdown diagnostics provider over the shared desktop_ls_* invoke surface so first-party plugins can own native markdownlint registration without importing Electron host code directly.
  • createTypeScriptLanguageServiceProvider() starts a TypeScript Web Worker backed by the TypeScript language-service APIs and @typescript/vfs file-system helpers. It serves TypeScript diagnostics, completions, hover, and definition lookups for virtual documents.

Markdownlint browser and native providers now share a host-neutral runtime helper under src/markdownlint/. That helper owns:

  • shared markdownlint options and the custom MD018 replacement alias set
  • markdownlint issue -> diagnostic mapping
  • markdownlint issue -> code-action generation, including ignore-next-line and ignore-for-file actions
  • inline markdownlint suppression alias normalization for MD018
  • applyFixes(...)-based edit generation so markdownlint fixes preserve YAML frontmatter and edit the actual violation line instead of rewriting from the top of the file

Providers communicate through the serializable worker protocol exported by @lapis-notes/api. Requests carry virtual document URI, language id, version, text, position/range payloads, and global declaration snapshots. Providers do not receive App, EditorView, DOM nodes, vault files, or plugin instances.

Runtime Boundary

The default runtime ships Web Worker implementations for browser/PWA hosts. Electron desktop still owns the native/process-backed sidecar and desktop_ls_* IPC surface, but first-party plugins now consume the same shared markdownlint runtime helper through bridge-backed provider factories in this package instead of duplicating markdownlint mapping logic in the host shell. The exported worker providers remain authoritative for parity testing in Chromium.

Consumers

  • The external official Markdown Lint plugin registers the markdownlint provider for MarkdownViewType consumers when installed and enabled.
  • The Notebook plugin registers the TypeScript provider and contributes minimal notebook global declarations for lapis, Inputs, html, svg, Generators, and Promises.
  • Workspace TypeScript text views and notebook cell editors consume the shared CodeMirror adapters through app.languageServices.

Notebook Core Package (@lapis-notes/notebook-core)

@lapis-notes/notebook-core is the first extracted package from the notebook runtime refactor. It owns reusable notebook contracts that do not depend on the bundled product plugin, Svelte views, DuckDB-Wasm, workers, or @lapis-notes/api.

Source: packages/notebook/core/src/

Current Scope

The package currently exports:

  • the notebook output protocol for text, markdown, HTML, structured input outputs, trusted live DOM, table, chart, image, and error outputs
  • notebook cell, document, block, and frontmatter model contracts
  • notebook session state, run-state, checkpoint, setup-phase, and progress-event contracts
  • table metadata types used by DuckDB-backed query output normalization and dataframe rendering
  • runtime feature flags used by worker policy and runtime routing code, including the distinction between broad DOM-related helper usage and live browser DOM requirements
  • generic notebook execution result/options contracts, including a typed runtime dependency loader hook, a host-neutral notebook file reader provider contract, and a JavaScript-like compile provider contract for TypeScript/ESM rewriting hosts
  • generic notebook graph types and pure graph helpers for graph construction from analyzed cells, dependency closure, stale descendant collection, duplicate-definition detection, topological order, and cycle detection
  • pure language-service projection helpers that concatenate TypeScript cells into a virtual TypeScript document and map provider positions/ranges back to cell-local coordinates
  • subpath exports for @lapis-notes/notebook-core/execution, @lapis-notes/notebook-core/graph, @lapis-notes/notebook-core/language-service, @lapis-notes/notebook-core/model, @lapis-notes/notebook-core/outputs, @lapis-notes/notebook-core/runtime-features, and @lapis-notes/notebook-core/session

The bundled notebook product plugin still owns notebook parsing, frontmatter normalization, analyzer selection, UI, the concrete NotebookSession runtime, persistence, worker selection, DuckDB runtime setup, and command wiring. It consumes and re-exports the core model/output/session protocol through compatibility shims and binds core graph construction to the current TypeScript, SQL, and markdown analyzers through a product-plugin wrapper.

Notebook execution provider contracts live here when they are host-neutral. The file reader provider receives notebook paths plus the source note path so browser, worker, and future desktop hosts can preserve notebook-relative syntax while swapping the underlying file-read implementation. The JavaScript-like compile provider receives the original JavaScript or TypeScript cell source plus source/cell metadata and returns executable source with any static notebook import rewrites already applied. Browser execution continues to use the product plugin’s default vault reader and lazy TypeScript compiler when no providers are injected.

Boundary Rules

Notebook core must stay host-neutral and startup-cheap.

  • Do not import Svelte, @lapis-notes/api, DuckDB-Wasm, htl, Observable inputs, d3, Plot, or worker entry points from this package.
  • Keep exports focused on stable contracts and pure helper functions that are not notebook-authored builtin implementations.
  • Move additional runtime contracts here only when they can be extracted without carrying product-plugin lifecycle or host dependencies with them.

Follow-Up Direction

Additional host-neutral contracts can move here later if they emerge from the notebook product runtime, but the current package-split plan does not require another core extraction before the product plugin can continue owning concrete runtime behavior.

The current output protocol now reserves a first-class input output kind for reconstructable notebook controls. That contract is host-neutral: it describes the persisted control artifact, including the owning cell id, exported variable name, input type, current scalar value, optional serializable config, reconstructable flag, and optional source metadata. Live DOM nodes remain out of scope for notebook core and continue to be represented separately as ephemeral dom outputs.

Notebook DuckDB Package

Status: Implemented for the current package boundary. packages/notebook/duckdb owns shared DuckDB runtime contracts, app-table and vault-file registration contracts, catalog/preview types, and pure query/catalog helpers consumed by the notebook product plugin.

Purpose

@lapis-notes/notebook-duckdb separates reusable DuckDB-facing notebook contracts from the product plugin. The package owns runtime TypeScript interfaces, query row/result types, app-table and vault-file registration contracts, catalog/preview types, DuckDB type mapping, normalization into notebook table metadata, and small SQL/catalog helpers.

Current Scope

  • NotebookDuckDbRuntime and NotebookDuckDbRuntimeProvider contracts
  • NotebookDuckDbColumnDefinition table registration contract
  • DuckDbQueryRow and DuckDbQueryResult types
  • row normalization for scalar/object DuckDB results
  • query result normalization into notebook table columns, column metadata, row counts, query timing, and source SQL metadata
  • DuckDB type-name mapping for notebook table column categories
  • catalog and table-preview types for inspector/runtime surfaces
  • information-schema table-type normalization
  • DuckDB identifier quoting for generated service queries
  • createNotebookDuckDbCatalog(options) for building notebook catalog models from information-schema table/column rows and a host-provided row-count callback
  • pure catalog/preview SQL helpers, preview-limit normalization, row-count normalization, and createNotebookDuckDbTablePreview(options) for shaping table preview rows
  • NotebookAppTablesSnapshot, NotebookAppTableRegistrar, NOTEBOOK_APP_TABLE_COLUMNS, and registerNotebookAppTablesSnapshot(service, snapshot) for registering app-derived tables into any runtime-shaped registrar that can create JSON tables
  • resolveNotebookVaultPath(path, sourcePath?), NotebookVaultFileReader, NotebookVaultFileRegistrar, and registerNotebookVaultFile(service, reader, alias, path, sourcePath?) for resolving notebook-relative vault file references with the shared stdlib path-normalization rules and either registering host-read file buffers or handing the resolved path to a native runtime registrar
  • createDuckDbSchemaSql(schema) and createDuckDbJsonTableSql(options) for creating stable DuckDB SQL used by product runtimes when registering JSON-backed notebook tables

Boundary Rules

This package must stay independent of product-plugin runtime code. It must not import @lapis-notes/api, Svelte, worker entrypoints, vault adapters, app-table snapshot collectors, DuckDB-Wasm, or DuckDB asset URL imports. Browser/PWA asset URL resolution belongs to @lapis-notes/notebook-duckdb-assets; the product plugin owns app-table snapshot collection, actual vault file reads, native vault-path registration, concrete query execution, and the concrete DuckDB-Wasm service lifecycle. Sharing notebook-relative path normalization with @lapis-notes/notebook-stdlib is allowed so DuckDB registration and notebook file helpers keep one path contract.

Consumers

@lapis-notes/notebook consumes this package from the concrete DuckDB service, app-table registration adapter, vault-file adapter, inspector data surfaces, and notebook execution paths.

Notebook DuckDB Assets Package

Status: Implemented for the current package boundary. packages/notebook/duckdb-assets owns the current browser/PWA DuckDB-Wasm and DuckDB worker asset URL resolver consumed by the notebook product plugin.

Purpose

@lapis-notes/notebook-duckdb-assets separates DuckDB asset ownership from the product plugin’s DuckDB service. The product plugin still owns DuckDB lifecycle, table registration, query execution, catalog inspection, and notebook progress phases, but raw wasm and worker URL imports live behind this resolver package.

Current Scope

  • MVP DuckDB-Wasm module URL
  • exception-handling DuckDB-Wasm module URL
  • MVP DuckDB browser worker URL
  • exception-handling DuckDB browser worker URL
  • getNotebookDuckDbBundles() resolver compatible with duckdb.selectBundle()

Production builds copy DuckDB wasm and worker files to dist/duckdb/ instead of inlining them into JavaScript bundles. Official notebook releases include those paths in the signed .lapis-plugin bundle, so first SQL execution resolves installed plugin asset URLs without making a post-install release download.

Boundary Rules

This package must stay asset-only. It should not instantiate DuckDB, open connections, register files, query tables, import app APIs, or depend on product-plugin runtime code. Browser/PWA asset resolution can evolve here without changing notebook query behavior.

Consumers

@lapis-notes/notebook consumes this package from NotebookDuckDbService when that service is lazily loaded by DuckDB-backed notebook execution or inspection paths.

Notebook Input Package

Status: Implemented for the current host-neutral boundary. The package owns the Observable-style viewof source transform, tracked input metadata, DOM input node binding helpers, callback-driven view runtime helpers, and the current local Inputs runtime built from @lapis-notes/ui primitives for the supported control set. Remaining parity work is tracked by task id TASK-NOTEBOOK-001.

Purpose

@lapis-notes/notebook-input separates notebook input source helpers and tracked DOM/input lifecycle behavior from the product plugin. The current extraction owns the constrained Observable-style viewof transform, tracked input metadata, DOM input node value/listener helpers, the callback-driven lapis.view binding workflow, and the local runtime surface whose supported controls render through shared @lapis-notes/ui primitives. The product plugin continues to own input DOM registration, notebook session writes, output id allocation, and rerun scheduling.

Current Scope

  • transformObservableViewof(source) rewrites supported top-level viewof name = expression declarations into const name = await lapis.view(name, expression) calls.
  • findObservableViewofDefinitions(source) returns unique viewof names for source-level analysis.
  • The transform uses the canonical lapis global name from @lapis-notes/notebook-stdlib.
  • loadNotebookDomRuntime() lazily assembles the DOM-backed notebook runtime surface for hosts that have already chosen in-process DOM execution. That runtime owns the Inputs namespace exposed to notebook cells, pairs it with html and svg helpers from notebook-stdlib, routes supported controls through @lapis-notes/ui primitives for app-level UI parity, and throws explicit errors for unsupported factories.
  • createTrackedNotebookInputs(inputs) wraps Observable input factories so reconstructable controls can attach host-neutral metadata to the returned DOM nodes without parsing source text.
  • styles.css gives notebook hosts a notebook-input-owned stylesheet entrypoint for the current UI-backed controls without importing upstream styles directly from the product plugin.
  • attachNotebookInputMetadata(value, metadata) and getNotebookInputMetadata(value) expose the private metadata attachment surface used to carry input type, current value, serializable config, reconstructable flag, and source details from tracked Observable inputs into a runtime host.
  • canReconstructNotebookInput(metadata) and reconstructNotebookInputNode(metadata) let runtime hosts decide whether persisted structured inputs are directly rebuildable and recreate supported controls from stored metadata without re-running the owning cell. Newly created metadata identifies the local runtime with source.library: "@lapis-notes/notebook-input".
  • isTemplateStringsArray(value) and exported htl template tag types support runtime hosts that bridge lapis.html/lapis.svg with htl tagged template calls.
  • bindNotebookInputNode(node, options) seeds an optional initial DOM input value, reports the current value, binds input and change listeners, and returns a disposer for the runtime host to attach to its DOM-output lifecycle.
  • getNotebookInputNodeValue(node) and setNotebookInputNodeValue(node, value) provide shared DOM input value access for hosts without owning persistence policy.
  • createNotebookViewRuntime(options) builds the async lapis.view(name, input) host function from callbacks. It resolves promised DOM nodes, validates that the result is a DOM node, reads any tracked input metadata, binds input listeners, writes the current value through host callbacks, registers the live DOM output, appends the host’s typed output, and emits input-change events through the host callback.
  • isNotebookDomNode(value) provides shared DOM-node detection for runtime hosts that need to classify cell return values.

Boundary Rules

This package must stay host-neutral at import time. It may lazily load DOM-only helper code, the Svelte runtime, and @lapis-notes/ui primitives only inside loadNotebookDomRuntime(), so workspace boot and non-DOM notebook runs stay startup-thin. It must not import @lapis-notes/api, product-plugin modules, notebook session managers, workers, or DuckDB at top level. Runtime hosts are responsible for deciding when DOM-backed execution is available, passing host callbacks into createNotebookViewRuntime, registering live DOM outputs, persisting scalar input values, turning tracked input metadata into product-level structured outputs, and emitting rerun requests.

Consumers

@lapis-notes/notebook consumes this package from notebook analysis, render DOM detection, and cell execution paths. The DOM helper surface still loads lazily only for DOM-backed notebooks, while notebook-input adds the tracked wrapper and the UI-backed control factories that let notebook inputs hand reconstructable metadata to the product runtime and rebuild supported controls from persisted structured outputs without changing the package’s host-neutral boundary.

Notebook Stdlib Package

Status: Implemented for the current package boundary. packages/notebook/stdlib owns current notebook builtin registry contracts, documented bare global names, the pure user-executed lapis output helper implementations, user dependency descriptor/registry validation contracts, the shared runtime dependency loader helpers, source-level runtime feature detection for standard-library-backed helpers, the host-neutral vault/file helper implementations used by notebook runtime hosts, the raw DOM helper loader for html/svg, the host-neutral DOM, Promises, and Generators helper implementations, and the host-neutral bootstrap used to assemble bundled stdlib bindings for notebook execution.

Purpose

@lapis-notes/notebook-stdlib is the shared contract and helper-implementation package for Lapis notebook builtins and user dependency registry metadata. It gives the product plugin and future dependency-management UI one source of truth for which names are Lapis-owned, which bare globals are documented conveniences, how dependency globals are derived and validated, how configured dependencies resolve into runtime globals, which source references require broad DOM-aware handling versus live browser DOM execution or DuckDB-backed execution, how vault-backed file helpers normalize notebook-relative paths, how DSV attachment loaders preserve table columns, and how bundled stdlib helpers are assembled for notebook execution.

The package is intentionally host-neutral. It does not load htl, DuckDB-Wasm, worker code, Svelte UI, or app APIs at workspace boot. Host-specific runtime wiring still lives in the product plugin, but shared host-neutral helper implementations such as vault-backed file attachment plumbing and dynamic bundled-helper bootstrap can live here so notebook-callable helper behavior stays centralized without making workspace boot expensive.

Current Scope

  • canonical lapis global name
  • pure lapis output helper implementations for typed text, markdown, HTML, DOM, input, table, chart, image, and error outputs
  • current lapis.* builtin member names, including output helpers, duckdb, vault, FileAttachment, md, tex, d3, Plot, DOM, Generators, Promises, view, Inputs, html, and svg
  • documented bare convenience globals: Inputs, html, svg, md, tex, d3, Plot, DOM, Generators, and Promises
  • validation for future user dependency globals, including identifier syntax, reserved builtin names, and duplicate configured names
  • host-neutral user dependency descriptors with pinned package versions, optional explicit globals, resolver metadata, package-name-derived default globals, whole-registry validation, and shared runtime dependency resolution helpers for referenced globals
  • source-level detection for broad DOM helper references, live browser DOM runtime references, DuckDB runtime references, and bundled visualization helper references
  • host-neutral notebook file helpers including notebook-relative path normalization, vault runtime adapters backed by host file readers, and FileAttachment helpers for arrayBuffer, text, json, blob, csv, and tsv
  • host-neutral raw DOM helper loading for htl, exposed for notebook-input so the input runtime can pair bare html and svg helpers with its own Inputs namespace ownership
  • host-neutral DOM helper implementations for canvas, context2d, download, and uid, patterned after the Observable stdlib namespace and exposed under both lapis.DOM and the documented bare DOM global
  • host-neutral Promises and Generators helper implementations patterned after Observable stdlib namespaces and exposed under both lapis.* and documented bare globals
  • host-neutral runtime bootstrap for bundled stdlib helpers, currently including lapis.md, lapis.tex, lapis.d3, lapis.Plot, lapis.DOM, lapis.Generators, lapis.Promises, and their documented bare globals during JavaScript-like execution

Consumers

@lapis-notes/notebook consumes this package for pure lapis output helpers, runtime feature detection, inspector DuckDB detection, notebook dependency-analysis reserved globals, shared runtime dependency loading helpers, the raw html/svg DOM helper loader used under notebook-input, the shared DOM, Promises, and Generators namespaces, the stdlib bootstrap for bundled helpers, and vault-backed lapis.vault/lapis.FileAttachment helper implementations. @lapis-notes/notebook-duckdb also reuses the shared notebook-relative file-path normalization so DuckDB vault-file registration and notebook file helpers obey the same path rules.

Boundary Rules

Host-specific implementations should remain lazy and must not make workspace boot load DOM, DuckDB, visualization, or remote dependency resolver code. DOM control wiring, DuckDB service execution, and remote dependency loading still belong outside this package; host-neutral file helper implementations and bundled-helper bootstrap are allowed here because they depend only on host-provided file readers or package-local dynamic imports.

Notebook Worker Package

Status: Implemented for the current package boundary. packages/notebook/worker owns generic notebook worker transport contracts and host request promise plumbing consumed by the notebook product plugin.

Purpose

@lapis-notes/notebook-worker separates worker transport message contracts, host-neutral worker-side request plumbing, main-thread command promise plumbing, main-thread host response wrapping, worker-side shell dispatch, and pure worker eligibility policy from the product plugin. The package owns protocol shapes, request id/promise settlement for both main-thread commands and worker-to-host calls, generic host response envelopes and transfer-list selection, generic worker-side command/response dispatch, and the reusable decision for whether a runtime feature set may use a worker; concrete worker startup, worker URL imports, request semantics, response application, host adapters, feature analysis, fallback execution, and execution remain in @lapis-notes/notebook.

Current Scope

  • stable notebook worker channel names
  • generic execute command request and response messages
  • generic progress messages
  • host request and host response messages
  • generic incoming/outgoing worker message unions
  • NotebookWorkerCommandBroker for creating command ids, creating command messages, tracking pending command promises, looking up command context for progress events, and rejecting outstanding commands when a worker stops
  • NotebookWorkerShell for dispatching incoming worker messages, settling host responses, wrapping execute command results/errors as command responses, and posting progress messages with the active command id
  • NotebookWorkerHostRequestBroker for posting host requests, tracking pending request promises, settling host responses, and rejecting outstanding requests when a worker shell tears down
  • NotebookWorkerHostRequestResponder for resolving main-thread host requests through product-provided callbacks, wrapping success/error host responses, and selecting transfer lists for transferable response values
  • shouldUseNotebookWorkerRuntime(input) for deciding whether a runtime feature set can use a worker, based on worker availability, DuckDB usage, live browser DOM requirements, injected dependency loaders, host-only providers, and unsupported dependency resolver kinds
  • hasUnsupportedNotebookWorkerRuntimeDependency(dependencies) for checking whether dependency descriptors require main-thread execution because they are not remote ESM descriptors

Boundary Rules

This package must stay host-neutral. It must not import @lapis-notes/api, Svelte, notebook product-plugin models, worker entrypoints, DuckDB, TypeScript, DOM runtime packages, or app host adapters. Product runtimes bind protocol generics to their document/result/progress types, decide what each command and host request means, provide the execute implementation used by NotebookWorkerShell, provide host request resolver callbacks used by NotebookWorkerHostRequestResponder, apply command responses to local state, and supply runtime feature facts to the worker policy helper.

Consumers

@lapis-notes/notebook consumes this package through a product-plugin alias layer that binds the generic protocol contracts to current notebook document, execution result, persisted cell-state, and progress-event types. Its concrete worker entrypoint uses NotebookWorkerShell plus NotebookWorkerHostRequestBroker for app-table and vault-file host calls, and its worker client uses NotebookWorkerCommandBroker, NotebookWorkerHostRequestResponder, and the shared policy helper after product-plugin feature analysis.

Web Host (@lapis-notes/web)

The web package is the deployable browser/PWA host. It wraps the shared workspace renderer with host-level concerns that should not live inside @lapis-notes/workspace: the HTML document shell, PWA manifest and icon generation, service-worker registration, install affordances, and cross-origin-isolation headers needed by the browser-local SQLite runtime.

Source: packages/web/src/

Responsibilities

  • Mounts the shared workspace renderer by importing mountWorkspaceApp() from @lapis-notes/workspace/components/app.
  • Shows browser-only host status items in the shared workspace status bar, including the shared app version item, a dismissible install affordance when the browser exposes beforeinstallprompt, a pending-update affordance that can reopen the host update prompt, and an icon-only offline indicator when the browser network state drops offline.
  • Owns the browser host document (index.html), including title, light/dark bootstrap theme-color metadata, installable icon links, and the early telemetry seed object.
  • Generates the browser-host icon set from the shared workspace logo.svg, keeping the favicon plus Apple touch and PWA PNG assets in sync.
  • Supports repo-root Nixpacks builds through nixpacks.toml, which runs pnpm --filter @lapis-notes/web build so deploy-time builds keep the full pnpm workspace available.
  • Provides a Docker Hub image path for static Web/PWA hosting as lapisnotes/web, built from the same repo-root package build and served by a nonroot distroless Caddy runtime on port 8080 with /healthz for deployment health probes. The published image version follows the web package CalVer from packages/web/package.json.
  • Mirrors the browser-host headers required for SharedArrayBuffer-backed SQLite (Cross-Origin-Embedder-Policy: require-corp, Cross-Origin-Opener-Policy: same-origin).
  • Configures vite-plugin-pwa to emit the web app manifest plus a generated service worker for offline startup.
  • Registers a web-app protocol handler for web+lapis:// where the browser supports installed-PWA protocol handling, routing launches through /open?url=... before the shared renderer dispatches the embedded app URL.
  • Provides the Web/PWA plugin asset server for verified installed plugin files, mirroring supported JavaScript, CSS, JSON, WASM, and image assets into Cache Storage under version/hash /__lapis/plugins/[vault-id]/[plugin-id]/[version]/[sha256]/[path] URLs.
  • Provides a dismissible host-level install affordance in the shared status bar when beforeinstallprompt fires and hides it after installation, explicit dismissal, or prompt consumption.
  • Surfaces pending service-worker updates through a host-owned bottom-right prompt with Later and Install Now actions instead of auto-reloading as soon as the browser detects a waiting worker.

Boot Surface

Entry Point (main.ts)

Reads the shared bootstrap appearance mode from IndexedDB-backed vault state, applies light/dark/system classes to the document root, initializes web-host document metadata, registers Window Controls Overlay handling, starts host theme-color synchronization, mounts the shared workspace app into #app, then calls registerWebPwa().

Web Host Document (pwa-host-document.ts)

  • Marks the host document with data-pwa-host="true" and data-runtime="web-pwa" before the workspace renderer mounts so PWA title-bar CSS can apply immediately.
  • Detects installed-PWA display modes and sets platform metadata (data-os, data-engine) used by the shared shell layout.

Host Theme Color (host-theme-color.ts)

  • Resolves the shared --workspace-chrome-background shell token from the same CSS variable chain used by sidebar and top-tab headers.
  • Replaces bootstrap light/dark theme-color metas with one dynamic meta tag once the host can read computed theme values.
  • Re-syncs when the document root appearance classes change or when prefers-color-scheme changes.

Window Controls Overlay (pwa-window-controls.ts)

  • Tracks installed-PWA display mode (standalone or window-controls-overlay) on the document root.
  • When the browser activates window-controls-overlay, sets data-pwa-titlebar-hidden="true" and mirrors navigator.windowControlsOverlay geometry into CSS custom properties so the shared workspace tab header can replace the native title bar on Chromium desktop PWAs. The web host document initializes data-pwa-titlebar-hidden="false" on boot and pwa-window-controls.ts keeps the attribute explicit ("true" in WCO, "false" in standalone) for every data-pwa-host="true" document. PWA chrome header borders and the optional :root::before divider paint only when data-pwa-titlebar-hidden="false".
  • Derives platform-specific safe-area insets from the overlay rectangle: macOS reserves leading space for traffic lights, Windows and Linux reserve trailing space for caption buttons, and all platforms reserve top space for the hidden title bar height. Collapsed title-bar geometry reports height 0 and clears the top inset so tabs stay visible instead of being negative-margin clipped.
  • The shared workspace shell CSS consumes those properties under html[data-pwa-host="true"] to draw the tab header edge-to-edge while keeping macOS traffic-light clearance. Top tabs and both sidebar dock tab rows remain visibly painted in collapsed and expanded Window Controls Overlay geometry because the sidebar tab buttons use explicit nav-item foreground colors instead of inherited text color; macOS left-dock clearance is split-scoped after subtracting the ribbon width, Windows/Linux right-dock clearance applies to the right sidebar tab row, and WCO chrome rows keep the shared sidebar header bottom border.
  • Existing installs must be removed and reinstalled after manifest changes for Window Controls Overlay to take effect; standalone fallback keeps the native title bar.

PWA Registration (pwa.ts)

  • Uses virtual:pwa-register so the generated service worker can report waiting updates without auto-applying them.
  • Skips service-worker registration during local development and clears stale dev registrations/caches so the shared workspace shell starts without PWA-induced reload races.
  • Marks the document as offline-ready when the service worker finishes preparing caches.
  • Tracks browser network state separately from offline-ready shell caching, mirroring the current network mode onto the document root and the shared status bar.
  • Waits for the mounted workspace app’s command registry and status bar services before registering browser-only install commands or status items, because globalThis.app can exist before all runtime services are initialized.
  • Registers browser-only status-bar items for Install and Dismiss when the browser exposes the deferred install prompt event.
  • Registers browser-only status-bar items for Update and offline network state, and mounts a host-owned prompt component above the shared status bar so update UI stays in the web host boundary instead of the notifications plugin.
  • Persists explicit dismissals in browser storage so the custom install affordance stays hidden across reloads while leaving browser-native install entry points available.
  • Hides the install affordance after installation completes or after the deferred prompt is consumed.
  • Polls for new service-worker versions when the PWA returns to the foreground or regains connectivity, then exposes Later semantics by hiding the prompt while keeping the waiting update available through the status bar until the user installs it or reloads.
  • Leaves native lapis:// handling to desktop shells. Browser/PWA launches use the web-supported web+lapis:// protocol handler and the /open?url=... route because browsers do not generally allow arbitrary bare custom schemes for installed web apps.
  • Installs a host-owned PluginAssetServer into the shared workspace mount. The server reads verified installed-plugin metadata from .obsidian/installed-plugins.json, checks installed file paths, sizes, and SHA-256 hashes through the shared API helper, writes supported assets into the lapis-plugin-assets-v1 Cache Storage bucket, and returns same-origin /__lapis/plugins/<vault-id>/<plugin-id>/<version>/<sha256>/<path> module URLs for future renderer ESM plugin imports.

Vite Host Config (vite.config.ts)

  • Reuses the same dev-time source aliases as the workspace dev shell for bundled first-party plugins, including explicit first-party UI subpaths such as @lapis-notes/ui/table-dnd/sensors and @lapis-notes/ui/table-dnd/utils that do not map through the wildcard component export shape.
  • Installs the shared plugin-host import-map Vite plugin so web/PWA HTML receives the generated app-owned host import map before renderer modules load.
  • Resolves $lib against the owning first-party source package during local development so workspace renderer modules and directly sourced bundled plugins can keep package-local imports.
  • Enables vite-plugin-pwa in generateSW mode with a manifest that prefers window-controls-overlay, falls back to standalone, and uses shell-aligned theme_color / background_color values instead of static brand blue.
  • Leaves Workbox in prompt-mode update flow by avoiding eager skipWaiting, so a newly downloaded service worker stays waiting until the host update prompt asks the user to install it.
  • Disables vite-plugin-pwa’s dev service worker so local web-host startup matches the workspace dev shell and avoids stale cached layout state during HMR.
  • Raises workbox.maximumFileSizeToCacheInBytes to 50 MiB so large browser-only runtime assets remain eligible for precache.
  • Adds a Workbox CacheOnly runtime route for /__lapis/plugins/ that serves only plugin assets already mirrored into Cache Storage by the active workspace runtime. This keeps dynamic module loading same-origin while avoiding a broad vault-file service worker route.

Build Context

The web host is built from the repository root when a deployment system performs a fresh install, because the package resolves workspace dependencies and source aliases against sibling packages across the pnpm monorepo. The repo-root nixpacks.toml captures that supported path by overriding the default Node build command to pnpm --filter @lapis-notes/web build while leaving the existing package-local build script authoritative for icon generation and the Vite bundle.

The repo also provides docker/web/Dockerfile for publishing the Web/PWA host as lapisnotes/web. The Docker build installs the @lapis-notes/web workspace dependency closure from the repository root, passes LAPIS_BUILD_COMMIT through to the Vite build so the shared About dialog can expose the source commit, labels the image with OCI title/description/source/revision/version/license metadata, and copies packages/web/dist into a Docker Hardened Images Caddy runtime (dhi.io/caddy:2). The runtime listens on port 8080, returns 200 ok from /healthz, disables Caddy admin/config persistence and automatic HTTPS, expects TLS termination at the ingress layer, serves hashed Vite assets with immutable caching, serves HTML/service-worker/manifest files with no-cache semantics, preserves 404 responses for missing file-like asset requests, and falls back to index.html for app routes such as /open?url=....

Icon Generation (scripts/generate-pwa-icons.mjs)

  • Copies the shared workspace logo.svg into public/favicon.svg.
  • Rasterizes the shared lapis.png app tile into padded PNGs for install surfaces: apple-touch-icon.png (180x180) and pwa-192x192.png / pwa-512x512.png / pwa-1024x1024.png center the logo at 82% scale (~9% inset per side), and dedicated pwa-*-maskable.png outputs center the logo at 80% scale for the maskable safe zone.

Offline Scope

The current web host precaches the built application shell and browser-local runtime assets, including large WASM and worker bundles that power SQLite, DuckDB, PDF rendering, and notebook execution. After a successful online load, the browser host can reopen the core app shell offline and continue working with browser-local vault and generated-state storage.

That offline guarantee currently applies to the shipped host shell and browser-local features. Optional features that intentionally fetch remote resources on first use, such as notebook dependency URLs resolved through esm.sh or transformer model assets pulled from Hugging Face, remain outside the precached shell and degrade when the network is unavailable.

lapis.md Site (@lapis-notes/lapis.md)

The lapis.md package is the deployable public documentation and marketing site for Lapis Notes. It serves the landing page, end-user help, and plugin developer docs as static HTML at https://lapis.md.

Source: packages/lapis.md/

Responsibilities

  • Builds a static Astro + Starlight site with dual doc trees at /help/ and /developers/.
  • Generates a TypeScript API reference under /developers/api-reference/ from the curated packages/api/src/lib/docs-api.ts entry point before Astro dev/build/check, preserving source-driven descriptions and Forgejo Defined in links for the published plugin API.
  • Provides a custom marketing landing page at / with CTAs to the app, help, and developer sections.
  • Publishes top-level legal pages at /privacy/, /terms/, and /license/, linked from the shared site header/footer and the Starlight docs header.
  • Uses a shared Obsidian-style multi-column site footer pinned to the page bottom on the landing and legal shells only; Help and Developer docs pages with sidebars omit it.
  • Top header nav links highlight the active section with a dark surface pill and matching ink text.
  • Narrows the desktop docs On this page rail to 18.75rem (~300px) with a custom PageSidebar.astro override so Starlight’s default viewport-percent TOC width does not expand the rail on wide screens.
  • Renders a starlight-site-graph interactive graph above the desktop docs On this page rail for /help/ and /developers/ pages, using the package’s sitemap generation while preserving the site-local PageSidebar.astro override. Generated /developers/api-reference/ pages are excluded from graph data and hide the graph panel because they are not graph nodes, keeping the Developer docs graph focused on authored guide pages.
  • Treats /help/ and /developers/ as first-class docs home pages inside the normal three-column docs shell instead of Starlight splash pages, so the left nav, center article, and right TOC appear immediately on load.
  • On wide Help and Developer docs pages, spare horizontal space is split into outer margins on both sides through --lapis-page-gutter, a custom TwoColumnContent.astro override, and matching shifts in PageFrame/header padding so the left nav, article, and TOC sit in a centered three-column shell instead of hugging the viewport edges. The /help/ and /developers/ home pages keep the same centered shell and additionally drop the usual header bottom border.
  • Applies an Obsidian-aligned public-site light/dark token map in src/styles/lapis-starlight.css, matching the app’s canvas, violet accent, hairline border, and compact radius language without importing app runtime CSS.
  • Uses the Lapis logo SVG as the site favicon at /favicon.svg.
  • Exposes root orchestration scripts: dev:site, build:site, and preview:site.
  • Produces a dist/ artifact for static hosting; DNS target for docs is separate from app.lapis.md.

Technology

PieceChoice
FrameworkAstro 5
DocsStarlight
Islandsstarlight-site-graph client element in docs rail
StylingSite-local light/dark token map aligned to app tokens
FontsPlatform system UI stack
OutputStatic dist/

Boot / Dev Surface

Local dev

pnpm dev:site

Build

pnpm build:site

The site package is self-contained for public-site styling and does not need the UI package to prebuild before Astro bundles styles.

Content Model

  • Landingsrc/pages/index.astro with shared SiteLayout.astro header/footer and generated product-evidence captures for editing, markdown/graph/canvas views, bases, plugins, notebooks, tasks, slides, search, and PDF. The lower markdown/graph/canvas and bases/plugins/notebook sections stack three full-width landing-band rows with large landing-shot--feature frames rather than a multi-column card grid.

  • Legal pagessrc/pages/privacy.astro, src/pages/terms.astro, and src/pages/license.astro use SiteLayout.astro and the site-local legal prose styles. They publish current-state policy text for local-first privacy, terms of use, package licensing, third-party notices, and trademark boundaries. Legal links appear in the landing/legal header and footer, and StarlightHeader.astro adds the same links to the desktop docs header.

  • User helpsrc/content/docs/help/** (getting started, VS Code-inspired UI layout/shortcut guidance, notes, a comprehensive editing reference, screenshot-backed plugin pages including a multi-page Notebook subsection, desktop/web, import, support, and attributions). The /help/ landing page is a normal docs article inside the shared sidebars-and-TOC shell rather than a standalone splash page.

  • Developer docssrc/content/docs/developers/** (plugin model, runtime overview, manifest, lifecycle, development workflow, App/vault/workspace/editor/events/metadata/notices/settings API surface, language and editor extensions, CSS contract, publishing, trust, and safe mode). The /developers/ landing page follows the same docs-shell treatment so plugin authors land directly in the main documentation layout.

    /developers/api-reference/ is generated from TypeDoc markdown through a site-local wrapper script that injects Starlight frontmatter, rewrites TypeDoc’s class pages from classes/ to class-docs/ for Astro route compatibility, and preserves repo source links back to Forgejo. The generated pages stay checked into the repo as normal site content and are refreshed explicitly with pnpm --filter @lapis-notes/lapis.md api-docs:generate. TypeDoc reads packages/lapis.md/typedoc.tsconfig.json, which extends the API dev tsconfig so first-party imports resolve from source and API reference generation does not depend on prebuilt dist/ artifacts.

    The Language & editor extensions section under /developers/language-extensions/ documents the public plugin surface for VS Code-inspired editing: CodeMirror/Lezer syntax registration, editor views and associations, LanguageServiceProvider, lapis.contributes manifest metadata, and CodeMirror extension registration. It maps those concepts from the VS Code language-extension model without documenting TextMate grammars, LSP wire protocol, or internal boot details.

The public site supports light, dark, and Default themes through Starlight’s theme toggle on docs pages and a matching control on the landing/legal header. Default is the initial selection on page load: the main site uses dark, while /help/ and /developers/ docs use light. Explicit light or dark choices persist in localStorage under starlight-theme across docs, landing, and legal shells via shared data-theme tokens in src/styles/lapis-starlight.css.

Public documentation content may adapt permissively licensed upstream documentation when attribution and license requirements are preserved on the site. Current attribution lives under Help and Support.

Public developer pages describe the supported plugin API only. Internal boot/runtime detail stays in spec/.

The public help docs may show syntax examples directly when Starlight can render them faithfully, but vault-aware markdown features that depend on the app renderer should be documented with syntax plus real captured app evidence rather than approximated by the site markdown pipeline.

Visual System

The public site uses an Obsidian-aligned software-documentation style with light and dark palettes:

  • Dark: canvas #171717, card surface #1e1e1e, hairline #262626, ink #e5e5e5, muted ink #b3b3b3, accent #a78bfa, and filled action violet #8a5cf5.
  • Light: canvas #ffffff, card surface #fafafa, hairline #e0e0e0, ink #222222, muted ink #5c5c5c, accent #7852ee, and filled action violet #6d47e0.
  • Platform system UI and monospace font stacks only.
  • Filled buttons use white text on violet. Sidebar active links use white text on #8a5cf5; inactive sidebar links use muted gray.
  • Cards and screenshot frames use 1px hairlines, compact radii, and app evidence rather than decorative illustration.
  • Docs code fences use Expressive Code with github-light and github-dark-dimmed themes on --lapis-surface-1 backgrounds, switching with the site theme.
  • The Help and Developer docs graph maps starlight-site-graph variables to the active Lapis token set and the bundled graph plugin’s subdued node/link language without importing app runtime CSS. Hovered labels use the primary violet separately from highlighted links, base labels stay compact for the sidebar, and fullscreen mode increases the graph scale to better use the expanded canvas.

Media Capture

  • pnpm media:site prepares e2e-vault-temp, boots the workspace dev server, creates a fresh browser vault, seeds the tracked e2e-vault dataset into it, restores .obsidian/workspace.json, captures the landing hero from that default layout, and for each view-specific still runs Workspace: Focus Leaf (app:focus-leaf) before screenshotting the full .workspace shell.
  • Generated assets live under packages/lapis.md/public/media/.
  • Landing stills use predictable landing-*.png filenames; plugin/help stills use help-*.png including help-notebook-view.png, help-notebook-dependencies.png, help-notebook-packages.png, and help-notebook-variables.png; workflow motion assets use help-*.gif filenames.
  • Keep still screenshots roughly 1280-1440px wide and keep animated GIFs short, dark-theme, and around 960px wide so the site ships optimized but readable media.

Domain Split

HostPackageRole
lapis.md@lapis-notes/lapis.mdDocs + marketing
app.lapis.md@lapis-notes/webInstallable PWA / app shell

Deployment

Production hosting is Cloudflare Pages at https://lapis.md.

PieceValue
Build commandpnpm build:site from the monorepo root
Output directorypackages/lapis.md/dist
Pages projectlapis-md (packages/lapis.md/wrangler.toml)
Forgejo workflow.forgejo/workflows/publish-lapis-md.yml

The publish workflow runs on pushes to main that touch packages/lapis.md/ or the publish workflow file. It checks the site package, builds static output, and deploys the prebuilt dist/ directory with Wrangler in the same job. Manual reruns are available through workflow_dispatch.

Forgejo repository secrets required for deploy:

  • CLOUDFLARE_API_TOKEN — API token with Cloudflare Pages → Edit for the account
  • CLOUDFLARE_ACCOUNT_ID — account ID that owns the Pages project

Create the Cloudflare Pages project named lapis-md, attach the lapis.md custom domain in the Pages dashboard, and point DNS at Cloudflare before the first automated deploy.

Production publication runs through Forgejo (.forgejo/workflows/publish-lapis-md.yml). Source code lives at code.ju.ma/lapis-notes/lapis.

Validation

pnpm --filter @lapis-notes/lapis.md check:all

Root pnpm check:all includes this package through Turbo. The site check runs pnpm --filter @lapis-notes/lapis.md api-docs:check before astro check; that non-mutating verification regenerates the API docs in a temp workspace, compares them against the checked-in src/content/docs/developers/api-reference/ tree, and fails on drift. TypeDoc still reads typedoc.tsconfig.json, which extends the API dev config so the docs pipeline resolves @lapis-notes/* imports from source and passes on a clean checkout without prebuilding UI or API dist/ output.

Troubleshooting

SymptomFix
lapis-ci image missingRun pnpm ci:image:build
Container startup / pull deniedEnsure the local image exists; the runner script already passes --pull=false
GitHub Actions clone auth failureSet GITHUB_TOKEN or run gh auth login so act can fetch actions/checkout and related actions
Platform mismatchRebuild with the matching platform: native pnpm ci:image:build or CI-parity pnpm ci:image:build:amd64
Smoke failures after startupRun pnpm test:smoke directly on the host for faster iteration; Electron + xvfb under act can be slow or flaky

Workspace App (@lapis-notes/workspace)

The workspace package is the visible renderer shell. It composes the API runtime, UI primitives, and plugins into the full note-taking application. It does not replace the runtime layer; it stages and renders it.

The installable browser/PWA host now lives in @lapis-notes/web. The approved desktop direction keeps this package as the shared renderer layer inside future host shells, while host-specific capability bridging, installability, and community-plugin isolation stay outside this package. See ADR-001: Plugin Runtime Rework.

Source: packages/workspace/src/

Boot Sequence

Shared Bootstrap (src/lib/components/app/bootstrap.ts)

Exports mountWorkspaceApp() and the shared bundled core-plugin list used by host packages. The bootstrap helper loads fonts (Shantell Sans variable, Source Code Pro), shared app CSS, and the bundled plugins: WordCount, LangCode, MarkdownPlugin, CsvPlugin, FmodePlugin, HistoryPlugin, FileExplorerPlugin, TagsPlugin, @lapis-notes/notifications, SearchPlugin, TasksPlugin, and BasesPlugin. AppPlugin is still registered by App.svelte as a required workspace-local core plugin. First-party plugin app.css sources are registered only for bundled plugin entries, so PluginManager injects those styles on enable and removes them on disable. The notifications plugin is required because it owns app-level notification and progress UI. Canvas, Graph, Markdown Lint, PDF, Slides, Telemetry, Docs, and Notebook are external official plugins: the workspace no longer imports, registers, scans CSS for, or bundles them by default. When one is installed from the verified official registry, its files load from /.obsidian/plugins/<id>/, its enablement remains compatible with community-plugins.json, and settings/search group it under Core plugins because installed-plugins.json records provenance: "official".

The shared shell also exposes a DOM-level hint-target contract for bundled keyboard navigation overlays. Workspace chrome that wants to participate in fmode-style navigation marks visible interactive elements with data-hint-target plus related data-hint-* metadata, and the runtime exposes those visible targets through Workspace.getVisibleHintTargets() rather than requiring the plugin to understand every shell component directly.

The shared src/app.css entrypoint declares a Tailwind source graph that includes workspace-local renderer source alongside API, UI, and bundled plugin source. It imports tailwindcss first, so Tailwind Preflight remains the shared renderer reset layer before the shared theme and workspace-shell overrides apply. Bundled plugin-specific app.css files are imported as inline CSS strings by the bootstrap module and handed to the plugin manager instead of being imported into the global workspace stylesheet, so local bundled-plugin style edits still hot-reload from source while plugin chrome follows enable/disable lifecycle. External official plugins are not listed in the workspace Tailwind source graph; installed official and community plugins load their packaged styles.css from /.obsidian/plugins/<id>/ when enabled. Host packages that import this stylesheet therefore emit the same workspace utility classes, including arbitrary sidebar sizing utilities, neutral hover-token classes, and workspace-owned CodeMirror lint chrome, as the standalone workspace shell.

The workspace Vite dev server now resolves its direct first-party workspace dependencies from their src/ trees instead of their published dist/ exports, including @lapis-notes/bases. It also aliases the shared UI CSS entrypoints (@lapis-notes/ui/theme.css, styles.css, and CodeMirror autocomplete CSS) to packages/ui/src/lib so live palette edits cannot be masked by stale local dist/ output. The dev esbuild target is pinned to es2022, which forces decorator-bearing package code such as Bases’ PEaQL table definitions to be transformed before the browser evaluates it. That gives Vite a live module graph for shared package edits, including package-local $lib imports, without the old rebuild watcher for package dist/ artifacts. The workspace Vite config also installs the generated plugin-host import-map plugin, which injects app-owned renderer import maps for public host wrapper modules under /__lapis/host/ in dev and emitted wrapper assets in production.

Because the module graph is evaluated before the Svelte shell exists, the bootstrap helper now buffers timing measurements so pre-mount import and mount delays can be replayed once telemetry becomes available.

Dev Entry Point (main.ts)

The workspace package still carries a thin browser dev-shell entry point, but it now just calls mountWorkspaceApp(document.getElementById("app")!) with the browser blob-backed plugin asset server used by dev and Playwright. The asset server reads verified installed-plugin metadata from the active vault and serves hash-checked object URLs for official ESM plugin entries without relying on the web package service worker. Host-specific browser responsibilities such as service-worker registration and install prompt handling live in @lapis-notes/web. E2E bootstrap may inject plugin distribution options before mount so local signed registry fixtures can replace the embedded official source during tests.

Vault Bootstrap (VaultBootstrap.svelte)

Phase-based state machine:

  1. “loading” — Attempts to restore a previous vault profile from CurrentVaultProfile.
  2. “choose” — Browser and desktop hosts now share the same branded two-column chooser: host-specific vault actions on the left and recency-ordered recent projects on the right, backed by the shared saved vault-profile store in IndexedDB (web) or the desktop bootstrap KV store (Electron). Browser actions are New Browser Vault (OPFS), Open Demo Workspace, Open Local Folder (File System Access), and New Legacy Vault (Lightning FS). Desktop actions are Create New Vault, Open Demo Workspace, and Open Vault for native folder vaults. The demo action opens a stable built-in sample vault from a generated bundle of the repo e2e-vault fixture, excludes plugin-test, strips stale .lapis/cache generated state, and marks the saved vault profile as a demo profile so reset flows can target it later. Normal demo opens are idempotent: after vault-session/database ownership is resolved and before App.svelte mounts, bootstrap validates the required fixture files, repairs missing or outdated fixture content, and preserves extra user-created files unless the user runs the explicit reset command. When no saved or recoverable vaults exist yet, the chooser defaults to a create-first landing on both hosts, promoting the create action and switching the hero copy from Open a vault to Create a vault; once recent or recoverable vaults exist, it falls back to the normal open-vault landing. Recent-project rows expose rename, copy vault id, remove-from-list, and browser-local delete-vault actions on all hosts; move and reveal actions remain desktop-only. Delete vault on browser OPFS and legacy profiles permanently clears stored vault files, generated state, and the saved profile after a confirmation dialog that requires typing the vault display name. Recoverable orphan OPFS rows expose the same delete flow using the humanized vault label. Bootstrap appearance (light/dark/system) is editable from the chooser settings dialog on both hosts. Desktop hosts restore legacy tauri-folder profiles through NativeDesktopVaultAdapter and migrate them to desktop-folder on open.
  3. “permission” — Requests read-write permission for File System Access handles.
  4. “blocked” — Fallback state used only when a competing tab already owns the per-vault SQLite OPFS database and delegation is unavailable.
  5. “ready” — Transitions to App.svelte.
  6. “error” — Error display.

On the Electron desktop host, the choose phase uses the shared chooser shell described above with desktop-specific folder actions, inline recent-project rows, a command-palette-style View all recent-project search, and a bootstrap-local settings dialog for light/dark/system appearance. Each inline recent-project row also includes a per-vault actions menu for copying the saved vault id, renaming the stored vault alias used by app URLs, moving the native vault folder, revealing the vault root in the host file manager, and removing the saved recent-project entry. The chooser appearance is driven by a desktop-global bootstrap setting stored alongside those saved profiles so the page can match the last selected app appearance before App.svelte mounts and before vault-scoped configuration loads.

On browser/PWA hosts, the same chooser shell loads recent projects from the renderer IndexedDB vault-profile store, applies bootstrap appearance when entering the chooser, and creates multiple isolated OPFS vaults under vaults/<vaultId>/ or multiple legacy vaults backed by per-profile Lightning FS IndexedDB stores. Legacy profile id lightningfs-default continues to use the original lightning-fs-1 store for backward compatibility. When OPFS vault directories exist without a matching saved profile, the chooser lists them under Recoverable vaults so users can re-register the profile without deleting stored files.

When browser SQLite is available, the bootstrap shell now coordinates per-vault ownership before it mounts App.svelte, but it defers the expensive appDatabase.open() work until the mounted shell begins background metadata hydration. The owning tab still becomes the only tab that starts local SQLite work, while competing tabs prefer to mount the normal app shell with a delegated AppDatabase proxy that forwards requests to the owner tab over BroadcastChannel and retries takeover in the background instead of silently opening a separate IndexedDB generated-state store. The blocked state remains as the fallback when delegation is not available. On the Electron desktop host, the same bootstrap path resolves the session runtime to electron-desktop, skips the browser-only SQLite coordination states, and hands the shared app shell a native folder adapter plus native AppDatabase bridge instead.

Vault bootstrap also records timing measurements around profile restore, profile opening, and vault-session creation so the diagnostics view can separate “time before any UI mounts” from “time spent resolving the active vault and app database”.

App Initialization (App.svelte)

Before the startup task runner begins, the shell installs a browser telemetry controller onto app.telemetry. The controller uses OpenTelemetry spans for app-owned lifecycle work plus browser web-vitals and longtask observers when enabled, and it can swap between a live browser exporter-backed implementation and a no-op implementation without changing the app.telemetry reference. Bootstrapping still seeds the controller from window.__LAPIS_TELEMETRY__ or matching localStorage keys (lapis.telemetry.enabled, lapis.telemetry.otlp.endpoint, lapis.telemetry.debug, lapis.telemetry.webVitals, lapis.telemetry.sampleRate, lapis.telemetry.slowSpanThresholdMs, lapis.telemetry.persistDiagnostics). The external official lapis-telemetry plugin, when installed and enabled, owns the diagnostics UI plus persisted telemetry settings, mirrors effective values back into those boot keys, and reapplies them on plugin load through its Core plugins settings surface. When the plugin enables diagnostics persistence, the controller also stores the recent traces, slow spans, measurements, and web-vitals buffer in vault-scoped IndexedDB state so the diagnostics view can restore them after an app restart. The controller keeps an in-memory working buffer so the installed telemetry plugin can show recent slow traces in a sortable, filterable, paginated table with a single segmented trace summary bar, plus a dedicated trace-detail header, stacked waterfall rows, a selected-span sidebar, and vitals without needing an external backend.

On startup, App.svelte first flushes the buffered bootstrap measurements into app.telemetry, then records the existing task-runner spans. This keeps the diagnostics timeline continuous across entry import, vault bootstrap, and sequential workspace initialization. The shell registers appearance, editor, files, and workspace configuration schemas before plugin loading; the workspace schema includes workspace.editorAssociations, a record map from VS Code-style glob patterns to registered editor-view IDs, plus the first mobile-shell configuration keys under workspace.mobile.*.

App.svelte also owns the workspace display-mode provider in the renderer package. It reads workspace.mobile.mode (auto, always, never) plus workspace.mobile.breakpointPx, observes the current viewport width, computes the effective workspace display mode (desktop or mobile), and updates app.workspace.displayMode through the API package’s non-persisted display-mode helpers. Changing display mode is presentation-only: it does not mutate or save workspace.json on its own.

The shell now also stamps the document root with data-runtime, data-engine, data-os, and native data-arch selectors derived from the active vault session, native desktop bridge platform metadata when available, and browser platform detection as a fallback. Workspace and host CSS can use that single root seam for WebKit-, Blink-, OS-, or architecture-specific regressions instead of scattering browser checks throughout individual components. The same root seam now exposes data-workspace-display-mode and data-workspace-mobile-requested-mode so shell-specific styling can react to the effective and requested mobile mode without asking individual leaves to know about viewport policy.

Sequential TaskRunner executes:

  1. Loading file systemapp.vault.load()
  2. Loading configurationapp.configuration.load() plus app.commands.loadHotkeys() for vault-scoped shortcut overrides
  3. Loading core plugins → Instantiates all core plugins
  4. Loading plugins -> registers the workspace CommonJS dependency resolver, registers explicit legacy CommonJS dependency overrides from deps.ts, gives PluginManager the host-provided plugin asset server when one was supplied by the Web/PWA or Electron bootstrap, reads installed plugin provenance, discovers external plugins, activates configured community plugins, and activates configured official plugins as optional core plugins
  5. Loading layoutapp.workspace.loadLayout()

The loading screen shows the active task plus the ordered startup checklist so users can see which initialization phases have already completed and which are still pending.

Once the shell mounts, App.svelte schedules app.metadataCache.load() on the next animation frame instead of blocking the loading screen on metadata restoration. That background load opens AppDatabase, restores the current metadata snapshot from the app database or portable .lapis/cache/metadata-cache.json backup, falls back to a first-open rebuild when no generated state exists, and notifies metadata-driven features through the existing loaded event when the cache becomes ready. The shell asks the metadata cache to flush any pending snapshot and portable backup write during teardown.

If any startup step fails, the shell no longer leaves the user on a misleading loading screen. It now renders a dedicated startup failure panel that shows the last boot step, the error detail, and recovery actions for reload, resetting the saved workspace layout, entering a session-scoped Safe Mode, disabling notebook execution for the next reload, and rebuilding generated metadata or search state.

Safe Mode state lives in browser session storage instead of canonical vault config, so recovery-only boot policies do not permanently disable plugins or layout restore. The ready shell shows a visible Safe Mode banner that explains which temporary restrictions are active and preserves the last startup failure summary; when a failing community plugin is identified, that banner also exposes a targeted disable action.

When the current session is using delegated SQLite ownership (sqlite-proxy) and a remote metadata snapshot request times out, the shell now warns through logging and continues boot with the legacy snapshot or an empty in-memory metadata cache instead of aborting startup.

Post-load setup:

  • Keyboard event handler forwarding to additional overlay scopes first, then intercepting bare Escape for transient workspace focus mode before delegating to the active leaf scope so focused editors do not trap the exit action
  • Optional console logger registration when window.__LAPIS_CONSOLE_LOGGING__ or localStorage["lapis.logging.console"] enables it
  • Telemetry disposal hook for shell teardown
  • Renders: ModeWatcher, Toaster, Tooltip, Commands, plus desktop-only StatusBar and PopoutWindowManager chrome when the effective workspace display mode is desktop

The required notifications plugin renders all app-level notification chrome after plugin activation. It bridges app.notifications to the workspace UI: in-app toast messages, compact progress and bell/count status items registered through app.statusBar, and a scrollable notification center panel toggled by the notifications command surface. The status-bar items keep inline text stable by showing only short task summaries while full progress detail lives in the item tooltip and notification center panel, and active progress items animate their icon as a spinner instead of expanding into a separate status banner. The existing Toaster container remains a renderer dependency, but callers should use app.notifications or the compatibility Notice wrapper instead of importing toast APIs directly.

The app plugin command surface now includes browser-local import and export commands, desktop-folder reveal, a shared About Lapis Notes command, saved workspace layout commands, transient workspace focus controls (Workspace: Focus Leaf for the selected root tab and Workspace: Exit Focus Mode), explicit mobile layout commands (workspace:toggle-mobile-layout, workspace:use-mobile-layout, workspace:use-desktop-layout, workspace:open-mobile-tabs, workspace:open-mobile-search, workspace:open-mobile-left-sidebar, workspace:open-mobile-right-sidebar), the existing metadata rebuild command, a demo-only Vault: Reset Demo Vault command, and a combined generated-state rebuild command that reruns metadata and search repair paths without deleting canonical notes. The mobile layout commands update the shared workspace.mobile.mode configuration or the current mobile page state instead of creating a second workspace layout source of truth; page commands show the existing mobile surfaces only while the mobile shell is active, while the sidebar commands fall back to expanding the corresponding desktop sidebar when the desktop shell is active. The reset command is available only while the active session profile is marked as the bundled demo workspace; it queues a one-shot reset marker, reloads the renderer, and lets VaultBootstrap.svelte rewrite the fixture and prune non-fixture files before App.svelte mounts so live watchers and metadata consumers do not race a full vault rewrite. The About command opens a small branded dialog in the shared renderer shell using the current Lapis logo, shows the app version plus the best available build timestamp, displays a GitHub-style short build commit hash on a copy button that copies the full hash, and offers a dedicated copy-version action. Browser-hosted workspace sessions also register a right-aligned version status item that shows the current app version with an info icon and runs the same About command on click; the item is gated by the runtime.browser context key so it does not render inside Electron desktop sessions.

Settings-target navigation inside an already-open Settings panel updates the selected surface directly instead of toggling the Settings dialog closed and open. Plugin management gear buttons and search result actions therefore keep the panel shell mounted while they switch to the target plugin or schema surface. The same Settings panel uses the shared sidebar provider’s resize state for its navigation column, starting at 14rem and letting the rail update the provider width directly so the content pane remains visible while the navigation width changes. Its modal-scoped resize rail keeps the visible hover/focus separator at 2px while still using the normal shared sidebar drag hit target.

When a user opens a file whose handler is not currently registered, the shared workspace leaf opening path can show an on-demand official plugin install prompt from signed registry contribution summaries. Full Registry V1 uses this for .lapisdoc and .lapissheet: the prompt offers Install and open, calls the distribution manager with requireOfficial: true and enable: true, and reopens the original file after the plugin registers its editor view. Existing registered handlers still win, and failed installs leave the prompt visible with the specific installer error so the user can retry or navigate away.

Saved workspace layouts are Obsidian-style named snapshots stored in .obsidian/workspaces.json, separate from the live session layout in .obsidian/workspace.json. The manage command opens a compact dialog with a name field, Save action, saved layout rows, active-layout badge, relative modified time, per-layout Load, and immediate delete. The load command opens a command-palette-style picker that lists saved layouts with modified time and active state. Loading a saved layout replaces the current workspace tree, saves the loaded snapshot back to the live layout file, and records the saved layout name as active. The Save and load another layout command saves the current tree back to the active saved layout before opening the picker; if there is no active saved layout, it asks the user to save one first.

The vault file watcher ignores generated metadata backup events for .lapis/cache/metadata-cache.json and its generated parent folders before reload handling, so periodic backup writes do not reprocess metadata or reload the active vault.

The shared shell also dispatches app URLs after boot through app.urls. Desktop hosts can deliver queued lapis:// launches through the native bridge, while the browser/PWA host can route installed-PWA web+lapis:// protocol launches through /open?url=... before handing them to the same API service.

When the current session is using delegated SQLite ownership (sqlite-proxy), the status bar shows a built-in icon-only DB Proxy button that opens a short explanatory popover on click. The indicator follows live coordinated-database ownership, so it disappears after the tab finishes taking over SQLite locally.

The shared status bar now renders API-owned declarative items from app.statusBar on both left and right alignment tracks, sorted by priority with stable registration order for ties. Declarative items can mark their icon as spinning while background activity is in progress, and the shared button chrome forwards that state through aria-busy. Declarative buttons expose data-status-bar-item-id for renderer diagnostics and focused Playwright coverage. A dedicated compatibility host remains mounted inside the same shell so imperative Plugin.addStatusBarItem() callers can continue to append raw DOM items without blocking manifest-only or first-party declarative status bar contributions.

This is still the shared renderer boot flow. Host-specific capability bridges, such as the native desktop bridge, can now steer vault bootstrap into a runtime-specific session and provide platform metadata without forking the visible shell. Broader plugin-host selection remains a future renderer-shell responsibility.


Application Layout

App.svelte
├── DesktopWorkspaceShell or MobileWorkspaceShell (one at a time)
│   └── DesktopWorkspaceShell
│       ├── Sidebar.Root
│       │   ├── Ribbon (left icon strip)
│       │   ├── Left Sidebar (TabsSplit)
│       │   ├── Root Split (TabsSplit, center)
│       │   ├── Floating Pane Overlay (WorkspaceWindow -> TabsSplit)
│       │   └── Right Sidebar (TabsSplit)
│   └── MobileWorkspaceShell
│       ├── MobileWorkspaceStage or MobileTabsPage
│       │   └── MobileSidebarToggle + MobileViewActions + MobileFloatingDock (stage mode)
│       └── MobileActionsDrawer + MobileTabsActionsDrawer
├── PopoutWindowManager (desktop mode only)
├── Commands (command palette)
├── StatusBar (desktop mode only)
└── Tooltip (global tooltip handler)

App.svelte branches between shell variants instead of mounting both and hiding one with CSS. That preserves the one-shell invariant for WorkspaceLeaf.containerEl: a live leaf host is mounted in only one renderer shell at a time, avoiding view reparenting bugs when the display mode changes.

MobileWorkspaceShell is the touch-first presentation layer over the existing workspace model. It binds app.workspace.containerEl to a full-viewport mobile shell, removes the dedicated mobile top header, hides the full desktop ViewHeader while mobile mode is active, and relies on the shared editor inline title inside the active view content immediately before the .cm-editor-content editor host so file-backed mobile titles scroll away with the page. The default mobile editor surface remains a single-page active-root-leaf viewport instead of the desktop root tab/split interface. MobileWorkspaceStage mounts fixed-width left and right sidebar panels offscreen and translates around that one mobile leaf page so pointer-driven pan gestures can reveal sidebars without rendering the whole desktop main interface in the center. Horizontal pan gestures can start anywhere on the main stage surface (including mid-screen) to reveal or dismiss mounted sidebars. When a sidebar is already revealed, pointer capture is taken immediately on the close overlay so swipe-to-close stays reliable. The stage defers to scrollable descendants only when movement clearly scrolls that ancestor on the matching axis and the scroller can still absorb more movement there (for example a task Bases .task-bases panel with overflow: auto does not block a horizontal sidebar reveal while scrollLeft is pinned at 0); otherwise horizontal movement crosses a pan threshold and max-angle gate before preventDefault. Touch drags do not call setPointerCapture (scrollable editors steal capture immediately); mouse/pen drags may capture when a sidebar is already open. Mobile leaf surfaces use touch-action: pan-y so the browser keeps vertical pans on scrollable content, switching to touch-action: none on the stage and editor chain while a horizontal pan is active. Active pans listen on document for pointer move/end when needed (touch from pointerdown; mouse only after a horizontal drag commits) so touch drags continue after incidental lostpointercapture events without settling early. Sidebar width is measured via ResizeObserver instead of per-gesture layout reads; scroll-target detection and debug layout snapshots run only after movement crosses the pan slop. Mobile scroll chrome follows native scroll events rather than synthetic touchmove deltas. The stage follows horizontal pointer movement while dragging, clamps between left/center/right snap points, and settles to a resting snap point on release using drag distance and fling velocity so cancelled or vertical-dominant gestures cannot leave an intermediate offset behind. The right sidebar reveal aligns the panel’s right edge to the mobile viewport instead of reserving desktop right-ribbon space, and tapping or dragging the exposed main page closes the revealed sidebar.

Mobile sidebar panels render the selected sidebar child body directly, omit the desktop sidebar tab-header strip, and provide a bottom name-based selector for switching sidebar tabs. Revealed sidebar panels are excluded from stage swipe capture so their toolbar and menu controls remain clickable. A small floating left-sidebar toggle mirrors the floating top-right action cluster derived from the active root leaf’s view.actions; both top controls are mounted inside the translated middle panel and hide when editor scrolling moves down, reappearing when scrolling returns upward. The compact floating dock is also positioned inside the middle panel instead of fixed to the page, hides with the other scroll chrome, avoids reserving a footer-sized content block, and exposes back, forward, left-sidebar search, new-note, main-tab count, and overflow actions; the bottom overflow surface uses a Vaul-backed shared Drawer wrapper; app-owned context, dropdown, and popover menus render through the shared drawer on mobile-width viewports; and the mobile tab viewer limits itself to main/root tabs rendered as compact live preview tiles capped around 176x200, with the title under each preview, activate-on-tap, and top-right close controls. The tabs page has no top heading; fixed bottom controls keep the open-tab count in the center, Done at bottom right, and a MobileTabsActionsDrawer for New Tab, Undo close tab, and Close tabs actions. The mobile shell mounts settings dialog content and a hidden status-bar compatibility host so desktop-era settings and imperative status-bar plugins remain available without a visible mobile status bar. The one-shell invariant still applies: the mobile editor page, mobile tab previews, and sidebar reveals are mutually exclusive leaf-host surfaces, so a WorkspaceLeaf.containerEl is mounted in only one place at a time.

Three-column layout using Sidebar.Provider + Sidebar.Inset + Sidebar.NestedProvider:

  • Left: Ribbon + left split (collapsible).
  • Center: Root split (dynamic width based on sidebar sizes).
  • Floating pane overlay: viewport-level floating-pane host that renders persisted WorkspaceWindow containers on the named workspace floating layer while leaving uncovered areas pointer-transparent so existing root drop targets still receive drag/drop. Floating pane content is wrapped in the same workspace shell styling context as the main split so shared top-tab and stacked-tab selectors apply consistently inside floating windows.
  • Right: Nested sidebar (collapsible).
  • Drag-enabled rails on left/right edges. The rail separator keeps its hit area but grows from a 2px neutral line to a 4px var(--interactive-accent) line on hover and focus-visible.

Renders ribbon items (toggleable), TabsSplit for left workspace views, and shell controls in the footer. Browser-local and desktop vault sessions now share a footer vault switcher trigger showing the current vault name, a dropdown of up to eight recent vaults, and a Manage Vaults action that returns to the chooser while preserving the existing settings button. The bundled file explorer now mounts immediately and shows an “Opening vault” loading state while vault contents are still loading so the left sidebar does not appear blank during bootstrap, and it repopulates the left sidebar with the default explorer leaf when restored layout state would otherwise leave that side dock empty.

The file explorer toolbar exposes create-file, create-folder, filename sort, auto-reveal, and expand/collapse controls. The sort menu supports filename ascending and descending modes while preserving the existing folder-before-file grouping inside each folder.

When the left sidebar is collapsed, the shared sidebar rail now offsets its visible hit target by the desktop titlebar clearance token so the left-edge reopen affordance clears the native macOS traffic-light cluster instead of starting directly beneath the ribbon/titlebar seam.

Simple sidebar wrapping TabsSplit for the right workspace split. The root container uses overflow-hidden so the shared rail hover/focus line is clipped to the pane edge the same way as the left outer sidebar.


Tab System

tabs-split.svelte — Recursive Split Layout

Renders a Resizable.PaneGroup (horizontal or vertical based on split type). Recursively renders child splits or tab groups. Empty state shows a drag hint. Emits app.workspace.requestSaveLayout() on pane resize. Manages pane sizes via view.sizes array. Split handles keep their existing drag targets while the visible separator line thickens from 2px to 4px and switches to var(--interactive-accent) on hover and focus-visible.

Empty View

The workspace package owns the app-facing empty-tab renderer used for normal new tabs and missing-view fallbacks. Normal empty tabs keep the standard centered create/open/recent actions. When the workspace falls back because a requested view type is no longer registered, the empty view reads state.__missingViewType, shows that id in the tab title, and swaps the center content for a dedicated unavailable-plugin message with a close action.

tabs-top.svelte — Horizontal Tab Bar

Top tab strip with:

  • Scroll container for overflow.
  • Top tab label icons from each view type.
  • Markdown tabs allow plugin-provided CSS overrides for icon display in top tabs.
  • Tab width: max - (count × 40px).
  • Close on middle-click.
  • Drag-drop reordering.
  • The reorder marker aligns to the visible tab edge and animates briefly as the drag target moves between tabs.
  • On the Electron desktop host, native macOS window dragging is restricted to non-interactive chrome instead of the tab strip itself so HTML5 tab drag-drop and drop indicators still work inside the frameless shell.
  • Active tab highlight.
  • Add (+) button for new tabs.
  • Respects editor.alwaysFocusNewTabs config.
  • Each leaf renders its ViewHeader above a flexed view-content region so file-backed views stay confined to the remaining tab body instead of stacking a second full-height pane under the header.

Double-clicking a root top tab now enters a transient focus mode for that tab group. The promoted group is rendered as a fixed shell layer spanning the full app chrome across both sidebars without moving the leaf into a floating pane or mutating persisted layout. The focused strip adds a visible Exit focus mode button, and global Escape routing leaves focus mode even when the active leaf editor would otherwise consume the key.

tabs-stacked.svelte — Stack Menu

Dropdown menu showing all tabs with toggle between stacked/regular view.

The stacked header bar now participates in the same desktop drag-region contract as the top and sidebar tab headers: the non-interactive bar surface is draggable on desktop hosts while the add-tab, overflow, and sidebar-toggle controls stay no-drag so their click handling continues to work.

Stacked tab headers share the same transient focus-mode behavior as top tabs: double-clicking a stacked root tab promotes that tab group across the full shell, preserves the in-place leaf/view state, exposes the same explicit exit control, and leaves overlay menus or popovers above the focus surface through elevated shell z-index rules.

tabs-sidebar.svelte — Sidebar Tabs

Vertical toggle group with icon + label per top-level sidebar child. A normal WorkspaceLeaf keeps the compact icon tab behavior and view context menu actions. A WorkspaceSidebarGroup renders as one top-level sidebar tab and expands into a vertical stack of collapsible child panels. The expanded panels are arranged in a vertical Resizable.PaneGroup, so users can resize the open panel bodies and the workspace saves the resulting proportions on the group. Each panel mounts the real leaf view, stretches the expanded content area to the available pane height, shows the leaf icon/title plus item-view actions, and keeps view context-menu hooks available from the panel header. The chevron and the panel title both toggle collapse, collapsed panels shrink down to the header row so their space is returned to expanded siblings, and when every panel is collapsed the headers pack together at the top of the stack instead of splitting the remaining height. Grouped sidebar tabs also preserve the shared sidebar toggle button in the header, so the active grouped tab can still collapse the left sidebar without switching back to a normal tab first. Sidebar tab buttons use --background-modifier-hover for hover feedback and --background-modifier-active-hover for selected state, while grouped panel headers use --sidebar-accent so both dark and light sidebars keep visible hover contrast.

Sidebar group menus support renaming the group, changing the icon, managing visible panels, ungrouping back into normal sidebar tabs, closing hidden panels, and closing the group. Normal sidebar tabs can be converted into a group through a lightweight metadata dialog, and the group metadata flow now uses the shared icon picker instead of a freeform icon text field. The visible-panels dialog toggles persisted hidden leaf IDs without destroying the underlying leaves.

Sidebar shell styling is placement-aware rather than view-type-specific. When leaves or tab groups are moved into a sidedock, workspace-shell.css applies dock outer borders, pane separators, tab/header borders, grouped-panel borders, and collapsed-sidebar boundary lines from sidebar boundary variables so a sidebar-hosted pane stays visually separated even when its view body uses the main editor background. Top tab containers, filler areas, overflow controls, and active tab wrapper outlines stay on the original tab-shell elements while resolving those colors through the tab or sidebar boundary tokens.

Installed browser PWAs that run with Window Controls Overlay use the same sidebar tab markup, but workspace-shell.css now keeps root top-tab and stacked-tab headers in their normal pane flow instead of pinning them to the viewport safe rectangle. Each root header measures how much the host-reported blocker area overlaps that pane-owned titlebar surface, reserves only the overlapping left or right inset as internal padding, keeps the non-interactive header surface draggable, and leaves interactive descendants no-drag. Sidebar headers now also feed that measured overlap into the split-scoped sidebar padding rules, so left and right dock tab rows can reserve more than the static macOS traffic-light or Windows/Linux caption-button gap when the reported blocker region is larger than the baseline safe-area token. The shared shell still expands the sidebar tab-header flex basis by the host-provided title-bar top inset, keeps the inner sidebar tab row at a normal minimum header height, and assigns the sidebar tab buttons explicit nav-item foreground colors so the left and right dock SVG icons stay visibly painted in collapsed and expanded overlay states instead of relying on inherited text color. The left dock reserves only the macOS split-scoped traffic-light gap after subtracting the ribbon width, the right dock reserves the Windows/Linux caption-button inset on the inner tab row, the narrow ribbon is not padded by the full macOS traffic-light width, and the unified bottom border of the WCO title-bar strip is painted by a 1px :root::before divider at --workspace-safe-area-top rather than per-bar border-bottom rules (which would otherwise show through under the active top-tab notch). When the native title bar is visible in a PWA host (data-pwa-titlebar-hidden absent), the same border-bottom treatment applies only to sidebar-tab and stacked chrome-header rows, not the top-tab outer bar, so the active-tab notch continues to clip the inner .workspace-tab-header-container border cleanly. Collapsed docks render matching 2px ::after/::before boundary lines below the header zone using --workspace-sidebar-pane-border so body-zone seams stay aligned with the shared 2px sidebar-rail and resizable-handle affordance. Only the top-tab and stacked chrome-header rows raise their z-index to keep painting over neighbour chrome; the sidebar bar inherits its natural stacking order so resizable pane handles and dock layers paint cleanly above it, and [data-sidebar="rail"] is offset by calc(-1 * var(--workspace-safe-area-top)) so the collapsed-sidebar reopen rail extends up into the safe-area strip.

packages/workspace/e2e/pwa-title-bar.spec.ts now clones the production WCO media rules, injects simulated native-control blocker rectangles, and asserts that pane-owned top titlebars stay aligned with their host panes while interactive controls never overlap those forbidden regions on macOS and Windows, alongside sidebar painted-icon contrast, split-scoped inset checks, blocker-based non-overlap assertions for sidebar tab buttons, WCO border checks (including a :root::before divider probe, an outer-bar border-bottom guard against active-tab notch leakage, sidebar-rail and collapsed-dock boundary width probes, and a sidebar-rail extends-into-safe-area assertion), an elementsFromPoint probe asserting the sidebar dock radio is the topmost paint at its own centre, a bundled demo-workspace (e2e-vault) WCO seam regression, and reference screenshots of the macOS top-left/top-right and Windows top-right corners under e2e/__screenshots__/pwa-title-bar.spec.ts/.

Workspace Playwright coverage now exercises the grouped sidebar path in the real renderer shell: grouped panel rendering, group menu and visible-panel basics, grouped-tab sidebar-trigger visibility, top-level sidebar-tab pointer reordering, title-toggle collapse behavior, all-collapsed header packing, vertical panel resizing, serialized panel-size state, expanded content filling the available panel body, and pointer-driven floating-pane creation for grouped sidebar panels.

Separate workspace Playwright coverage also exercises pen and touch layout drag/drop in the live shell, including top-tab reordering, split drops, grouped-sidebar panel reordering, drag-out floating panes, floating-pane move/resize/collapse/minimize/maximize/close, floating stacked-tab styling, floating markdown-link local navigation, popout theme-color mirroring, menu-driven float actions, reload-time bounds and collapsed/minimized state persistence, redock, pointer cancellation, touch long-press activation, flick cancellation, and jitter-under-tolerance behavior, alongside dark/light hover contrast for sidebar and workspace chrome controls, notebook-owned runtime and output status messaging, a Chromium-scoped daily-use journey that boots an OPFS vault, creates and renames notes, verifies search indexing, executes a notebook, reloads the shell, and confirms the renamed note and restored notebook outputs survive restart, and a fresh-browser app-boot smoke path that reports startup checklist and shell-readiness state when boot times out or surfaces the startup-failure panel. The workspace-shell visual baseline forces light mode before screenshot capture so local OS color-scheme preference does not change the committed snapshot target.

Workspace Vitest coverage is supported through the package-local vitest.config.ts, which resolves first-party packages to source and provides jsdom browser shims for renderer-only helper imports. pnpm --filter @lapis-notes/workspace check:all runs static validation (format, svelte, types, lint). pnpm --filter @lapis-notes/workspace test runs the full Vitest suite. pnpm --filter @lapis-notes/workspace check:unit remains a focused unit gate for plugin feature diagnostics, settings search, and the CommonJS dependency resolver when you want that narrower test subset directly. pnpm --filter @lapis-notes/workspace test:e2e:official-plugins runs the local official plugin install matrix against a signed fixture generated by root pnpm test:official-plugins; it installs every external official plugin through the real registry installer, verifies default enablement/provenance/runtime diagnostics/reload persistence, and performs one runtime assertion per plugin.

Dynamic width: calc(var(--sidebar-width) - var(--ribbon-width)).

Sidebar-tab reorder markers align to the visible tab button edge and animate briefly as the drag target moves between tabs.

Grouped sidebar drag/drop extends the shared workspace layout drag model: top-level group tabs can be reordered with normal sidebar tabs, grouped panels can be reordered vertically, leaves can be dropped into a group, and grouped leaves can be moved back to normal sidebar tabs.

The app shell now also supports in-app floating panes. Each floating pane wraps a persisted WorkspaceWindow split tree in dedicated overlay chrome above the full workspace interface, supports focus/z-order changes plus move, resize, collapse, minimize, maximize, restore, and close interactions, and reuses the same tab/split drop targets for redocking. Floating pane tab content keeps the workspace shell class context, so stacked tab headers keep the same vertical styling as stacked tabs in the main center split. Collapsed and minimized pane states persist with workspace layout JSON, minimized panes render as a bottom-center restore stack, and maximized panes are transient session state that covers workspace chrome until restored. Dragging a top tab, stacked tab, sidebar tab, sidebar group, or grouped sidebar panel out of all registered workspace drop targets now creates a floating pane instead of silently ending the gesture. Top-tab overflow menus, tab context menus, sidebar tab context menus, grouped-panel context menus, and command-palette commands expose the same floating and supported-host popout actions.

Floating-pane chrome now owns a named viewport-level overlay layer above the surrounding sidebar shell so collapse, restore, maximize, minimize, and close controls remain the browser’s top pointer target even when an expanded right sidebar is present. Maximized panes use a higher in-overlay layer while staying transient session state, reserve the desktop macOS traffic-light safe area on the left side of their header, and keep that non-interactive header surface available for native desktop window dragging while the toolbar controls remain explicit no-drag targets. Shared hover-card previews also render above floating panes, so markdown link previews are not hidden under floating window chrome.

The same WorkspaceWindow model now also backs true host popouts on supported renderer hosts. openPopoutLeaf(), moveLeafToPopout(), moveWorkspaceChildToPopout(), and paneType=window app URLs request a separate browser or Electron popup window through a host capability, mount the recursive TabsSplit tree into that document, mirror theme classes, titlebar-derived theme-color metadata, stylesheet, and plugin CSS changes into the secondary head, and register popup-scoped keydown and focus listeners so command routing and active-leaf tracking stay attached to the popout document. On desktop-styled macOS hosts, the leftmost popout tab header now reserves the full traffic-light safe area for both top and stacked tab variants so the first tab never renders beneath the native window controls. Closing the host popup removes the corresponding WorkspaceWindow from layout state and persists the cleanup. Unsupported hosts still surface the shared explicit popout error instead of silently converting the request into a floating pane.

Popup-owned trigger overlays such as the tab overflow menu now inherit their portal target from the shared UI primitives, which default to the trigger element’s ownerDocument.body. That keeps popup menus attached to the popup host without per-call-site portal wiring.

Popup shells also participate in the shared command-host model rather than forwarding palette state back to the root renderer. The workspace tracks a focused host id for the root shell and each WorkspaceWindow, the root app shell mounts the palette for WORKSPACE_ROOT_HOST_ID, and popup shells mount their own inline palette surface inside the popup document. Mod+P inside a popup therefore opens and executes against that popup’s leaf scope instead of rendering into the parent window.

The renderer still computes drop geometry and indicator placement locally, but committed layout mutations now route through shared Workspace helpers instead of mutating the split tree directly inside Svelte handlers. Top-tab reorder, content-area tab drops, split drops, grouped-panel reorders, grouped-panel ingestion, and top-level sidebar-tab moves all emit cancelable workspace lifecycle events before mutating layout and finish with one committed layout-change event tagged with source: "drag-drop".

DragState now resolves workspace layout DnD through auto, pointer, and html5 modes. Desktop mouse drags keep the existing HTML5 path and drag-image fallback, while pen and touch drags use pointer-event sources, registered drop targets, elementsFromPoint() target resolution, and a renderer ghost that does not depend on DataTransfer. Touch drags use a hold delay plus pre-activation tolerance so quick flicks cancel without mutating layout, while held drags can still reorder tabs, split panes, move sidebar tabs, reorder grouped sidebar panels, and float dragged items when no registered workspace drop target accepts the gesture.

On the Electron desktop host, the sidebar tab rows and drop targets are kept out of native -webkit-app-region: drag zones so dragging a sidebar tab into another pane still reaches the renderer drag-drop handlers.

tabs-drop.svelte — Drop Zone Handler

Tracks drag position with 4-edge threshold detection. Visual feedback (blue highlight). Handles leaf and file drops. Auto-selects tab after 2-second hover. Before showing an overlay, it asks the shared workspace API whether the drop target should be exposed by firing layout-will-show-overlay; committed center and edge drops then call shared workspace helpers so drop cancellation, split creation, and final layout persistence follow the same model-level path as tab reorder.

On the Electron desktop host, internal tab/file drags use a renderer-supplied drag image and force a move drop effect so the shell does not fall back to the native copy badge while dragging between panes.

tabs-move.svelte — Inline Drop Indicator

Shows left/right drop zones between tabs for reordering and drives a shared animated edge marker for top and sidebar tab strips. Indicator eligibility is gated through the shared workspace overlay event path, and accepted tab or sidebar-group reorders delegate to Workspace.moveWorkspaceChildToTabIndex().

leaf.svelte

Mounts a leaf’s view by injecting leaf.containerEl into the DOM via a Svelte action. Re-renders on leaf.id change.

view-header.svelte

Tab header with:

  • Back/forward navigation buttons.
  • Breadcrumb trail (file path with hover reveal).
  • Inline file rename (editable title) that truncates with ellipsis when the center header space is narrower than the pane actions and now delegates through app.fileManager.renameFile() so note-link rewrites use the shared link formatter.
  • Context menu: split down/right, close.
  • Ellipsis menu button.

View-header buttons and breadcrumbs are also one of the initial shell surfaces that opt into the shared hint-target metadata used by the bundled fmode plugin.


Command Palette (commands.svelte)

  • Bound to the shared host-aware command-manager state in app.commands.
  • Filterable command and file lists ranked with the shared UI Fuse.js helper while bits-ui command filtering stays disabled.
  • Closing the dialog clears the previous input so reopening starts from an empty query.
  • File search mode: > prefix switches to vault file search.
  • Hotkey display with platform-aware Unicode symbols (⌘, ⌥, ⇧, etc.) sourced from effective command hotkeys, so custom overrides and explicit unbindings match runtime routing.
  • Text highlighting on matches.
  • Command labels parse VS Code-style icon tokens through CodiconLabel: codicon font for $(play), registry SVG for $(lucide:file-text), codicon-first fallback to registered icons for unqualified names, and literal text for unknown or malformed tokens.

The root shell still uses the normal overlay path, but popup shells render the command palette inline inside the popup document rather than relying on a cross-document portal. That keeps palette focus, keyboard handling, and command execution attached to the window host that opened it.

command-files.svelte

Standalone file search dialog with icon + path display. Closing it also clears the previous query.


Configuration UI (configuration.svelte)

  • Obsidian-style sidebar nav with flat groups: app-owned Options, then plugin settings surfaces under Core plugins and Community plugins.
  • Scrollable content area with dynamic form generation from JSON schemas.
  • The Plugin registry tab is the registry-facing plugin management surface. It keeps legacy Core Plugins and Community Plugins tabs available during the V1 transition while adding Installed, Browse, Updates, and Sources views backed by app.pluginDistribution. The tab labels installed official provenance from .obsidian/installed-plugins.json separately from manual community plugins discovered by PluginManager, shows the locked official registry source, and renders through shadcn-backed tabs, buttons, items, badges, and alerts. Browse results render as a denser responsive card grid that can use three columns at full settings width; cards keep scan metadata minimal with version, official status, bundle size, and latest-release update time when the verified detail record provides a releasedAt timestamp, while platform, category, install-state, and other badges move to the detail view. Card details open a full settings-width modal whose resizable left sidebar preserves the current filtered Browse result set while the main pane swaps between plugin README/detail content, including the verified bundle size. README markdown is fetched from the registry-hosted markdown artifact and rendered through the app markdown preview component so registry documentation follows normal markdown preview styling. Registry catalog refresh uses the Refresh button’s spinning icon as its only inline loading affordance rather than adding a separate progress banner. A header action lets users install a local .lapis-plugin file through the same signed official bundle verification path as registry installs, including API-side worker verification on web hosts when available. Install and update actions keep the initiating button busy-disabled, but live task state is reported through app.notifications: the status-bar item stays compact with the plugin task title and percent when download bytes are known, while the notification center shows detail text, the progress bar, and the Cancel action. The settings panel does not render a separate install-progress panel. Registry fetch and install failures appear as destructive alerts and notifications instead of hidden text fields, while user-cancelled installs do not become destructive registry errors. Installed official rows show the verified release bundle size when detail metadata is available and otherwise fall back to summing recorded installed files. The Updates view distinguishes compatible official updates, incompatible latest releases with reasons, and revoked installed official versions that can be disabled or updated to a compatible replacement; update rows include the target release bundle size.
  • When a schema expands to exactly one nested subgroup, the visible settings heading keeps the schema’s own title instead of replacing it with the subgroup key.
  • Declarative schemas render enum selects, enum-array multi-selects, range sliders, color pickers, and nested object-group headings; unsupported shapes fall back to an Edit in app.json button that opens the vault configuration file in a centered in-app floating editor pane.
  • Schema category headings render outside the setting rows, and each category’s generated rows sit inside a rounded, subtly tinted panel with internal dividers and spacing between categories. Row labels use the setting title without repeating the category breadcrumb.
  • Record-object schemas render as editable key/value tables. workspace.editorAssociations uses that control with glob-pattern keys and a value dropdown sourced from the live Workspace.editorViews registry while preserving saved values for disabled or missing views.
  • Settings writes surface schema-validation failures through the normal interaction path instead of silently accepting malformed values.
  • The settings dialog uses a narrower navigation rail and lighter content padding so the form pane uses more of the available width.
  • On small viewports, the settings content exposes an icon-only top-left sidebar trigger so the shared sidebar provider can reopen the settings navigation sheet without changing the selected settings surface.
  • The settings sidebar includes a command-palette-style search field above the flat navigation. Non-empty queries keep the normal sidebar visible and replace the main settings pane with fuzzy-ranked, grouped result cards built from schema-backed settings, app settings tabs, plugin rows, plugin manifest metadata, plugin diagnostics/features, and declarative configuration metadata. Results highlight matching text, then navigate to the canonical selected surface for the matched schema row, settings tab, or plugin row without mounting hidden imperative tabs for scraping.
  • Settings content renders only the selected surface; schema sections are not used as recursive sidebar nodes and scroll position no longer drives nav selection.
  • Reacts to config/schema updates, including file-link formatting controls exposed through the shared files.links.* schema.

Hotkeys Tab (hotkeys-settings.svelte)

The app-owned Options → Hotkeys page lists registered commands from app.commands, filters by text or captured pressed-hotkey tags, and can show only commands with an effective binding. The search input includes the capture control: clicking the keyboard icon swaps it for Press hotkey, consumes keydown events while active, exits on Escape, and commits captured shortcuts as removable tags after an idle gap. Entered search text shows an in-input clear button. Each row displays current bindings, Blank for unbound commands, plus/remove/reset controls, and conflict diagnostics for duplicate shortcuts. Edits call the CommandManager hotkey override API, persist to .obsidian/hotkeys.json, and update runtime routing without a reload.

Core Plugins Tab (core-plugins.ts)

Lists bundled plugins and installed official plugins with inline manifest description metadata. Non-required bundled plugins and installed official plugins can be enabled or disabled from this tab; required bundled plugins stay visible with a disabled toggle.

Bundled and official plugins that register PluginSettingTabs or declarative configuration surface them here. Rows with plugin settings show a gear button next to the enable toggle, and clicking it opens the imperative tab when present or the matching declarative configuration section otherwise. The external official lapis-telemetry plugin exposes its browser telemetry settings from this section after install instead of relying on manual window.__LAPIS_TELEMETRY__ or localStorage edits.

Rows that expose inline panel content show a trailing chevron disclosure affordance, start collapsed, and expand a shared inline Details | Features tabbed panel on a full-width second row under the plugin name and action controls when the row is activated. The Details tab shows manifest summary metadata (version and author when present, plus description). The Features tab uses a natural-height two-column category/detail layout for runtime status, activation events, and any available privilege or capability details, leaving scrolling to the main settings pane instead of adding nested scrollbars. Core plugin rows intentionally omit version and author metadata, so those rows can show only the Details tab for description-only content or only the Features tab when feature metadata exists. Rows without details metadata or feature sections stay non-expandable and keep their visible summary metadata. Rerenders destroy and recreate the mounted Svelte instances so toggles and restart actions do not duplicate embedded panels.

The installed telemetry diagnostics plugin also registers Clear telemetry traces, which clears the live diagnostics buffer and any persisted vault-scoped IndexedDB copy.

Community Plugins Tab (community-plugins.ts)

Lists installed plugins with version/author. Toggle enable/disable, hotkeys, restart, and uninstall buttons. Plugins that register PluginSettingTabs or declarative configuration also show a gear button next to the enable toggle that jumps straight to the imperative tab or declarative configuration section.

Community plugin rows that expose inline panel content use the same trailing chevron disclosure affordance, start collapsed, and expand a shared inline Details | Features tabbed panel on a full-width second row under the plugin name and action controls. The Details tab shows manifest summary metadata (version, author, description). The Features tab uses the same natural-height two-column layout to group plugin state into Runtime Status, Activation Events, merged permission or capability pills, Commands, Configuration, Languages, Views, Services, and other indexed contribution buckets. Runtime Status starts with loader badges for ESM, CommonJS compatibility, hybrid fallback, Electron sidecar, fallback used, missing dependency, deprecated host API, and reload required, then lists selected host, module format, fallback entry, fallback-used status, reload policy, asset URL mode, version/hash URL, declared/scanned shared dependencies, undeclared or missing dependencies, deprecated host APIs, and private host APIs when those diagnostics exist. Empty sections are hidden, activation events and permissions render as monospace outline badges, command entries render as actionable item rows with trailing chevrons that execute through the app command registry, and trusted-desktop, loader, or invalid-contribution problems surface through inline alerts. Plugins that failed manifest preflight or module evaluation before registration still appear as indexed diagnostic rows with restart and disable actions, so unsupported runtime, capability, dependency, or contribution declarations remain understandable without editing files manually. Rows without details metadata or feature sections stay non-expandable and keep their visible summary metadata. Settings-search navigation reveals collapsed plugin rows before scrolling and highlighting them. Rerenders destroy and recreate the mounted Svelte instances so toggles and restart actions do not duplicate embedded panels.


Built-in Features (Core Plugins)

These features are loaded like plugins but live in the workspace package.

AppPlugin

Commands:

  • app:new-note (⌘N) — Creates a new uniquely named markdown note at the vault root and opens it in the active leaf.
  • app:import-local-vault — When the active vault uses OPFS, prompts for a local folder, copies its files into the current browser vault with overwrite semantics, reports copy progress through app.notifications, and keeps the reload action for applying imported .obsidian settings.
  • app:open-vault — Clears the stored current vault profile and reloads the shell back to the vault chooser.
  • app:restart-with-community-plugins-disabled — Restarts the current session in Safe Mode with community plugins disabled for the next boot only.
  • app:restart-without-restoring-layout — Restarts the current session in Safe Mode and skips saved layout restoration for the next boot only.
  • app:open-settings (⌘,) — Opens settings dialog.
  • app:toggle-left-sidebar — Collapses/expands left sidebar.
  • app:toggle-right-sidebar — Collapses/expands right sidebar.
  • app:toggle-ribbon — Hides/shows ribbon (updates appearence.interface.showRibbon).
  • app:split-down — Horizontal split of active leaf.
  • app:split-right — Vertical split of active leaf.
  • app:rebuild-vault-cache — Rebuilds metadata cache.

Registers VSCode icon pack (@iconify-json/vscode-icons).

FileExplorerPlugin

View type: "file:explorer".

The file explorer context menu includes a Copy Path submenu. Every file or folder can copy its vault-relative path, native desktop vaults can also copy the absolute system path, and files can copy an encoded lapis://open?vault=...&file=... URL. When the active host advertises native file-system actions, the same menu adds a separated desktop block for opening the selected item in the OS default app and revealing it in Finder, File Explorer, or the platform file manager. Browser/PWA sessions hide those native-only actions because OPFS and File System Access do not expose stable absolute paths or OS reveal/default-app hooks. On mobile-width viewports, app-owned file explorer menus use the shared drawer renderer instead of anchored context or popover menu surfaces.

Hierarchical folder tree with:

  • Folder expand/collapse toggle.
  • File open on click.
  • Context menu: new file/folder, rename, delete, refresh.
  • Rename and drag-drop move flows delegate through app.fileManager.renameFile() so note-link rewrites use the shared link formatter.
  • Context menus are cached per visible path so active-file selection changes do not rebuild menu state for the whole tree.
  • Create new file/folder with auto-naming (Untitled, Untitled 2, …) while selecting the new item in the explorer; newly created files also open in the active leaf.
  • Explorer rows are rebuilt from the loaded vault entries rather than the vault’s nested child arrays so empty folders stay visible and duplicate child-path state during reloads does not break keyed rendering.
  • Keyboard rename mode.
  • Custom icon fetcher: maps file extensions to VSCode icons (vscode-icons:file-type-*).
  • Special icons for .obsidian and plugin folders.
  • Auto-reveal current file (configurable via file explorer toolbar and Workspace settings as workspace.fileExplorer.autoRevealCurrentFile; expands ancestor folders, scrolls the active file into view, and applies on load for the active file).
  • Drag-drop reordering.

Commands: file-explorer:reveal-path, file-explorer:show-file-explorer.

TagsPlugin

View type: "tags".

Metadata-driven tag browser with:

  • Counts derived from the current metadata cache, aggregated once per file.
  • Search filtering and sort-order controls.
  • Optional nested hierarchy display for slash-delimited tags.
  • Expand-all and collapse-all control for nested tag trees, enabled only while hierarchy mode is active.
  • Automatic refresh on metadata cache changed, deleted, and loaded events.

Commands: tags:show-tags.

WordCount Plugin

Registers editor extension via statusBarEditorPlugin. Updates status bar on editor changes (debounced 20 ms).

Calculations: Word count (Unicode-aware), character count, sentence count (.!? delimiters). Display: "{N} words" + "{M} characters". Click → menu with "X min read" (from editor.readingSpeed config).

LangCode Plugin

Registers TextFileView for non-markdown languages:

  • javascript.js
  • typescript.ts
  • json.json, .data
  • text.txt, .text
  • yaml.yaml, .yml
  • css.css, .scss, .less

Registers matching CodeMirror language extensions (javascript(), javascript({ typescript: true }), json(), yaml(), css()). The TypeScript view registers the browser TypeScript provider from @lapis-notes/language-service and mounts shared language-service completion, hover, diagnostics, and lint-gutter adapters for .ts files.


Hooks and State

drag-state.svelte.ts

Tracks drag operations: leaf, file, dropPosition, mousePosition. dragStart(event, leaf) creates a drag ghost element.

task-runner.svelte.ts

Sequential async task execution: addTask(name, executor, options?), start(). Reactive message property for progress display.

The shell currently wraps startup tasks in telemetry spans (app.boot, app.boot.task) so boot regressions can be attributed to the specific database, vault, plugin, layout, or metadata phase instead of only surfacing as a slow initial render.

watch-vault.svelte.ts

File system watcher. Listens to vault events (create, modify, delete, rename). Debounces with recentInternalEvents map (7.5 s TTL). Closes views for deleted files, updates references on rename, emits "file-change" for external changes.

watch-color-scheme.svelte.ts

Syncs appearence.baseColorSchema with userPrefersMode.current (mode-watcher).

watch-metadata.ts

Initializes app.metadataTypeManager.trackChanges() on mount.

watch.svelte.ts

Generic effect watcher: watch(getter, cb) calls cb(prev, current) on reactive changes.

useBoundingRect.svelte.ts

DOM measurement: useBoundingRect({resize?, scroll?, observe?}){ref, rect, update()}.

use-text-highlight.ts

Svelte action for highlighting query matches. It builds text nodes and <span class="suggestion-highlight"> nodes instead of assigning raw HTML, treats regex characters literally, and supports multi-word matching.


Configuration Schemas

Appearance (appearence.schema.json)

  • appearence.baseColorSchema — enum: dark / light / system
  • appearence.accentColor — color picker
  • appearence.font.fontSize — 10–30px
  • appearence.font.quickFontSizeAdjustment — boolean
  • appearence.interface.showInlineTitle — boolean
  • appearence.interface.showRibbon — boolean
  • appearence.interface.showTabTitleBar — boolean
  • appearence.advanced.zoomLevel — 10–30

Editor (editor.schema.json)

  • editor.alwaysFocusNewTabs — boolean
  • editor.defaultViewForNewTabs — enum: editing / reading
  • editor.defaultEditingMode — enum: live-preview / source
  • editor.readingSpeed — 100–600 wpm
  • editor.display.* — readable line length, fold, line numbers, indents
  • editor.behaviour.* — spellcheck, indent tabs, indent width

Workspace (workspace.schema.json)

  • workspace.mobile.mode — enum (auto by default) choosing whether the renderer uses the mobile shell automatically, always, or never
  • workspace.mobile.defaultPage — enum (editor by default) choosing whether the mobile shell opens on the active leaf or the tabs page
  • workspace.mobile.showBottomNav — boolean (default true) controlling whether the mobile shell shows the floating dock
  • workspace.mobile.includeSidebarsInTabs — boolean (default true) controlling whether the mobile tabs page lists left and right sidebar leaves
  • workspace.mobile.includeFloatingInTabs — boolean (default true) controlling whether the mobile tabs page lists floating and popout leaves
  • workspace.mobile.breakpointPx — number (default 768, clamped to 320..1280) defining the auto-mode viewport threshold
  • workspace.editorAssociations — record map from VS Code-style glob patterns to editor-view IDs
  • workspace.fileExplorer.autoRevealCurrentFile — boolean (default false); syncs between the file explorer toolbar and Workspace settings

Styling

CSS Architecture

app.css imports Tailwind, the shared @lapis-notes/ui/theme.css core theme layer, workspace custom CSS files, and bundled plugin source app.css entries from the monorepo. The workspace is the only full Tailwind app entry and does not import standalone plugin dist/styles.css assets for bundled plugins; published dist/app.css remains the package export for external consumers, while local workspace development stays on source CSS paths. External official plugins load packaged styles.css only after installation. The first-party artifact rules are documented in Plugin CSS Contract.

@lapis-notes/ui/theme.css owns the canonical palette, semantic aliases, and shared Obsidian compatibility helpers. Workspace-local workspace-shell.css owns the remaining app-specific shell variables and selectors for tabs, stacked tabs, ribbons, status chrome, hover state routing, placement-aware sidebar boundaries, tab border routing, and layout behavior, but it consumes the shared canvas, surface, text, border, neutral interaction, and accent tokens instead of acting as a separate palette source. Dark mode is the primary Obsidian-aligned target; light mode remains supported.

The effective style-system contract is documented in Style System.

The workspace currently pulls built plugin styles from sibling packages/plugins/*/dist/*.css outputs instead of package-exported CSS subpaths because the dev CSS pipeline does not consistently resolve linked workspace package exports. Search and Bases styling are imported from packages/plugins/plugin-search/dist/app.css and packages/plugins/plugin-bases/dist/app.css, not standalone CSS outputs. Shared palette and semantic theme variables live in @lapis-notes/ui/theme.css; workspace-local stylesheets like workspace-shell.css and codemirror.css consume those variables instead of redefining the palette.

Tailwind content sources:

  • packages/api/src
  • packages/ui/src
  • packages/plugins/plugin-markdown/src
  • packages/plugins/plugin-bases/src
  • packages/plugins/plugin-search/src

Custom stylesheets: workspace-shell.css (workspace shell theming), codemirror.css (editor styling), codemirror-lint.css (workspace-owned lint chrome), app.css (app-specific).

Workspace shell BEM hooks

Workspace-owned renderer chrome exposes additive workspace-shell__* BEM classes in Svelte markup alongside existing Obsidian compatibility classes (workspace-tab-header, view-header, status-bar-item, and similar) and Tailwind utilities. Do not remove or rename the legacy classes when adding hooks; append the BEM classes in the same class attribute.

  • Block: workspace-shell on the ready-state layout root in App.svelte
  • Elements: workspace-shell__segment (double underscore), for example workspace-shell__status-bar-left
  • Modifiers: workspace-shell__segment--modifier (double hyphen), for example workspace-shell__vault-chooser--loading
Hook prefix / blockOwning Svelte surfaces
workspace-shell__main, __split, __pane, __split-emptysidebar-root.svelte, tabs-split.svelte
workspace-shell__tabs, __tab-header*, __leaf*, __drop-overlaytabs-top.svelte, tabs-sidebar.svelte, tabs-stacked.svelte, tabs-drop.svelte, tabs-move.svelte
workspace-shell__sidebar-group*grouped sidebar panels in tabs-sidebar.svelte
workspace-shell__view-header*, __view-hostview-header.svelte, leaf.svelte
workspace-shell__status-bar*status-bar.svelte, proxy-status-item.svelte
workspace-shell__ribbon*, __sidebar-footer, __settings-triggersidebar-left.svelte
workspace-shell__file-explorer*file-explorer.svelte
workspace-shell__tags*tags.svelte
workspace-shell__command-palette*, __file-dialog*commands.svelte, command-files.svelte
workspace-shell__settings*configuration.svelte, setting-tab.svelte
workspace-shell__startup*, __safe-mode-banner*, __startup-error*App.svelte
workspace-shell__vault-chooser*VaultBootstrap.svelte
workspace-shell__empty-view, __import-toast*empty-view.svelte, import-vault-toast.svelte

Existing CSS in workspace-shell.css and app.css continues to target Obsidian-style selectors. New theme, snippet, test, or host overrides should prefer the workspace-shell__* hooks.


Plugin Dependency Injection

Generated provider values from the host-module catalogue supply public CommonJS host modules in the plugin require() context:

  • obsidian@lapis-notes/api
  • @codemirror/* → CodeMirror packages
  • luxon — Date/time
  • zod — Schema validation
  • clsx — Class utility
  • bits-ui — Headless components

deps.ts exports only explicit legacy/private compatibility overrides and browser fallbacks, including selected @lapis-notes/api/* and @lapis-notes/ui/* subpaths, historical Lucide icon subpaths, Svelte legacy/private subpaths, @lapis-notes/markdown, and browser-compatible crypto, url, and util shims.

Desktop Electron Package (@lapis-notes/desktop-electron)

The Electron desktop package is the first-party native desktop shell for Lapis Notes. It lives at packages/desktop-electron and mounts the shared renderer from @lapis-notes/workspace.

See ADR-003 for the decision rationale.

Responsibilities

packages/desktop-electron owns:

  • Electron app lifecycle (main process, BrowserWindow creation, app events)
  • Native window behaviour (title bar overlay, traffic-light positioning on macOS)
  • Native drag affordances for tab/header regions that need to initiate window dragging from the shared workspace shell
  • Native menus (File, Edit, View, Window plus debug entries)
  • IPC command handlers for folder picking, revealing the selected vault root in the host file manager, vault-scoped file operations, scoped vault resource URLs, generated-state persistence, native file watching, native file actions, app URL launches, and native OS notification mirroring
  • Native notebook DuckDB sidecar process management for Electron notebook query execution, timeouts, crash recovery, and app-table, virtual-file, and validated vault-file registration
  • Native language-service Node sidecar (markdownlint + embedded TypeScript language service) routed through main-process IPC (desktop_ls_*) for renderer-backed LanguageServiceProvider registrations with bounded JSON payloads
  • Desktop-only runtime adapters (NativeDesktopBridge registration), platform/capability metadata, and shell metrics that tune renderer titlebar spacing
  • Package-local Vite config, TypeScript config, build/dev scripts, and Electron packaging metadata

Shared app behaviour remains in packages/workspace (renderer shell), packages/api (runtime kernel), and first-party plugins. Desktop-specific code belongs in this package only when it requires Electron main-process access.

Package Layout

PathResponsibility
package.jsonpnpm scripts for dev, build, preview, check, Electron packaging, and local desktop release publication
index.htmlDesktop host document with #app, title, theme metadata
src/main.tsRenderer entry: registers native desktop bridge, applies shell-provided CSS metrics, imports and calls mountWorkspaceApp()
src-electron/main.tsElectron main process: app lifecycle, BrowserWindow, IPC handlers, native menus, shell metrics passed to preload
src-electron/notebook-duckdb-sidecar*.tsNative notebook DuckDB sidecar manager and child process used for Electron notebook query execution
src-electron/language-service-sidecar*.tsNative Markdown/TypeScript language-service sidecar manager and forked Node child implementing markdownlint synchronous lint and embedded typescript language-service diagnostics, completion, hover, and definition
src-electron/preload.tsContext-isolated preload: exposes window.__LAPIS_NATIVE_DESKTOP__ bridge, native platform/capabilities, shell metrics, and native notification forwarding
e2e/Playwright Electron smoke helpers, startup-shell regression coverage, Plugin registry settings coverage, and shared-shell grouped-sidebar rendering/menu/resizing coverage; launch helpers isolate user data and strip ELECTRON_RUN_AS_NODE before starting Electron
playwright.config.tsPackage-local Playwright config for Electron automation
electron-builder.config.cjsPackaging identifiers, release-channel metadata, macOS/Linux distribution targets, and release artifact naming
build/entitlements.mac.plistHardened-runtime entitlements used for packaged macOS builds
build/icon.pngDefault packaged desktop app icon with a rounded-square background derived from the shared workspace branding asset
build/icon-light.png / build/icon-dark.pngAppearance-specific macOS dock icon variants selected at runtime so the running app matches light and dark system themes
vite.config.tsElectron renderer Vite config, dev port 1421, first-party source resolver, plugin-host import-map injection, cross-origin isolation headers
tsconfig*.jsonPackage-local TypeScript configuration
scripts/dev.mjsDev orchestrator: starts Vite, watches src-electron, restarts Electron after successful main/preload rebuilds
scripts/generate-build-icons.mjsPackaging preflight: regenerates build/icon.png, build/icon-light.png, build/icon-dark.png, and macOS build/icon.icns from packages/workspace/src/assets/lapis.png before Electron Builder runs
scripts/notarize.cjsEnvironment-gated notarisation hook for signed macOS distributions
../../scripts/release-desktop-local.mjsRepo-root local release orchestrator that verifies, builds selected desktop targets, stages checksums, creates the remote Forgejo tag/release, and uploads assets
spec.mdPackage-local spec summary

Native IPC Commands

The Electron shell exposes this native command surface through the shared API bridge:

  • desktop_pick_vault_folder — native folder-picker dialog (dialog.showOpenDialog)
  • desktop_create_vault_folder — native create-or-pick dialog for Create New Vault; returns the selected folder after ensuring it exists
  • desktop_open_demo_vault — resolves a stable Electron-owned userData/demo-workspace folder for the shared Open Demo Workspace chooser action and creates it on demand before the renderer seeds bundled fixture files into it
  • desktop_move_vault_folder — native choose-destination flow for moving an existing vault folder; moves the folder and relocates the per-vault generated-state database to the new vaultId
  • desktop_reveal_vault_folder — opens the active vault root in the host file manager so users can copy or back up canonical files outside the renderer
  • desktop_fs_resolve_path — resolves a vault-relative file or folder path to an absolute host path for copy-path actions
  • desktop_fs_to_vault_path — maps an absolute host path back to a vault-relative path when it is under the active vault root
  • desktop_fs_open_path — opens a vault-relative file or folder through the OS default app
  • desktop_fs_reveal_path — reveals a vault-relative file or folder in the host file manager
  • desktop_fs_exists / desktop_fs_stat — path existence and stat queries
  • desktop_fs_read_text / desktop_fs_read_binary — vault-scoped file reads
  • desktop_fs_get_resource_url — returns a scoped lapis-vault-resource:// URL for read-only vault media and embeds
  • desktop_plugin_assets_register — registers a verified installed plugin asset context for the active renderer window so the main process can serve lapis-plugin://[vault-id]/[plugin-id]/[version]/[sha256]/[path] module URLs without exposing arbitrary vault files
  • desktop_fs_write_text / desktop_fs_write_binary — vault-scoped file writes
  • desktop_fs_list — directory listing
  • desktop_fs_mkdir / desktop_fs_rmdir / desktop_fs_remove — directory and file removal
  • desktop_fs_copy / desktop_fs_rename — file copy and rename
  • desktop_fs_watch_start / desktop_fs_watch_stop — host-native directory watching bridged into the shared vault watcher
  • desktop_db_load_state / desktop_db_save_state — native SQLite-backed generated-state persistence outside the vault; load probes return null without creating a SQLite file when neither SQLite nor legacy JSON state exists
  • desktop_vault_bootstrap_kv_get / desktop_vault_bootstrap_kv_set / desktop_vault_bootstrap_kv_set_many / desktop_vault_bootstrap_kv_get_many / desktop_vault_bootstrap_kv_del / desktop_vault_bootstrap_kv_keys / desktop_vault_bootstrap_kv_is_empty / desktop_vault_bootstrap_kv_import_if_empty — main-process JSON persistence for vault profile pointers, saved recent profiles, and desktop-global bootstrap settings such as chooser appearance so startup does not depend on renderer IndexedDB when the Chromium profile misbehaves
  • desktop_notifications_show — validates and mirrors selected durable app notifications through Electron’s OS notification API
  • desktop_ls_capabilities — returns { markdown, typescript } feature flags once the preload protocolVersion matches Electron main expectations (no sidecar spawn)
  • desktop_ls_update_document — pushes a sanitized VirtualDocument plus globals into the forked Node sidecar (document priming parity with worker document/update)
  • desktop_ls_diagnostics — returns diagnostics arrays for Markdown (markdownlint) or TypeScript/JavaScript (embedded LS)
  • desktop_ls_completions / desktop_ls_hover / desktop_ls_definition — position-based intelligence for TS/JS hosts
  • desktop_ls_code_actions — presently returns []; reserved while browser workers also return empty payloads

All file operations are scoped to the selected vault root. .., root, and absolute-subpath escape attempts are rejected. The resource URL command uses the same path validation and serves files through an Electron-registered lapis-vault-resource:// protocol instead of exposing arbitrary raw file:// paths to renderer-owned media views.

The desktop package registers lapis:// and the compatibility lapis-notes:// scheme with the OS. URLs received during cold start, macOS open-url, or Windows/Linux second-instance launches are queued in the main process until the renderer has mounted and can dispatch them through app.urls. The first supported action is open, including vault/file, desktop path, shorthand vault URLs, and pane-type hints where the shared workspace supports them.

Bootstrap Sequence

  1. Electron main process creates BrowserWindow with contextIsolation: true, nodeIntegration: false, and the context-isolated preload script.
  2. Preload exposes window.__LAPIS_NATIVE_DESKTOP__ with the IPC command stubs, native platform details, capability registry, and shell metrics via contextBridge.exposeInMainWorld.
  3. Renderer entry (src/main.ts) reads the preload bridge, applies shell-owned CSS metrics such as the macOS leading safe-area inset, calls setNativeDesktopBridge(bridge) on the shared API runtime, migrates any legacy vault profile keys from renderer IndexedDB into a main-process JSON store when that store is still empty, registers that main-process store via setDefaultVaultStateStore, and calls mountWorkspaceApp(). Before mounting the shared workspace shell, the renderer also reads the desktop-global bootstrap appearance setting from that same store and applies the matching light/dark document classes so the chooser can render in the expected theme before vault configuration becomes available.
  4. VaultBootstrap.svelte detects the native bridge and routes to the desktop-folder vault selection flow.
  5. VaultSession resolves to NativeDesktopAppDatabase, which persists the serializable AppDatabase state shape in a per-vault SQLite file under Electron app.getPath('userData') outside the selected vault folder. Metadata cache snapshots are additionally mirrored to .lapis/cache/metadata-cache.json as rebuildable vault-local generated state so copied/imported vaults can hydrate the app database when the host-owned SQLite file is absent.

Vault profile records (last-opened vault / saved profiles) persist under app.getPath('userData')/vault-bootstrap-kv.json in the Electron main process. The same file now also carries desktop-global chooser settings such as the bootstrap appearance mode. The renderer runs a best-effort, time-bounded migration from the legacy IndexedDB store (lapis-notes-vault-state) only while that file is still empty, so Chromium storage or service-worker database errors in the renderer profile are less likely to strand the shell on the “Opening vault” bootstrap screen.

The shared chooser now presents a desktop-first landing page on Electron: branded header, Create New Vault, Open Demo Workspace, Open Vault, inline recent projects, command-style recent-project search, and a settings link that edits only bootstrap-relevant appearance state. Create New Vault uses desktop_create_vault_folder, Open Demo Workspace uses desktop_open_demo_vault to resolve a stable Electron-owned demo folder before the shared renderer seeds its bundled fixture files, and Open Vault continues to use desktop_pick_vault_folder. On a first launch with no saved or recoverable vaults, the shared chooser defaults to the same create-first landing used by the browser host, promoting Create New Vault and switching the hero copy to Create a vault; once recent vaults exist it returns to the normal Open a vault landing. Recent projects remain the saved vault-profile records in the bootstrap KV store, ordered by most recent updatedAt. Inline recent-project rows now expose actions for copying the saved vault id, renaming the stored vault alias used by app.urls, revealing the vault root, removing the saved profile, and moving the vault folder. The move flow updates the saved profile root path and migrates Electron’s host-owned generated-state files from the old path-derived vaultId to the new one so search and app state continue to follow the moved vault.

Because Electron mounts the shared workspace bootstrap with the browser popout host enabled, openPopoutLeaf(), moveLeafToPopout(), and paneType=window requests now open a real secondary Electron popup window rather than falling back to an in-window floating pane. Electron main applies the same BrowserWindow shell policy to those window.open('about:blank') popouts that it uses for the initial app window, so secondary windows inherit the same hidden macOS title bar, preload bridge, and shell metrics before the shared workspace renderer mirrors document classes plus stylesheet and plugin CSS changes into the popup document, mounts the same recursive WorkspaceWindow -> TabsSplit shell there, and removes the popout from persisted layout state when the popup closes. The popout window itself does not mount a second top-level App; the owning app-host window keeps the live runtime, tracks focused popout state inside app.workspace, and receives native menu-open-vault events plus queued lapis:// app URLs on behalf of the focused popout.

The Electron native app database also mirrors prepared search documents into the same per-vault SQLite store and uses FTS5 for candidate selection on plain lexical searches. desktop_db_load_state checks for an existing SQLite file before opening the database so probing for missing generated state does not leave an empty vault-state/<vaultId>.sqlite3 file behind. If only legacy JSON state exists, load returns that JSON and migration waits until a later save. When a platform-compatible sqlite-vec extension is available, ready chunk embeddings are mirrored into a native vec0 table and vector/hybrid searches use that table for candidate selection before the shared evaluator builds final results. If sqlite-vec cannot be loaded or dimensions do not match, vector and hybrid searches fall back to the shared in-memory vector scorer over the mirrored documents.

The Electron notebook capability is available through a native DuckDB sidecar. Electron main forks a Node child process backed by @duckdb/node-api, routes notebook query and registration requests over IPC, enforces a 30-second request timeout, kills and restarts the sidecar after crashes or timeouts, and shuts it down during app quit. The sidecar keeps its own temporary working directory for registered DuckDB file aliases so existing notebook SQL such as read_csv_auto('alias') continues to work without syntax changes. Vault-backed CSV, Parquet, and JSON registrations pass the resolved notebook path and active native vault root to Electron main, which validates the path under the selected vault before the sidecar links or copies the file into that working directory. The notebook plugin injects this provider only when the preload capability registry advertises notebook as available; browser/PWA notebooks continue to use DuckDB-Wasm and byte-buffer registration.

The Electron plugin-sidecar capability is available as electron-plugin-sidecar with the desktop_plugin_host_* protocol family and an Electron-main-owned child-process boundary. Electron main now owns a trusted per-plugin sidecar keyed by vault/window context plus plugin ID, so crashes, timeouts, restart budgets, and cooldown failures are isolated per hosted plugin instead of shared across every trusted desktop plugin in the window. Each sidecar can prepare, evaluate bundled-only CommonJS Lapis desktop entries, activate/deactivate them, and route the initial parent-brokered capability set for vault I/O, plugin data, commands, notices, settings snapshots, metadata snapshots, event subscriptions, and logging. The sidecar CommonJS v1 resolver exposes only lapis and @lapis-notes/api from the generated host-module catalogue; local require() calls fail with a bundle-required diagnostic, and renderer-only dependencies such as obsidian, Svelte, DOM/CodeMirror view modules, and @lapis-notes/ui fail with explicit unsupported-sidecar dependency messages. The shared API runtime selects this host only for desktop/sidecar-aware community plugins or plugins that request brokered capabilities; baseline renderer-compatible community plugins continue through the renderer execution host. The durable design is documented in Community Plugin Host Boundary.

The Electron plugin-asset capability is available through preload as plugin-assets with provider electron-plugin-protocol. Renderer bootstrap installs a PluginAssetServer for native vault sessions; it reads verified installed-plugin metadata from the active vault, verifies the requested entry file once through the shared adapter path, registers the plugin/version context through desktop_plugin_assets_register, and returns a version/hash lapis-plugin:// URL. Electron main registers lapis-plugin before app readiness, tracks registered contexts by owning window, rejects unregistered vault/plugin/version/hash/path requests, and verifies the URL hash segment, file size, and SHA-256 against installed metadata before returning a JavaScript, CSS, JSON, WASM, or image response. Window cleanup removes owned plugin-asset contexts so the protocol cannot outlive the renderer vault session that registered it.

The Electron notifications capability is available through preload as electron-notification. The required notifications plugin still listens to app.notifications in the renderer, but only mirrors records that were persisted into generated app state. Preload forwards those payloads over desktop_notifications_show; Electron main validates the bounded payload, suppresses duplicate IDs for the window session, and calls Electron’s native Notification API when the host supports OS notifications.

The Electron language-service capability advertises language-service in the preload capability registry as electron-language-service-sidecar. Electron main forks a Node child modeled on the DuckDB manager (30-second request timeouts, restart after crashes/timeouts, shutdown on quit) and validates every payload for protocol version mismatches plus bounded document/global text lengths before forwarding work. Renderer entry (desktop-electron/src/main.ts) now registers only the native TypeScript provider during host bootstrap; the external official Markdown Lint plugin consumes the same desktop_ls_* capability through the shared native bridge and registers native markdownlint itself when installed and enabled, so markdown diagnostics ownership stays with a first-party official plugin rather than with the host bootstrap path. Browser worker providers remain registered as fallback parity, and LanguageServiceManager continues to merge diagnostics/code actions only among the highest priority tier while completions, hover, and definition already stop at the first successful provider sorted by descending priority.

Read-only vault resources requested through Vault.getResourceUrl() are delegated from preload to the main process, which returns cache-busted lapis-vault-resource:// URLs. Markdown image views, PDF views and embeds, and Bases card images consume that URL path so Electron avoids renderer-side full-file blob copies for those assets while the browser adapters keep their blob URL fallback.

When the bridge advertises native watch support, NativeDesktopVaultAdapter forwards watch requests through preload to Electron main-process chokidar watchers so the workspace can react to external file changes without falling back to polling.

The shared workspace shell uses data-desktop-drag-region markers on non-interactive chrome. In Electron, native window dragging comes from real -webkit-app-region drag surfaces on the non-interactive header regions such as tab spacers and the page-title header container, while interactive descendants stay opt-out no-drag regions so clicks, renames, and tab drag/drop keep working.

Dev and Build

# Start the Electron dev host (renderer Vite + Electron main)
pnpm dev:desktop
# or from the package
pnpm --filter @lapis-notes/desktop-electron dev

# Build the renderer bundle (also builds bundled workspace plugin dist outputs first)
pnpm --filter @lapis-notes/desktop-electron build

# Run checks
pnpm --filter @lapis-notes/desktop-electron check

# Validate the packaged app layout
pnpm --filter @lapis-notes/desktop-electron package:dir

# Build release artifacts
pnpm --filter @lapis-notes/desktop-electron dist:mac:all
pnpm --filter @lapis-notes/desktop-electron dist:linux

# Verify, build, tag, and upload a local desktop release (set FORGEJO_TOKEN in .env or export it)
pnpm release:desktop:local -- --version <version-without-v> --targets all

Dev port: 1421 (distinct from the workspace dev port 5173).

pnpm dev:desktop and pnpm --filter @lapis-notes/desktop-electron dev must always load the renderer from the live Vite source server via LAPIS_DESKTOP_DEV_SERVER_URL. The root Turbo dev task explicitly passes through LAPIS_DESKTOP_* variables so test-vault, user-data, devtools, and diagnostic launch controls reach the package dev launcher. The Electron dev host must not fall back to dist/index.html when running those dev flows, and the dev launcher strips inherited ELECTRON_RUN_AS_NODE before spawning Electron so package-manager or test environments cannot accidentally run the desktop main process as plain Node. Unless LAPIS_DESKTOP_USER_DATA_DIR is set explicitly, the dev launcher assigns a repo-scoped temporary user-data directory so a packaged Lapis Notes app running on the same machine cannot steal the single-instance lock or receive the dev launch as a second-instance event. In dev-server mode, closing the last Electron window quits the app even on macOS so the dev shell can shut down Vite and TypeScript watchers cleanly.

Packaged Electron builds use the in-package build/icon.png, a rounded-square app tile derived from the shared workspace branding asset at packages/workspace/src/assets/lapis.png, so the desktop shell ships an icon that reads like a native app icon instead of a floating transparent mark. Before package:dir / dist:* invoke Electron Builder, scripts/generate-build-icons.mjs regenerates build/icon.png, build/icon-light.png, build/icon-dark.png, and macOS build/icon.icns (on macOS) so x64/arm64 packaging does not fall back to the default Electron icon when generated build resources are missing. On macOS, Electron main also switches the running app dock icon between build/icon-light.png and build/icon-dark.png based on the current system appearance during both packaged runs and pnpm dev:desktop, while build/icon.png remains the packaged bundle fallback. The package metadata now also declares productName: "Lapis Notes", and Electron main still calls app.setName("Lapis Notes") at startup, so dev, e2e, and unpackaged runs use the branded menu and dock label instead of the default Electron binary name.

Electron main now also owns a small native app-info surface for branding and diagnostics. The shared renderer About Lapis Notes command calls desktop_app_info_get to read the desktop app version and build timestamp when the native bridge is available, while Electron’s native About panel is configured through app.setAboutPanelOptions() with the same name, version, icon, and a credits line that shows the build timestamp when the packaged or built bundle exposes one.

Release artifact names are stable and hyphenated for Forgejo downloads and Homebrew tap updates:

  • Lapis-Notes-<version>-mac-arm64.dmg
  • Lapis-Notes-<version>-mac-x64.dmg
  • Lapis-Notes-<version>-mac-arm64.zip
  • Lapis-Notes-<version>-mac-x64.zip
  • Lapis-Notes-<version>-linux-x64.tar.gz
  • Lapis-Notes-<version>-linux-x64.AppImage

pnpm release:desktop:local -- --version <version> --targets all is the local release path for a macOS developer machine. It checks that the input version matches packages/desktop-electron/package.json, refuses to release from a dirty JJ working copy unless --allow-dirty is passed, runs the repo checks plus smoke tests by default, builds macOS and Linux artifacts, writes SHA256SUMS.txt, and uses the Forgejo API to create/update v<version> plus release assets. On non-macOS machines, --targets current resolves to Linux and macOS targets are rejected.

The Forgejo desktop release workflow builds Linux desktop assets on ubuntu-latest, builds macOS arm64/x64 DMG and ZIP assets on macos-arm64, combines checksums, and publishes one release containing every desktop asset. The local macOS command remains available for developer-run releases and dry-runs.

The Electron Builder package scripts pass --publish never and set CI=1 plus ELECTRON_BUILDER_DISABLE_UPDATE_CHECK=true; Forgejo tag creation, release creation, checksums, and uploads are handled by repo release scripts rather than Electron Builder’s publishing integration or update checks.

During development, renderer source changes reload through Vite, while src-electron main/preload changes trigger a TypeScript watch rebuild followed by an Electron process restart.

Validation

  • pnpm --filter @lapis-notes/api build
  • pnpm --filter @lapis-notes/desktop-electron check
  • pnpm --filter @lapis-notes/desktop-electron build
  • pnpm --filter @lapis-notes/desktop-electron test:e2e:smoke (local dev renderer; CI uses full build)
  • pnpm --filter @lapis-notes/desktop-electron dist:linux
  • pnpm --filter @lapis-notes/desktop-electron dist:mac:all on macOS release machines
  • pnpm release:desktop:local -- --version <version-without-v> --targets all --skip-upload for local release dry-runs (FORGEJO_TOKEN in .env or exported)
  • pnpm --filter @lapis-notes/desktop-electron exec playwright test -c ./playwright.config.ts e2e/grouped-sidebar.spec.ts
  • pnpm --filter @lapis-notes/desktop-electron exec playwright test -c ./playwright.config.ts e2e/language-service-markdown.spec.ts
  • pnpm --filter @lapis-notes/desktop-electron package:dir
  • pnpm --filter @lapis-notes/workspace exec playwright test e2e/workspace-shell-visual.spec.ts -g "electron macOS sidebar header subtracts the ribbon width from the traffic-light inset"
  • Manual smoke against ~/Documents/vault-copy

Open Design Items

The Electron community-plugin host boundary is now designed in Community Plugin Host Boundary. Remaining implementation slices are documented on the owning package and contract pages.

Plugin Packages

Plugins are a first-class architectural unit. They provide both bundled product features and community-style extensions.

Package Groups

GroupSource pathBoot relationship
First-party plugin packagespackages/plugins/plugin-* packages that depend on @lapis-notes/apiEither registered by the workspace shell as bundled/core plugins or distributed as official registry-installable plugins
Obsidian-targeted packagesColocated packages that still depend on upstream obsidianNot part of the current workspace boot sequence unless a host loads them as community-style plugins
Workspace-local featurespackages/workspace/src/lib/feature/Behave like core plugins but are owned by the workspace package

First-Party Plugins

PackageResponsibility
MarkdownMarkdown, media, properties, outline, metadata, and editor behavior
Markdown LintOfficial installable markdown diagnostics registration across browser workers and Electron
SearchSidebar search, indexing controls, semantic-search status, and search UI
TasksBrowser-first task capture, filtering, and bundled task persistence
Bases.bases and .base structured data views
CanvasJSON Canvas editing over .canvas files
CSVCSV/TSV source and editable table preview
DocsUniver-backed native document and sheet editing
FmodeHint-based keyboard navigation for opted-in workspace chrome
GraphGlobal and local note graph visualization
HistoryPlanned persisted text-file revision history, history view, and restore flow
NotificationsRequired in-app notification center, toasts, and progress status
NotebookNotebook files, markdown cells, and runtime-facing notebook views
PDFEmbedPDF-backed .pdf file views and markdown PDF embeds
SlidesReveal.js-backed presentation views over markdown notes
TelemetryDiagnostics leaf, telemetry settings, and persisted trace buffers

Canvas, Graph, Markdown Lint, Notebook, PDF, Slides, Telemetry, and Docs are external official plugins. Their packages remain in the monorepo for building and publishing official artifacts, but the workspace does not register them in the bundled core list. Installed official records with provenance: "official" still appear under Core plugins and activate through optional-core Safe Mode policy. Bases remains bundled and non-installable for this phase.

Obsidian-Targeted Packages

PackageResponsibility
SpellcheckerHarper-backed grammar and spelling diagnostics

The spellchecker package keeps its own permissive package metadata because it is an Obsidian-targeted compatibility package rather than a bundled Lapis core plugin in the current app boot sequence.

Styling Ownership

First-party plugin UI should follow the shared Plugin CSS Contract. The contract owns the current CSS artifact rules and rollout tracker so individual plugin pages can describe package-specific selectors without duplicating global policy.

Workspace-Local Built-in Features

The workspace package also contains features that behave like plugins but are not distributed as separate packages:

FeaturePurpose
AppPluginCore commands, ribbon actions, icon pack
FileExplorerPluginFile tree view and reveal-path command
WordCountStatus bar word/character/sentence count
LangCodeText views and language extensions for JS, JSON, YAML, CSS, plain text

These are documented in detail on the Workspace App page.

Community Plugin Loading

Plugin Runtime owns the common runtime contract for bundled and community plugins. The workspace shell now creates a CommonJS dependency resolver from generated host-module metadata and generated provider values, while deps.ts is limited to explicit legacy/private overrides and browser fallbacks, so plugins can still require() common packages such as obsidian, selected @lapis-notes/api/* exports, @codemirror/*, luxon, zod, clsx, and bits-ui.

This allows community plugins written for the Obsidian API to run with minimal or no modification when the active host supports the required APIs.

Architecture Patterns

PatternPlugins Using
CodeMirror extensionsmarkdown, spellchecker
Svelte view componentsmarkdown, search, bases
Web Workersmarkdown metadata, markdown-lint, spellchecker linting
External enginessearch, bases, spellchecker
Custom parsersmarkdown, bases
HTML post-processingmarkdown (list callouts)
Metadata processorsmarkdown
Type widgetsmarkdown

Markdown Plugin (@lapis-notes/markdown)

Source: packages/plugins/plugin-markdown/src/

Manifest: id markdown, version 0.0.1, min app version 1.7.7.

The markdown package is the primary document-editing plugin. It owns Markdown language support, media viewing, outline/property side views, markdown preview rendering, metadata extraction, and most CodeMirror extensions used by note editing. Metadata-driven markdown surfaces are expected to scope cache reactions to the active note or its direct reference neighborhood rather than treating every metadata event as a full-view refresh trigger. Markdown preview surfaces that receive shared Editor instances are expected to scope change subscriptions to the specific editor instance they mounted so embed/file swaps can tear down safely even after the live prop is cleared.

Non-image file embeds route through the app embed registry so other plugins can supply plugin-owned inline viewers without widening shared preview sanitization rules. The rehype embed component resolves short embed paths against the active source note through MetadataCache, so embeds render from either full vault paths or the same shortest-path link text used elsewhere, and it re-resolves mounted embeds when metadata finishes loading, when the source note changes, and when the directly resolved embed target changes or disappears. Vault create, delete, and rename events remain the fallback path for new shortest-path resolutions that do not have enough metadata context yet. Image media views consume Vault.getResourceUrl() and revoke URLs on teardown, which lets Electron use the scoped native resource protocol while browser vaults keep blob URL fallback behavior. In the rich editor, standalone ![[...]] paragraphs mount through the same markdown block-widget renderer path, so live preview uses the same file-embed surface as reading mode for PDF and other non-image embeds. That registry path now also carries first-party read-only Bases and Canvas file embeds for .base/.bases and .canvas targets. The package also exports a reusable read-only FileEmbed surface so other plugin UIs can reuse the same image, section, custom-embed, and text fallback behavior outside markdown documents.

The package now also exports narrow embed surfaces through @lapis-notes/markdown/embed: read-only MarkdownEmbed, editable EditableMarkdownPreview, and read-only FileEmbed. Official external plugins use that subpath when they need markdown surfaces without importing the full markdown plugin root graph. MarkdownEmbed accepts plain markdown text plus optional source-path and plugin-extension hooks, and renders preview output without requiring an Editor instance, plugin lifecycle, or app-owned directive/postprocessor registries. The first embed slice intentionally excludes app-resolved file embeds, custom directive registration through app.markdownDirectiveRenderers, and markdown postprocessor execution.

Markdown style ownership is split by surface. Shared variables and rendered markdown rules live separately from source-mode, live-preview, reading-mode, and embedded-mode overrides under src/styles/. The stable mode hooks are markdown-source-mode / markdown-source-view, markdown-live-preview-mode / markdown-live-preview-view, markdown-reading-view, and markdown-preview-surface--embedded / markdown-embed-surface, so CSS selectors can target one surface without relying on broad global markdown selectors or :not(.cm-live-preview) fallbacks.

Top-tab-specific markdown CSS hides markdown view icons only for top tabs, while leaving stacked and other tab header variants to display their icons.

The tracked e2e-vault/plugin-markdown/CodeMirror Layout Showcase.md fixture is the focused manual and automated sample for CodeMirror layout behavior. It covers GFM tables, grid tables with inline markdown, blockquotes, quoted lists and checklists, nested list indentation, continuation paragraphs, line wrapping, embeds, internal links, tags, math, fenced code, and Mermaid across source mode, live preview, and reading mode.

Inline Mermaid blocks render as scroll-friendly static SVG previews in reading mode and live preview, while the Mermaid expand dialog owns the interactive svg-pan-zoom controls for large diagrams.

Registered Views

View TypeClassExtensionsPurpose
MarkdownViewTypeMarkdownView.md, .markdownSource, preview, and live-preview editor
MediaViewTypeMediaView.jpg, .jpeg, .png, .svg, .bmp, .gifImage and asset viewer
AllPropertiesViewTypeAllPropertiesView-Property browser across all files
OutlineViewTypeOutlineView-Real-time heading tree
FilePropertiesViewTypeFilePropertiesView-Frontmatter editor for the active file
file:backlinksBacklinksView-Active-note linked and unlinked reverse references
file:outgoing-linksOutgoingLinksView-Active-note resolved outgoing links and unlinked mentions

Public Embeds

  • @lapis-notes/markdown/embed exposes MarkdownEmbed, EditableMarkdownPreview, and FileEmbed for markdown preview surfaces that official external plugins can bundle without importing the full markdown plugin root graph.
  • @lapis-notes/markdown continues to expose MarkdownPreview, EditableMarkdownPreview, FileEmbed, and NoteLink for richer plugin-integrated use cases. MarkdownPreview accepts frontmatterOpen (default true); FileEmbed forwards frontmatterOpen (default false) so note-link hover previews and inline embeds start with the Properties block collapsed while reading mode and live preview remain expanded.

Commands

  • show-all-properties
  • show-outline
  • show-file-properties
  • show-backlinks
  • show-outgoing-links
  • show-links-sidebar

Markdown owns Obsidian-inspired Backlinks and Outgoing Links side views. Both register as sidebar views on the right side under the grouped sidebar tab named Links with Backlinks above Outgoing links. The plugin seeds that group only when the right sidebar has no saved children; existing non-empty user layouts are preserved until a command explicitly opens a view.

The Backlinks view follows the active markdown note. Its Linked mentions section groups cached links and embeds from other markdown notes that resolve to the active file. Its Unlinked mentions section searches indexed markdown document content for exact active-note basename and alias matches, excludes matches already inside link/embed ranges, skips frontmatter, and falls back to vault reads for files that are not yet present in the generated search index.

The Outgoing Links view follows the same active markdown note. Its Links section lists resolved internal links and embeds from the active note grouped by target file. Its Unlinked mentions section scans the active note for exact basename and alias matches for markdown files that are not already linked by that note.

Both sidebars use the same compact sidebar result language as All Properties and Search: fixed icon toolbar, optional search panel, collapsible section titles, collapsible result groups, search filtering, context expansion, sort menu, and scrollable rows. Result rows open files in the main workspace area; backlink rows jump to the source mention. Hovering a result uses the shared Hover Card plus FileEmbed preview surface, previewing the backlink source note or outgoing target note.

Editor Ownership

The package extends CodeMirror with markdown table editing, heading/list decorations, grid tables, wiki links, tags, embeds, directives, LaTeX, paste handlers, completion, rich-editor behavior, local path-link parsing for standard markdown destinations that contain raw spaces, and a markdown-owned frontmatter line-decoration pass that marks every YAML frontmatter source line with cm-hmd-frontmatter, including both delimiter lines, so the editor can style the full block consistently. Standard live-preview markdown tables now mount @dnd-kit/svelte row drag handles and drop targets inside the rich-editor widget surface, so row reordering no longer depends on the older native HTML5 drag path. Shared autocomplete defaults and tooltip alignment come from the API lapisCodeMirrorAutocomplete() helper and @lapis-notes/ui/codemirror-autocomplete.css; markdown supplies override completion sources (wiki links, suggestions) and markdown-only addToOptions renderers. Vault file scanning for link completions uses API helpers (vaultFilesMatchingPathSubstring, embed filters). Completion-driven note, embed, heading, and display-text link insertion delegates to the shared API internal-link formatter so link text resolves relative to the active note and uses the same shortest-path rules as metadata and file-management helpers. Its custom language extends the Markdown Lezer parser with GridTable, Table, WikiLink, path-link, Tag, directive nodes, YAML frontmatter, and fold support, while keeping wiki-link and embed parsing line-local so malformed cross-line link text does not create invalid rich-editor replacement ranges and recovering embed tokens when a literal exclamation mark directly precedes ![[...]]. Both the standard editor link-decoration pass and the rich-editor pass apply the same internal-link, target, and display-text classes to embed nodes that they already apply to wikilinks.

The plugin mounts the shared CodeMirror diagnostics/lint-gutter adapters for MarkdownViewType, while the external official Markdown Lint plugin owns markdown diagnostics provider registration when installed and enabled. Markdown diagnostics remain single-document only and do not depend on metadata cache, vault-wide link resolution, or markdown preview rendering.

Markdown-owned list, plain-indent, and blockquote editor decorations emit first-paint hanging-indent fallback variables, and the measured-indent pass later writes measured pixel variables over the same CSS contract. Source and live-preview wrapping therefore keep continuation rows aligned during opening, scrolling, and caret movement into raw prefixes instead of waiting for a post-layout style replacement. Unordered-list marker spacing is expressed through shared marker-slot custom properties, so list fallbacks, anchored continuation padding, and indentation-guide offsets account for the same visual bullet width across source mode and live preview.

Markdown fenced code blocks continue to dispatch through the shared per-language code-block processor registry. The bundled Bases plugin now uses that path for fenced base and bases blocks, rendering the fence body as a read-only Bases document in reading mode and live preview without adding new markdown syntax.

List Callouts

The markdown plugin owns inline list callout markers (for example - & Highlighted item) in source, live preview, and reading mode.

SurfaceMechanism
Source / live previewCodeMirror line decorations and marker widgets (lc-list-callout, lc-list-marker, lc-list-bg)
Reading / previewMarkdown post-processor on rendered list items (sort order 10000)

Default callout characters: & (yellow), ? (orange), ! (red), ~ (purple), @ (cyan, book-open icon), $ (green), % (gray). Users can add custom callouts from the Markdown plugin settings tab, whose catalog editor uses the shared icon picker for icon-backed callouts and keeps preview rows with representative sample text.

Settings:

  • Callout catalog persists in /.obsidian/markdown.json under listCallouts.
  • Appearance (background opacity and padding) persists as the canonical object setting markdown.listCallouts.style in app.json. Runtime style access still reads child values through markdown.listCallouts.style.*, and those values map to --lc-* CSS variables on markdown surfaces.
  • Legacy flat app.json keys such as markdown.listCallouts.style.paddingLeft are normalized into markdown.listCallouts.style when no object value exists and are pruned after normalization. If both forms exist, the object value wins.
  • Legacy obsidian-list-callouts community plugin data is imported once into markdown.json when no catalog is present yet.

Blockquote Callouts

The markdown plugin also owns Obsidian-style blockquote callouts (for example > [!warning] Title) in reading mode and live-preview block widgets.

SurfaceMechanism
Reading / previewremarkCallout mdast transform, markdown-owned admonition rehype handler, and the callout renderer component
Live preview widgetsThe same markdown parser and rehype handler path, mounted through block widgets

The blockquote callout transform runs after markdown parsing and must recognize both raw [!type] text prefixes and the bracket-only internal-link tokens produced by the markdown path-link extension. Rendered callouts use the markdown plugin’s callout component plus shared callout theme variables, so reading mode and live-preview widgets share the same callout DOM shape and styling.

Metadata Ownership

Markdown metadata extraction is worker-backed through PromiseWorker. The plugin registers the metadata processor for .md files and provides the frontmatter/property widgets used by the workspace and related plugins. Package build emits dist/lib/metadata/worker.js after svelte-package so downstream plugin builds can load the worker through import.meta.url instead of unresolved Vite ?worker&inline imports.

The shared frontmatter editor now uses a Svelte-owned property-name input with anchored suggestions in both live preview and the File Properties side view instead of the older imperative menu-attached blur flow. Property rows are keyed by exact nested frontmatter paths, so rename and type-menu actions now target the concrete property path that exists in a note rather than only the top-level key. Normal row rendering depends on the active note frontmatter, persisted type declarations, registered widgets, and local inference rather than vault-wide observed property counts, while property-name suggestions load the vault-wide property list lazily when an input is focused. Note-local property edits in live preview and the File Properties side view now share the same helper around FileManager.processFrontMatter(), which lets first-property adds create a new YAML block, last-property removals drop the whole block, and widget edits preserve concurrent note changes. Built-in widgets now commit text values on input/blur/Enter and checkbox/date/number changes on stable change/blur events so remounts and focus handoffs do not lose pending edits. Embed-hosted previews (FileEmbed, including note-link hovers and inline ![[embeds]]) collapse the Properties block by default; reading mode and live preview keep it expanded unless a host overrides frontmatterOpen.

The All Properties side view is a top-level frontmatter key browser. It builds rows from exact keys present in cached YAML objects, preserving literal dotted keys such as note.status while hiding generated nested paths such as tags[0] or prop[0].name from the root list. Object and complex-array rows can expand into an indented nested-property tree. Object rows show concrete generated child paths with counts and the same rename, type-change, and delete actions using nested path semantics. Complex arrays aggregate object-child paths across indexes, so items[0].name and items[1].name render as a single name/items[].name style child instead of separate numeric index rows. Clicking any property row opens the Search sidebar with a quoted property query; aggregate array child rows normalize items[].name to the searchable nested filter ["items.name"]. Each top-level row also exposes bulk Rename, Change Property Type, and Delete Property actions; those actions operate across affected files through exact top-level YAML keys, report progress through the shared notifications status item, and summarize partial failures.

The frontmatter widgets include generic removable pill rendering for tags, aliases, and primitive multi-text arrays. Editable list pills do not force markdown tag prefixes in the Properties editor, autocomplete new values from existing metadata values for the same property, and render whole wikilink values through NoteLink with hover preview and click-to-open behavior resolved from the active note path.

Rendered markdown preview and reading-mode surfaces apply the same markdown heading tokens used by the editor so heading color, size, weight, and font variables stay consistent across modes. Markdown-specific callouts and table-selection styling inherit shared theme-owned compatibility tokens rather than maintaining plugin-local light/dark palette blocks. The shared editor wrapper and Outline side view also cancel deferred focus and heading-observer work on teardown so preview/editor transitions and leaf changes do not leave stale async UI work running. The Outline side view initializes nested heading groups expanded whenever the active note changes, then preserves user collapse choices for that note.

Rendered note-link hover previews and unresolved-link tooltips inherit their popup ownership from the shared Hover Card and Tooltip primitives. Popup-hosted reading views therefore keep note-link overlays inside the popup document without markdown-specific portal props at the call site.

Markdown Lint Plugin (@lapis-notes/markdown-lint)

Source: packages/plugins/plugin-markdown-lint/src/

Package: @lapis-notes/markdown-lint. Manifest id: lapis-markdown-lint.

The Markdown Lint plugin is an external official plugin that owns markdown diagnostics provider registration. It separates markdownlint ownership from the main Markdown editing plugin while preserving the existing editor diagnostics UI and host fallback behavior.

The plugin now also owns vault-scoped markdownlint rule suppression settings, per-diagnostic ignore actions, and markdownlint quick-fix surfacing for fixable diagnostics.

Registration

  • Official registry install under /.obsidian/plugins/lapis-markdown-lint.
  • Listed under Core plugins when installed provenance is official, with enablement persisted through the existing external plugin enablement file.
  • Declarative manifest language-service contribution with one provider binding.
  • Declarative manifest-only configuration contribution for vault-scoped markdown-lint settings.
  • Registered as an optional external official plugin, so optional-core Safe Mode disables it while community-plugin Safe Mode does not.
  • No views, commands, or bespoke settings tabs.
  • Exports a concrete MarkdownLintPlugin subclass and registers exactly one Markdown LanguageServiceProvider during plugin onload().

Provider Selection

  • On browser/PWA hosts, the plugin registers the worker-backed markdownlint provider from @lapis-notes/language-service, whose worker bundles markdownlint runtime entry points for browser module resolution.
  • On Electron hosts where the native language-service capability is available and responsive, the plugin registers a native markdownlint provider that talks to desktop_ls_* IPC through the shared native desktop bridge.
  • The provider keeps priority: 100 on Electron so native markdownlint outranks the browser worker while preserving worker fallback when the native capability is unavailable.
  • Both runtime paths expose markdown diagnostics and markdown code actions for fixable markdownlint rules.

Settings

  • The plugin contributes a vault-scoped markdown-lint.disabledRules array in app configuration.
  • Each configured rule ID is mapped to false in the effective markdownlint config for both worker and native providers.
  • Inline markdownlint suppression comments continue to work because the underlying markdownlint library still evaluates them during linting.

The first implementation deliberately limits persisted settings to rule suppression. Full markdownlint rule-option passthrough and external config-file loading remain out of scope.

Quick Fixes And Ignores

Markdownlint diagnostics with rule ids expose three code actions through the shared Lapis lint tooltip:

  • Fix markdownlint <RULE>
  • Ignore markdownlint <RULE> on next line
  • Ignore markdownlint <RULE> for this file

Ignore-next-line inserts a markdownlint-disable-next-line HTML comment immediately before the offending content line, but it is not offered for diagnostics inside leading YAML frontmatter because HTML comments are not valid frontmatter content. File-level ignores insert markdownlint-disable-file before the first real content line, after any leading YAML frontmatter, and include MD041 when needed so the inserted comment does not create a new first-line-heading warning.

Lapis MD018 replacement

In-app lint disables stock markdownlint MD018 and registers a Lapis custom replacement through @lapis-notes/language-service (createMarkdownlintLintOptions()).

The replacement registers as MD018-lapis in markdownlint because built-in rule names cannot be reused, but diagnostics surface as MD018 so vault disabledRules, inline markdownlint-disable MD018 comments, and editor doc links continue to work.

Tag-aware behavior:

  • Exempt whole-line lowercase Lapis tags such as #task, #project/roadmap, and indented tags with up to three leading spaces.
  • Exempt line-start tag prefixes such as #task Buy milk.
  • Do not report valid spaced ATX headings such as # Intro, ## Start Here, and ### Deep Dive.
  • Still report malformed ATX headings such as #Introduction, ##Section, ###foo, and #Heading.
  • Preserve the quick-fix that inserts a space after the hash run for remaining violations.

Tag exemption matches Lapis inline tag conventions: optional up-to-three-space indent, single #, lowercase letter first, then lowercase letters, digits, _, -, and /.

Both the browser worker and Electron sidecar use the same shared lint options helper so behavior stays aligned across hosts. They also share the same markdownlint runtime helper for diagnostic mapping, code-action generation, inline suppression alias normalization, and applyFixes(...)-based edit generation so fixes preserve frontmatter and edit the real violation line instead of rewriting from the top of the file.

Package Boundary

  • Owns markdown diagnostics provider registration only.
  • Owns markdown quick-fix and ignore-action generation for markdownlint diagnostics.
  • Does not own Markdown editor views, preview rendering, metadata extraction, or CodeMirror lint UI.
  • Does not own Electron sidecar lifecycle or IPC handlers; the desktop host still owns those capabilities.

Runtime Contract

  • Consumes app.languageServices.registerProvider(...).
  • Consumes app.configuration.getConfiguration() to resolve vault-scoped disabled rules on demand.
  • Uses @lapis-notes/language-service for both worker-backed and native-bridge markdown provider factories.
  • Probes the shared native desktop bridge through @lapis-notes/api before attaching the native markdown provider.

This makes markdownlint a plugin-owned feature even when the underlying implementation uses a host-owned Electron sidecar.

Current Limitations

  • Host-aware selection between worker-backed and native-desktop markdownlint still lives in package code rather than purely in manifest metadata.
  • The current settings treatment is the shared Core plugins surface, not a richer dedicated markdownlint UI.
  • Quick fixes currently surface per-diagnostic markdownlint edits; workspace-wide fix-all and formatter parity remain separate UX work.

Search Plugin (@lapis-notes/search)

Source: packages/plugins/plugin-search/src/

Manifest: id search, version 0.0.1, min app version 1.7.7.

The search package owns the search sidebar, indexing controls, result rendering, semantic-search status affordance, and the plugin-level settings that shape lexical and vector retrieval. Heavy indexing and query execution are routed through AppDatabase instead of running SQLite or embedding work directly in the renderer.

View, Commands, And Settings

  • View type: SearchViewType in the left sidebar.
  • Commands: search-all-files, search-selected-text, open-search-left-sidebar, rebuild-search-index, refresh-semantic-search.
  • open-search-left-sidebar accepts an optional query argument and seeds the Search view when called by other plugins.
  • Settings: semantic provider, embedding model id, remote/local model policy, explicit embedding rebuild, chunk target size, breakpoint search window, breakpoint decay, result limit, snippet length, match-case mode, recent searches, sidebar presentation options, plus a disabled-by-default Electron-only query-enhancement contract that is persisted for future local expansion/reranking but is not yet exposed in the UI.

Search State

File-level records include path, name, extension, checksum, tags, tag parts, tag hierarchy, and serialized frontmatter metadata text.

Chunk records include stable chunk id, source path, ordinal, source offsets, nearest heading or section label, chunk content, and optional embedding state. Ready embeddings are mirrored into the SQLite vec table in SQLite-backed sessions.

Key Behaviors

  • Derives markdown-aware chunks from cached headings, sections, and frontmatter-aware source positions.
  • Listens to metadata cache changed and deleted events for reactive updates and background embedding invalidation.
  • Indexes visible JSON Canvas node and edge text from .canvas files during refresh and canvas vault create/modify/delete events.
  • Retries startup embedding backfill when provider configuration lags initial layout so persisted semantic-search state eventually resumes without a manual refresh.
  • Starts provider warmup as soon as an embedding provider is configured, but treats startup provider configuration as best-effort post-mount work so proxy-session database delegation cannot block the rest of app boot.
  • Refreshes searchable markdown files even when metadata cache entries are still unavailable by indexing them with empty source-metadata snapshots until later metadata events enrich the stored search document.
  • Vault-driven semantic refresh also prunes stale generated search documents whose paths are no longer present in the current vault file list, so footer status and pending-chunk counts can recover from missed delete events or older failed runs.
  • Debounces metadata-cache changed and deleted events by source path so rapid edits collapse into bounded search-index updates and the latest content wins before the write reaches AppDatabase.
  • Wraps vault refresh indexing in AppDatabase search batches when the active backend supports them and yields to the UI thread between refresh items so long-running semantic refreshes do not monopolize the renderer event loop.
  • Delegates rebuild and query execution to AppDatabase.
  • Supports quoted property filters for exact and nested metadata paths, including nested object and array values such as ["project.name"] and ["items.name"].
  • Reports search index rebuilds and semantic-refresh backfills through app.notifications so long-running background work appears in the shared status-bar progress item and notification center. Errors are persisted as app notifications.
  • On Electron, simple lexical candidate selection can be served by the native SQLite FTS path behind AppDatabase; the search UI and query syntax do not fork by runtime.
  • SQLite-backed candidate builders quote generated prefix terms and split punctuation-heavy input such as hyphenated or underscored words before passing it to MATCH, avoiding FTS syntax errors while leaving final result semantics to the shared evaluator.
  • SQLite-backed candidate queries use deterministic rank/path ordering before the shared evaluator performs final scoring.
  • Electron native search keeps structured, case-sensitive, vector, and hybrid modes on the shared evaluator over mirrored documents, with native smoke coverage for those parity paths.
  • Electron vector and hybrid searches use native sqlite-vec candidates when a compatible extension is available, then fall back to the shared in-memory vector scorer when it is not.
  • The plugin now persists an Electron-only local query-enhancement request contract and forwards it to AppDatabase.searchDocuments() only when it is explicitly enabled and the runtime is Electron. The current implementation still keeps UI controls hidden and reports enhancement as unavailable until a runnable local model/reranker provider exists; strong lexical or hybrid RRF results are treated as an automatic skip condition.
  • Registers embedded markdown query code-block rendering backed by the same search manager.
  • Returns grouped file results with snippet text, explicit highlight ranges, chunk offsets, backend provenance, lexical-versus-semantic mode, and explainable RRF score breakdowns for hybrid ranking.

UI Ownership

The search view uses a CodeMirror query editor backed by the shared API search-query language, focus-time search option help, wrapper-click focus forwarding that keeps the query caret visible on first focus, operator autocomplete, vault-backed path:, file:, and tag: value autocomplete, fixed query controls, a scrolling results area, a copyable total-result summary, result sorting with responsive trigger truncation, match-case control, recent-search recall from the empty-query sidebar and with ArrowUp / ArrowDown when the caret is at the start of the query, inline presentation settings, explicit snippet highlighting, and click-to-open result navigation.

The semantic-search status-bar item remains a search-specific diagnostics surface for provider readiness, chunk counts, model download status, and embedding health. Status reads now prefer incremental app-database search stats instead of rescanning the full stored search corpus on every poll while indexing is active. Generic active-work visibility belongs to the shared notifications plugin.

The package follows the Plugin CSS Contract with workspace dist/app.css, standalone dist/styles.css, search-view__..., search-status..., and search-highlight selectors.

Obsidian Search Parity

This page tracks the intended search-query and search-UI parity target for the bundled search implementation against Obsidian’s Search plugin documentation at https://obsidian.md/help/plugins/search.

The goal is not to clone every internal implementation detail. The goal is to preserve the user-visible query language, result semantics, and core sidebar workflows closely enough that Obsidian search terms transfer directly.

Upstream Feature Areas

Obsidian’s documented Search plugin behavior covers these main areas:

  • Search terms: word conjunction, exact phrases, OR, parentheses, negation, and regex.
  • Search operators: file:, path:, content:, match-case:, ignore-case:, tag:, line:, block:, section:, task:, task-todo:, and task-done:.
  • Search properties: [property], [property:value], [property:null], property comparisons such as [duration:<5], and grouped or regex subqueries inside property values.
  • Search UI behavior: explain search term, collapse results, more context, sort order, recent searches, copy search results, and the sidebar search workflow.
  • Search scope: notes and canvases, with embedded query code blocks as a separate rendering surface.

Current Parity Matrix

Obsidian featureCurrent stateNotes
Plain word conjunctionImplementedRuntime lexical search treats whitespace-separated terms as AND.
Exact phrasesImplementedParsed and normalized through the shared search-query AST.
ORImplementedRuntime evaluation now preserves boolean alternatives.
ParenthesesImplementedGrouped expressions are evaluated through the shared search-query AST.
Negation with -ImplementedNegative clauses exclude matching documents from lexical results.
Regex /.../ImplementedRegex literals execute against searchable fields with case-mode awareness.
Bare property existence [property]ImplementedResolved against indexed frontmatter property records rather than plain text.
Property value match [property:value]ImplementedEvaluated against indexed frontmatter property values.
Property nullImplementedMatches indexed empty, nullish, or empty-array property values.
Property comparisons [duration:<5]ImplementedNumeric, date-like, and fallback string comparisons are evaluated at runtime.
Nested property subqueriesImplementedGrouping, OR, phrases, and regex subqueries run against property values.
file:ImplementedFilters against the indexed file basename.
path:ImplementedFilters against the vault-relative path.
content:ImplementedFilters against indexed document content.
tag:ImplementedFilters against normalized tags, tag parts, hierarchy, and #tag spellings.
match-case: / ignore-case:ImplementedSupported as query operators and through the sidebar match-case setting.
line:ImplementedRuns the nested expression against individual content lines.
block:ImplementedRuns the nested expression against same-block text regions.
section:ImplementedRuns the nested expression against heading-bounded sections.
task: / task-todo: / task-done:ImplementedRuns task expressions against markdown task lines filtered by completion state.
Explain search termImplementedThe sidebar can explain simple AST-backed terms.
Collapse resultsImplementedPersisted sidebar setting.
Show more contextImplementedPersisted sidebar setting.
Sort orderImplementedSidebar supports the documented file and timestamp sort modes.
Recent searchesImplementedEmpty-query sidebar state lists persisted recent searches, and ArrowUp / ArrowDown from the start of the query recall them without leaving the input.
Copy search resultsImplementedThe sidebar can copy grouped search results to the clipboard.
Search selected text shortcut flowImplementedCommand flow opens search seeded with the active editor selection.
Search notes and canvasesImplementedSearch refresh and canvas vault events index .canvas user-visible node and edge text.
Embedded query code blockImplementedThe search plugin renders fenced query blocks as clickable search-result lists.

Implemented Today

The current implementation is strongest in these areas:

  • Shared search-query grammar and AST ownership in packages/api.
  • Phrase-aware lexical search, regex literals, boolean grouping, negation, and search-term explanation.
  • Frontmatter-key existence, value, null, comparison, and nested property queries.
  • Field, case-mode, line, block, section, and task-scoped operator execution.
  • Sidebar presentation settings and sort options.
  • Recent searches, selected-text handoff, copy-results action, canvas indexing, and embedded query blocks.
  • Hybrid lexical plus semantic retrieval infrastructure behind AppDatabase.

These pieces provide the current user-visible Obsidian search parity target while leaving performance and presentation refinements available for follow-up work.

Remaining Caveats

The implemented parity layer intentionally stays within the app’s current generated search model:

  1. SQLite-backed sessions route structured or case-sensitive lexical queries through the shared evaluator over the mirrored in-memory search document set so semantics stay consistent across database backends.

  2. Same-block and same-section matching uses text-derived block and heading regions. It does not yet require a markdown parser section record for every match.

  3. Canvas search indexes visible JSON Canvas node and edge text, linked files, and URLs. It does not inspect the contents of files referenced by canvas nodes unless those files are indexed separately.

  4. Embedded query blocks render a compact clickable result list and intentionally reuse the search plugin’s current lexical query path.

Follow-On Refinements

Further work can improve performance and polish without changing the user-visible query contract:

  1. Lower structured field and property filters into SQLite candidate queries before evaluator scoring for large vaults.
  2. Prefer cached markdown section metadata for block: and section: when all indexed documents carry complete section spans.
  3. Add richer embedded-query block chrome such as result counts, snippets, and sort controls.

Tasks Plugin (@lapis-notes/tasks)

Source: packages/plugins/plugin-tasks/src/

Package: @lapis-notes/tasks. Manifest: id tasks, version 0.0.1, min app version 1.7.7.

The tasks package owns browser-first task capture and management as a bundled first-party plugin. It is currently a transitional implementation that is being aligned to TaskNotes’ markdown-first model without relying on desktop-only integrations or Obsidian internal APIs.

This package is a vendoring of the upstream TaskNotes plugin into Lapis Notes. That upstream plugin should be treated as the implementation reference for any functionality we decide to support here, while the standalone TaskNotes specification remains the behavioral contract for markdown model semantics.

TaskNotes Direction

TaskNotes is explicitly markdown first: the intended end state is one markdown note per task with YAML frontmatter for machine-readable properties and note body content for freeform detail. The standalone TaskNotes specification is the behavioral contract for future read/write semantics, especially around model mapping, operations, temporal fields, recurrence, validation, configuration, dependencies, reminders, and links.

For future feature work, start from upstream TaskNotes behavior and data flow first, then adapt it for this host only where browser constraints, shared package boundaries, or non-Obsidian runtime differences require a documented deviation.

The current plugin now claims the TaskNotes core-lite conformance profile through a dedicated adapter bridge. The primary storage model is markdown first and supports persisted folder and field-role configuration plus stricter validation, while advanced TaskNotes semantics remain intentionally out of scope.

See Upstream Feature Audit for the current gap inventory and phased roadmap derived from upstream TaskNotes docs and source references.

Implementation Sequence

Vendoring proceeds in ordered slices instead of a wholesale upstream import:

  1. Keep the current first-party Lapis plugin shell, workspace boot wiring, and @lapis-notes/ui-based Tasks view.
  2. Unwind the current non-conforming primary model by removing JSON-backed manual tasks and generic checklist aggregation from the main Tasks dataset.
  3. Replace transitional storage with markdown-first TaskNotes task-note writes and task-folder indexing.
  4. Add TaskNotes semantic field mapping, browser-safe configuration handling, and strict validation for the supported core behavior.
  5. Add TaskNotes fixture or conformance-backed validation before expanding into recurrence or other advanced features.

The first six slices are now in place, and the adapter now also covers the current core-lite bridge surface. The remaining roadmap is now split into concrete feature families: schema/settings parity, richer editor and inline-capture surfaces, remaining Bases parity, recurrence/dependency/link semantics, time-management surfaces, and host-constrained external integrations.

Registration

  • View type: plugin:tasks.
  • Settings tab entry: Tasks, which renders the persisted task settings form directly in the plugin settings panel.
  • Command: tasks:open-view.
  • Commands: tasks:open-task-list-base, tasks:open-kanban-base, tasks:open-fizzy-base, tasks:open-fizzy-calendar-base, and tasks:open-mini-calendar-base.
  • Ribbon action: opens the Tasks view.
  • Core-plugin activation through the workspace shell.

Data And Persistence

The current slice stores each task as a markdown note in a configurable managed task folder, defaulting to TaskNotes/Tasks/. Supported task-note frontmatter roles currently include:

  • title
  • status
  • priority
  • due
  • scheduled
  • tags
  • projects
  • blockedBy
  • recurrence
  • recurrenceAnchor
  • completeInstances
  • skippedInstances
  • timeEntries
  • dateCreated
  • dateModified
  • completedDate

The task identifier is currently derived from the note path rather than stored as a separate frontmatter field. The plugin indexes the configured task-note folder as its primary dataset, also indexes markdown notes outside that folder when the task parser accepts them and their mapped frontmatter tags include the configured task tag, persists folder and field-role settings through plugin data, creates missing folder paths in a browser-safe way, and updates task status, priority, scheduled dates, relationship links, Fizzy pin or golden state, and other frontmatter-backed task properties by mutating mapped frontmatter fields through the vault API with the same path-aware semantics used by the shared Properties and FrontMatter editors, automatically touching dateModified whenever one of those mutations changes task frontmatter. The initial full-vault rebuild now gathers candidate note content on the host thread but offloads bulk task-note parsing and index construction to a dedicated worker with a main-thread fallback if worker startup fails. Strict validation now also enforces required temporal fields, completedDate conditional requiredness, due-value parsing, and dateModified >= dateCreated ordering for the currently supported model.

The persisted settings model now also carries a broader schema foundation for later TaskNotes parity work, including:

  • extended frontmatter-role mapping for scheduled dates, contexts, projects, time estimates, recurrence, reminders, blockers, and time entries
  • title-storage configuration for frontmatter vs filename-based titles
  • default task values and task-detection strategy data
  • richer status and priority vocabularies
  • Fizzy board preferences for Not Now status, entropy enablement, and entropy age; pinned card state is persisted in task-note frontmatter as pinned: true/false rather than in plugin settings
  • custom user-field definitions with NLP and suggestion-filter metadata
  • modal field-group layout metadata

The Tasks settings tab now exposes the persisted managed folder, marker tag, title-storage mode, filename strategy, default status/priority/date presets, task-detection rules, Fizzy Not Now lane, Fizzy entropy controls, status and priority vocabularies, and JSON-backed custom fields directly inside the plugin settings panel. The interactive runtime also now uses that persisted schema for creation defaults, preset recurrence rules, recurring completion history, mapped reminder visibility, dependency readiness state, Fizzy frontmatter-backed pin state and entropy moves, and timeEntries-backed task timers. Modal field-group editing and richer schema-specific editors remain future work.

UI Ownership

The first view slice is a dedicated Tasks leaf with:

  • toolbar Add task dialog for task-note capture (preserves #tasks-title, due date, and tags for E2E); settings configuration lives in Settings → Tasks, not an inline settings card
  • command-driven inline task conversion from the current Markdown editor line, a single-line selection, or all supported prefixed task lines in the current note into TaskNote links
  • partial metadata extraction during inline conversion for hashtags and explicit ISO due-date tokens
  • reading-mode convert affordances for promoting supported prefixed Markdown task candidates into TaskNotes
  • source-mode and live-preview convert affordances for promoting supported prefixed Markdown task candidates in place
  • task-note summary widgets in reading mode, source mode, and live preview for indexed task notes, including markdown notes outside the managed task folder when their mapped frontmatter tags include the configured task tag and the task parser accepts them, now rendered through a reusable upstream-shaped task-row surface with an upstream-style cycling status button (none -> open -> in-progress -> done) that emits app toasts, centered priority/menu affordances, completed-row dimming, an inner hover surface that preserves a visible outer gutter, scheduled-date metadata buttons and the overflow Schedule submenu both driven by an upstream-style popup menu with increment/basic/weekday sections plus Pick date & time... and Clear date, where Pick date & time... opens a shared @lapis-notes/ui date-time picker dialog, YAML tag metadata from the mapped note tags field rendered immediately beside the scheduled chip with markdown tag styling, including the task tag when it is present on the note, due metadata pills, mapped reminder metadata pills, priority none, preset recurrence actions that write mapped recurrence fields, recurrence-aware completion that rolls the next due or scheduled date forward while recording completeInstances, a Skip next occurrence recurrence action that advances the current recurring instance while recording skippedInstances, recurring history metadata pills that surface the latest completed or skipped recurrence instance, dependency readiness metadata pills that surface blocked, ready, and blocking state from the resolved task relationships, a task-card start/stop timer button with tracked-duration pills backed by timeEntries, and a richer overflow menu for status, priority, scheduling, reminders, dependencies, organization, recurrence, copy, open, and delete actions. The dependency and organization sections open a shared @lapis-notes/ui command dialog with fuzzy search and match highlighting (same interaction model as the workspace command and file palettes) for blocker, dependent, project, and subtask linking instead of expanding every candidate task in nested submenus; reminder actions still mutate mapped reminders, and the note-widget variant now renders dedicated Reminders and relationship detail content with reminder entries, nested blocked-by and blocking task cards, linked projects, and recursive subtask rows using indentation guides instead of section labels, without a Relationships header or Open relationships entrypoint. When either panel would render, both stay collapsed by default behind a shadcn Collapsible with a hover-only chevron beside the overflow menu and click-to-expand on the card surface, while collapsed relationship summary pills remain visible in the main metadata row and hover feedback scopes to the whole card when collapsed or to individual sections and nested child cards when expanded.
  • a dedicated Tasks settings-tab form that writes the persisted TaskNotes-style schema through updateSettings() so folder/tag/title/defaults/detection/vocabulary changes are available without direct test-only runtime calls
  • task-link overlays in reading mode, source mode, and live preview that now reuse the same task-row family in a smaller inline layout instead of a separate compact pill surface, keeping a single compact row with status, priority, title, always-visible details and overflow actions that open the shared task detail dialog for relationship stacks and metadata instead of rendering relationship pills inline, and inactive CodeMirror source/live-preview lines that replace the raw [[...]] task link text with the overlay widget instead of showing both
  • parsed relationship-link groundwork for projects and blockedBy, with runtime helper lookups for derived subtasks, blockers, and blocked-by tasks so future recursive trees and relationship widgets can build on the indexed markdown task-note dataset
  • generated TaskNotes-style .base files under the managed task views folder, defaulting to TaskNotes/Views/, including a generated Fizzy base; the Fizzy view settings now expose labeled controls for the column property, Maybe and Not Now column values, title property, and hide-empty-columns toggle through the shared Bases settings sidebar
  • custom task-focused Bases renderers for Task List, Kanban Board, Fizzy, Fizzy Calendar, and Mini Calendar views, registered through Plugin.registerBasesView() and rendered from the BasesQueryResult supplied by plugin-bases; task-specific views consume the already filtered and grouped Bases dataset instead of rebuilding or re-filtering task entries locally, keep a reactive snapshot of the latest Bases query result, and also invalidate themselves when the Tasks runtime emits tasks-changed so late query refreshes, task-index rebuilds, and frontmatter mutations repopulate open task Bases views; Task List and Kanban Board rows now use the full note-widget task-card surface with inline metadata, increased outer view padding, and a hover-only details button that opens a medium-width task detail dialog with header status/priority badges, note body, relationship stacks, and a built-in-properties sidebar that surfaces interactive status, priority, scheduled, due, recurrence, reminder, timer, and timestamp controls together with linked projects and blockers rendered through NoteLink where a task note target exists, plus Bases-provided extra properties, instead of compact rows or an embedded task card, while the generated Fizzy .base now persists board-specific view config including the column-driving property, special Maybe and Not Now values, user-arranged column order, and per-column title/color overrides, and the generated Fizzy Calendar .base adds an activity timeline that derives Added, Updated, and Done lanes from each task note’s current dateCreated, dateModified, and completedDate metadata rather than a durable event log, collapsing consecutive empty visible days into a single gap row when Show empty days is enabled
  • a Fizzy board renderer inspired by Basecamp Fizzy (“Kanban as it should be”) that now groups the markdown task-note dataset by a configurable metadata property, defaulting to the mapped status field, while still supporting dedicated Maybe and Not Now values plus completed columns; the board applies an optional entropy move from stale tasks into Not Now on open, resolves column colors from either configured status colors or persisted per-column overrides, applies each column color to every card in that lane instead of deriving card backgrounds from priority, supports @dnd-kit/svelte-backed drag-and-drop moves across every column by mutating the configured note property instead of hardcoding status writes, keeps only fixed column positions non-reorderable, exposes a compact inline column-action popup for moving workflow lanes left or right and editing persisted title or color metadata, renders frontmatter-backed pinned cards (pinned: true/false) in a bottom pin stack, uses explicit frontmatter-backed golden tickets (golden: true/false) instead of highest-priority inference, provides full-screen per-column grid and card detail views, embeds the reusable editable markdown preview/editor for the task-note body inside detail together with the reusable task-card controls beneath the title, supports inline detail-title edits that write back to the task note, preserves Done and Not Now stamps plus entropy countdown copy, scopes Fizzy keyboard shortcuts to the active visible board or detail surface instead of document-wide handlers, hands lane search off through the shared Bases query controller, and keeps the page root height-locked with independent column scrolling
  • Task-focused Bases view chrome, including Fizzy board, detail, and calendar surfaces, now ships through the Tasks package stylesheet artifacts (app.css and styles.css) rather than component-local <style> blocks; runtime values such as per-card or per-column colors are passed through CSS custom properties that those external styles consume so the same override surface is available in both workspace and standalone builds
  • Tasks styling inherits the shared Obsidian-aligned global theme through core background, border, text, accent, warning, success, and error variables. Task-specific variables keep status, due-date, recurrence, and relationship semantics distinct without defining a separate app palette.
  • Tasks leaf toolbar Task views dropdown that creates or opens the generated task Bases files through the shared Bases runtime (replacing the former Views card)
  • local create-form validation and inline submission errors for task-note capture in the toolbar dialog
  • a shadcn-svelte-style data table as the primary browse surface (see Tasks leaf browse surface below and task id TASK-PLUGIN-018)
  • completion toggling, deletion, note-open actions, and task-note path badges through table rows and the shared task-card overflow menu
  • shared @lapis-notes/ui controls for form fields, data table, table, badges, and actions
  • namespaced tasks-... selectors for plugin-owned styling

Tasks leaf browse surface

Tracked as TASK-PLUGIN-018. The main Tasks leaf (plugin:tasks) uses a data-table-first layout aligned with the shadcn-svelte tasks example:

flowchart TB
  header[Header: title + stats]
  toolbar[Toolbar: search + filters + Add task + Views + columns]
  table[Primary data table]
  pagination[Pagination footer]
  header --> toolbar --> table --> pagination
ColumnBehavior
CompleteCheckbox → plugin.setTaskCompleted()
TitleSortable; NoteLink hover preview and click-to-open when the task has a note path; path as muted subtitle
Statusplugin.getTaskStatusLabel(); faceted filter
PriorityDot + label; faceted filter
DuegetTaskDueLabel() / tone classes
TagsBadge with tasks-view__meta-pill tasks-view__meta-pill--tag, #tag format
Meta (hideable)Reminder, recurrence, dependency badges from existing helpers
ActionsFull task-card overflow menu (extracted from card-row.svelte)

The toolbar provides search, status/priority faceted filters, reset, column visibility, Add task, and Task views. Row filtering reuses filterTasks() at the view level. The overflow menu is shared between table row actions and task-card surfaces. Bulk row selection and new @lapis-notes/ui faceted-filter primitives are out of scope.

Future upstream-style audits for task-row surfaces must compare the rendered widget DOM against the upstream TaskCard implementation surface, not just the visible controls and token palette. That audit must cover styles/task-card-bem.css, the TaskCard status-cycle/runtime wiring, taskCardProperties metadata renderers, the upstream DateContextMenu popup structure, status and priority settings vocabularies, and the upstream task context menu action set. Alignment, hover hit-area placement, metadata text sizing and color, tag rendering, state vocabulary, overflow-menu breadth, and scheduled-date menu grouping are part of the feature contract.

Task completion updates TaskNotes-style frontmatter through the vault API instead of maintaining plugin JSON state or inline checklist mirrors.

The package now also carries source-level conformance status metadata targeting tasknotes-spec 0.1.0-draft, documenting current configuration providers, core-lite capabilities, and known deviations. It exposes a dedicated ESM conformance adapter entrypoint alongside adapter-style meta.*, date.*, field.*, config.*, validation.*, create_compat.create, op.*, delete.remove, task.load, task.create, and the existing internal OP.* mutation aliases.

This slice is intentionally browser-safe and self-contained. It does not depend on community-plugin compatibility shims, desktop-only HTTP/OAuth flows, or internal embed/editor APIs.

Shared UI primitives for this package should come from @lapis-notes/ui. If the plugin needs an additional control, it should first be added to @lapis-notes/ui using the existing shadcn-svelte / Bits UI wrapper approach before introducing a plugin-local control implementation.

Things-Like Planner Workflow

A Things-like planner workflow now ships as a Lapis-specific layer over the existing markdown-first Tasks runtime. It helps users decide what deserves attention now without replacing the existing Tasks data table, generated Bases views, task-card widgets, recurrence/reminder/dependency behavior, or Fizzy board workflows.

The planner is implemented first as a dedicated workspace view backed by the Tasks runtime (getTasks() plus tasks-changed). A generated Planner Base now ships as a companion query surface, but the daily workflow still lives in the first-class planner view rather than in a database browser.

The planner buckets are:

BucketRuntime semantics
InboxActive tasks that still need triage: no scheduled date, no due date, no project links, no area links, and no explicit when.
TodayTasks intentionally selected for today through today’s scheduled date or explicit planner placement, ordered by todayOrder.
This EveningA lower-emphasis section for tasks planned for today but deferred to evening, persisted with today’s scheduled date plus when.
UpcomingActive tasks with a future scheduled/start date, rendered as a calendar-like list with near-term days visible and later empty runs collapsed into gap rows.
AnytimeActive tasks available now but not committed to Today, not parked in Someday, and not still untriaged in Inbox.
SomedayIntentionally parked active work, represented explicitly by planner metadata rather than by the absence of a date.
LogbookCompleted work grouped by completion date or completion-status metadata.

Due dates and scheduled dates must stay distinct. A due date is a deadline; a scheduled date is the date a task should become available or planned. The default planner model should not treat due-today tasks as Today commitments, though a later setting may add that behavior as an explicit option. Someday must be explicit planner metadata; undated tasks should classify as Inbox or Anytime.

The planner metadata layer now includes mapped when, areas, todayOrder, projectOrder, projectSection, and reviewed fields. The planner settings surface persists the default planner bucket, due-today inclusion, Anytime review threshold, Someday review threshold, and the mapped field names for those planner-specific properties.

The dedicated planner view now owns:

  • quick Inbox capture that bypasses date defaults
  • a settings-pane-style resizable sidebar and independently scrolling main pane, with a draggable divider that does not show a visible grip icon
  • planner scheduling actions for Today, This Evening, Tomorrow, This Weekend, Next Week, Someday, and Clear
  • first-class area and project side rails with linked task views
  • a persistent Areas rail section that remains visible even before any area-linked task exists
  • compact planner rows that show a checkbox, title, and minimal metadata in the collapsed state
  • double-click row expansion that opens a frontmatter-hidden inline live-preview editor, focuses an editable title field on open with the caret at end, keeps the expanded row stable while focus moves between the title, body editor, and footer actions, dismisses only from planner list/body background clicks or a different task row while ignoring app chrome, sidebars, planner headers, capture controls, expanded-row content, and portaled menus or dialogs, animates close/open switching so double-clicking a different row closes the old panel before opening the new one without losing the second click target, and preserves the task note’s frontmatter on write
  • expanded-row footer actions for planner placement, due/deadline editing and clearing, area/project assignment, full detail opening, note opening, and overflow task actions
  • Upcoming calendar rows that show the date number and weekday, insert month dividers, and collapse longer empty spans such as June 4-30
  • manual ordering in Today and project lists through drag-and-drop plus move up or down controls
  • project sections persisted in task frontmatter and edited from project views
  • a review surface that highlights Inbox triage, stale Anytime tasks, stale Someday tasks, and projects without a next action, with actions to mark reviewed or re-plan the task in place

The generated Planner Base companion now creates TaskNotes/Views/Planner.base with Inbox, Today, Upcoming, Anytime, Someday, Logbook, and Deadlines views. That Base intentionally remains a companion approximation: it exposes planner metadata and the closest safe query representations Bases can express, but it does not replace the dedicated planner’s runtime-only bucketing and review logic.

Known Deviations And Gaps

  • Richer relationship widgets, richer metadata extraction, and natural-language parsing are still missing. Inline task capture now covers command, reading-mode, source-mode, and live-preview conversion for supported prefixed Markdown task candidates with formatting-preserving replacements plus hashtag and explicit ISO due-date extraction, the task-note widget now renders dedicated Reminders and relationship detail content together with linked projects, nested blocked-by and blocking task cards, dependency sections, reminder metadata, blocked/ready/blocking state pills, and recursive subtask rows, and inline task-link overlays now route relationship detail through the shared task detail dialog instead of inline pills. Remaining relationship work is focused on broader dedicated widgets, deeper parity polish, and broader metadata extraction. Task IDs: TASK-PLUGIN-007, TASK-PLUGIN-011.
  • Task schema/settings parity now has an interactive Tasks-view settings surface, including Fizzy controls for Not Now lane selection, entropy enable/days, status and priority vocabularies, and JSON-backed custom fields, but modal field-group editing, richer typed editors for custom fields and suggestion filters, and deeper filename-strategy parity are still missing. Task ID: TASK-PLUGIN-009.
  • The initial Bases-backed task list, kanban, Fizzy, Fizzy Calendar, mini calendar, and generated default .base templates are now implemented. The Fizzy board now uses frontmatter-backed pins and golden tickets, property-driven column grouping with persisted column-property and Maybe/Not Now value config in the generated .base, persisted per-column title/color overrides, drag writes against the configured note property, detail-screen editable markdown plus inline title editing with a footer Edit action and e shortcut that enter body-edit mode, Done and Not Now stamps, entropy countdown copy, full-screen detail and grid views, and demo-faithful layout/interactions. The Fizzy Calendar view now adds an activity timeline grouped into Added, Updated, and Done columns, keeps the page root height-locked with its own vertical scrolling surface, reuses the shared task-card body inside each calendar card, uses a dedicated day-window slider for its recent activity range, and still derives that activity from current task metadata instead of a durable historical event stream. Remaining Fizzy work includes comments, boosts, richer task-card overflow-menu reuse, manual card ordering within lanes, and deeper activity-history parity. Task IDs: TASK-PLUGIN-010, TASK-PLUGIN-015, TASK-PLUGIN-016, TASK-PLUGIN-017.
  • The Things-like planner workflow is now implemented as a dedicated Tasks planner view plus a generated Planner Base companion. The dedicated view owns the refined Things-style interaction model, including the resizable planner sidebar, compact expandable rows, inline body editing, Areas and Projects side rails, review projections, calendar-like Upcoming grouping, and sparse ordering. Current limitations are intentional: the generated Planner Base exposes the closest safe query views Bases can express, while runtime-only bucketing, review projections, inline row editing, and sparse ordering still live in the planner view itself.
  • Recurrence now has preset rule selection plus rolling completion or skip-forward actions for the managed due or scheduled field, and the browse/task-row surfaces now show the latest recurring completion or skip date, mapped reminder metadata, explicit due-date, day-before-due, scheduled-date, and picked reminder actions, a note-widget bell reminder indicator with tooltip, lightweight dependency readiness state, and collapsible note-widget relationship stacks, but custom rules, richer skip-state UX, broader reminder automation semantics, broader relationship widgets, richer link semantics, and the extended TaskNotes conformance profiles remain unimplemented. Task IDs: TASK-PLUGIN-006, TASK-PLUGIN-011.
  • Time tracking now has an initial task-card start/stop timer surface backed by timeEntries, but calendar editing, richer auto-stop policy controls, Pomodoro views, calendar integrations, ICS subscriptions, and automation surfaces are still missing or blocked on host-capability decisions. Task IDs: TASK-PLUGIN-012, TASK-PLUGIN-013, TASK-PLUGIN-014.
  • Any intentional divergence from upstream TaskNotes plugin behavior for browser-host or package-boundary reasons must be recorded here before implementation is treated as complete.

Tasks Upstream Feature Audit

This page inventories the remaining gap between the vendored @lapis-notes/tasks package and the upstream TaskNotes plugin, then turns that gap into an ordered implementation roadmap.

It is based on upstream TaskNotes documentation and source references, especially:

  • tasknotes.dev/core-concepts/
  • tasknotes.dev/features/inline-tasks/
  • tasknotes.dev/features/time-management/
  • tasknotes.dev/features/calendar-integration/
  • tasknotes.dev/views/
  • tasknotes.dev/views/default-base-templates/
  • tasknotes.dev/views/kanban-view/
  • tasknotes.dev/views/calendar-views/
  • tasknotes.dev/views/pomodoro-view/
  • tasknotes.dev/settings/task-properties/
  • tasknotes.dev/settings/modal-fields/
  • upstream source in callumalpass/tasknotes, including src/editor/TaskLinkWidget.ts, src/editor/InstantConvertButtons.ts, src/bases/KanbanView.ts, src/bases/CalendarView.ts, src/views/PomodoroView.ts, and src/templates/defaultBasesFiles.ts

Current Lapis Coverage

The current vendored package covers only the first TaskNotes slice families:

  • markdown-first note-per-task storage
  • folder and frontmatter-role mapping
  • strict validation for the current minimal model
  • a first-party Tasks list view using shared @lapis-notes/ui components
  • a core-lite conformance bridge for the current markdown model and operation subset

That leaves most upstream user-facing surfaces still missing.

Missing Feature Families

Task-Note Editor Surfaces

Upstream TaskNotes augments task notes and task links directly inside Markdown views:

  • interactive task-link overlays in reading mode and live preview
  • task-file widgets in task notes, including the relationships widget backed by relationships.base
  • task-aware quick actions that can mutate status, dates, and metadata at the link location

Lapis Notes now has a reusable task-row family across task-note widgets and task-note links in reading mode, source mode, and live preview. The current slice exposes an upstream-shaped single-row contract with checkbox completion, priority selection, scheduled-date quick options plus a custom date-time picker entry point, metadata pills, and an overflow menu through the existing Markdown widget mount paths. Relationship-model groundwork now also parses projects and blockedBy links into runtime lookups, the task-row menus now mutate those mapped relationship fields directly through the task-note frontmatter write path, the browse/task-row surfaces now show reminder metadata together with blocked/ready/blocking dependency state, the overflow menu now exposes explicit due-date, day-before-due, scheduled-date, and picked reminder actions through the mapped reminders field, the note-widget variant now renders a dedicated Reminders panel that lists each mapped reminder plus a dedicated Relationships panel with summary counts, nested blocked-by and blocking task cards, an Open relationships base entrypoint, linked projects, and recursive child-task rows inline, and inline task-link overlays now surface a compact relationship summary, direct related-task pills, and the same Open relationships entrypoint. The remaining gap is rollout depth rather than feature existence: broader relationship/dependency widgets are still missing, and the full note-widget family still needs broader parity polish.

Audit note: future upstream-parity passes for task rows must verify the upstream implementation surface, not just static tokens or control presence. The previous passes matched the visible shell of the row but missed that upstream behavior is split across styles/task-card-bem.css, TaskCard runtime wiring, taskCardProperties metadata renderers, the DateContextMenu popup structure, status and priority settings vocabularies, and the task context menu action set. That gap is why later audits had to rediscover inner-hover hit areas, metadata text sizing/color, markdown-tag rendering, none/open/in-progress/done status cycling, priority none, toast feedback, menu breadth, and the scheduled-date popup grouping after the initial visual parity pass looked superficially close.

The next durable rollout order for relationship-driven task rows is:

  1. broaden the current Relationships panel into fuller upstream-style relationship/dependency widgets
  2. finish broader upstream parity polish for the note-widget family

Task IDs: TASK-PLUGIN-007, TASK-PLUGIN-011

Inline Task Capture And Conversion

Upstream TaskNotes integrates with the editor so checklist lines and plain content can be promoted into TaskNotes files:

  • Create inline task command from the current line
  • per-line convert affordances next to checkboxes and other supported line shapes
  • bulk conversion of all checkbox tasks in a note
  • formatting-preserving replacement of the source line with a task link
  • metadata extraction and optional natural-language parsing during conversion

Lapis Notes now supports command-driven line, selection, and note-wide conversion plus reading-mode, source-mode, and live-preview convert affordances for supported prefixed Markdown task candidates. The current conversion path preserves list and blockquote structure while replacing source content with task-note links, and it extracts hashtags plus explicit ISO due dates into the mapped frontmatter model.

Task Schema, Settings, And Modal Parity

The upstream plugin exposes a much richer configurable task schema and task-editing surface than the current Lapis implementation:

  • title storage in filename or frontmatter with multiple filename strategies and templates
  • custom status and priority vocabularies with labels, colors, icons, and completion semantics
  • richer property coverage for scheduled dates, projects, contexts, reminders, recurrence, time estimates, blocked-by links, time entries, archive tags, and feature-specific keys
  • custom user fields with typed defaults and autosuggestion filters
  • configurable modal field groups, ordering, visibility, and required flags
  • NLP trigger configuration and capture-oriented defaults

Lapis Notes now ships a dedicated Tasks-view settings surface for the managed folder, marker tag, title storage, filename strategy, default status/priority/date presets, task-detection rules, status and priority vocabularies, and JSON-backed custom fields. Modal field-group editing, richer typed custom-field editors, and deeper filename-template parity are still missing.

Task ID: TASK-PLUGIN-009

Bases-Backed View Suite And Default Templates

Upstream TaskNotes v4 uses Bases as the main view system for task-focused navigation. The documented view suite includes:

  • Task List views
  • Agenda views
  • Kanban views with grouping, swimlanes, drag-and-drop, and manual ordering
  • Mini Calendar views
  • full Calendar views with day/week/month/year/list/custom-day modes
  • Relationships views backed by generated .base files
  • generated default .base templates with formulas, filters, ordering, and command-to-file mappings derived from TaskNotes settings

Lapis Notes now generates and opens TaskNotes-style .base files for Task List, Kanban Board, Fizzy, Mini Calendar, and Fizzy Calendar under the managed task views folder, and it registers matching custom Bases renderers (tasknotesTaskList, tasknotesKanban, tasknotesFizzy, tasknotesMiniCalendar, tasknotesFizzyCalendar) through the public Plugin.registerBasesView() path. The Fizzy renderer adds a first-pass Basecamp-Fizzy-style status board with drag-and-drop lane moves, persisted pinning, shared search controls, keyboard navigation, and optional entropy moves into a configured Not Now lane. These task-specific renderers intentionally use the Tasks plugin’s indexed markdown task-note dataset as the source of truth so the generated views stay browser-safe and do not depend on upstream Obsidian-only Bases internals.

The remaining gap in this feature family is upstream parity depth rather than feature existence: Agenda, full Calendar, and Relationships Bases views are intentionally out of scope for the current generated suite, the Fizzy base now covers a first-pass triage-board surface, but Kanban and Fizzy do not yet implement persisted manual ordering, richer swimlanes, comments, or boosts, and the remaining calendar renderers do not yet cover the full upstream mode set.

Task ID: TASK-PLUGIN-010

Lapis Planner Workflow

The Things-like planner is a Lapis-specific workflow layer rather than direct TaskNotes upstream parity. It should sit on top of the same markdown-backed task-note runtime and keep the existing Tasks table, generated Bases views, task-card widgets, recurrence/reminder/dependency behavior, and Fizzy workflows intact.

The planner should start with a pure bucket model and mapped when metadata, then add a dedicated planner view, Things-style planning actions, Inbox capture and triage, area links, manual ordering, settings controls, project sections, a generated Planner Base companion, and an optional review workflow. The dedicated planner view is the primary daily workflow; the generated Planner Base is a later query/customization companion.

Task IDs: TASK-PLUGIN-054, TASK-PLUGIN-055, TASK-PLUGIN-056, TASK-PLUGIN-057, TASK-PLUGIN-058, TASK-PLUGIN-059, TASK-PLUGIN-060, TASK-PLUGIN-061, TASK-PLUGIN-062, TASK-PLUGIN-063

The current core-lite bridge intentionally stops short of the richer TaskNotes model surface. Dependency and project link mutations now exist in the task-row surface, the note-widget now includes dedicated Reminders and Relationships panels, the relationship widget now renders fuller blocked-by and blocking task cards. The interactive runtime now also supports preset recurrence rules plus recurrence-aware completion or skip-forward actions that roll the managed due or scheduled field forward while recording completeInstances or skippedInstances, and the browse/task-row surfaces now expose mapped reminder metadata together with explicit due-date, day-before-due, scheduled-date, and picked reminder actions plus lightweight blocked/ready/blocking state, but the remaining missing behavior still includes:

  • custom recurrence rules and richer per-instance completion/skip state UX
  • broader reminder defaults and automation semantics
  • deeper dependency graphs (blockedBy, blocking, relationship tabs, richer readiness checks)
  • link semantics and richer note/task relationship handling
  • extended conformance profiles beyond core-lite

Task IDs: TASK-PLUGIN-006, TASK-PLUGIN-011

Time Tracking And Productivity Views

Upstream TaskNotes includes time-management features that cut across task cards, views, and dedicated productivity surfaces:

  • start/stop task timers writing to timeEntries
  • auto-stop of active timers when tasks complete
  • calendar-driven time-entry editing
  • timeblocking flows
  • Pomodoro timer view
  • Pomodoro statistics/history view

Lapis Notes now has an initial task-card start/stop timer surface that writes timeEntries, renders tracked-duration pills, and auto-stops active timers when tasks complete. Calendar time-entry editing, timeblocking flows, Pomodoro views, and richer time-policy settings are still missing.

Task ID: TASK-PLUGIN-012

Calendar And External Calendar Services

Upstream TaskNotes blends task scheduling with external calendar systems:

  • Calendar and Mini Calendar Bases views
  • OAuth-backed Google Calendar and Microsoft Outlook sync
  • ICS subscriptions
  • drag-and-drop calendar scheduling and event rescheduling
  • time-entry editor and timeblocking flows from calendar interactions

In Lapis Notes this area is partly a product gap and partly a runtime-capability decision, because browser-only delivery cannot safely absorb credential handling or background sync in the same shape as upstream Obsidian.

Task ID: TASK-PLUGIN-013

Automation And External APIs

Upstream TaskNotes also exposes automation-oriented surfaces:

  • HTTP API
  • webhooks
  • CLI-oriented flows
  • MCP-style and service-backed automation surfaces in the repo

These do not map directly onto the current browser-first Lapis host. They need an explicit capability decision before they can be sensibly vendored.

Task ID: TASK-PLUGIN-014

Proposed Sequence

  1. Implement schema/settings parity first so later view, editor, and conversion work target the right frontmatter model and configurable fields. (TASK-PLUGIN-009)
  2. Add inline task capture and editor/read-preview integration on top of that schema so task promotion and task-note widgets share one model. (TASK-PLUGIN-007, TASK-PLUGIN-008)
  3. Build the Bases-backed view suite and generated default templates once the schema and field mapping are broad enough to drive task list, kanban, agenda, calendar, and relationships views. (TASK-PLUGIN-010)
  4. Add the Lapis planner foundation after the markdown task runtime and reusable task-row surfaces are stable, starting with the bucket model plus when metadata and first-class planner view before later ordering, areas, project sections, generated Base, and review slices. (TASK-PLUGIN-054 through TASK-PLUGIN-063)
  5. Expand the model into recurrence, reminders, dependencies, and link semantics, then promote conformance beyond core-lite. (TASK-PLUGIN-006, TASK-PLUGIN-011)
  6. Add time-tracking and Pomodoro surfaces after task-card actions, dependency handling, and calendar entry points exist. (TASK-PLUGIN-012)
  7. Tackle calendar-provider and ICS integrations only after runtime capability boundaries for auth, secrets, and background synchronization are explicitly decided. (TASK-PLUGIN-013)
  8. Tackle external automation surfaces last, because they depend on the settled task model, view suite, and host-capability story. (TASK-PLUGIN-014)

Implementation Constraints

  • Visible UI should continue to use Svelte components backed by shared @lapis-notes/ui primitives where possible.
  • Upstream Obsidian internals should be replaced with Lapis Notes editor, Markdown, workspace, and Bases APIs rather than copied directly.
  • Browser-only delivery is the default assumption. Features that require secrets, background sync, local OS integration, or long-running services should be capability-gated and may need future desktop or service companions.
  • Any deviation from upstream TaskNotes behavior that changes user-facing semantics should be documented in the Tasks package page and linked back to the relevant task id when one exists.

Bases Plugin (@lapis-notes/bases)

Source: packages/plugins/plugin-bases/src/

Package: @lapis-notes/bases. Manifest: id bases, version 0.0.1, min app version 1.7.7.

The bases package owns structured data views over Markdown files and opens .bases and .base documents through BasesViewType. The package now also mounts the same query-and-view pipeline into markdown-owned read-only surfaces: .base and .bases file embeds render inline through the markdown embed registry, and fenced base or bases code blocks render the fence body as an inline read-only Bases document.

Data Model

type BasesPropertyType = "note" | "formula" | "file";
type BasesPropertyId = string | `${BasesPropertyType}.${string}`;
type SortColumn = { property: string; direction: "ASC" | "DESC" };
type FilterLine = { column: string; op: string; value: unknown };

type BasesDocument = {
  filters: unknown;
  properties: unknown[];
  formulas: unknown[];
  activeView: string;
  views: BasesView[];
};

Table views track column sizing, table/card layout, row height, card size, image configuration, image fit, and image aspect ratio.

Query And Rendering

The plugin integrates PEaQL, but QueryController no longer builds every candidate row directly from metadataCache.fileCache up front. It first asks app.appDatabase.queryIndexedMetadata() for a plugin-neutral indexed metadata row set, lowering only conservative conjunctive constraints such as folder prefixes, file extensions, tag requirements, property existence, scalar property comparisons, and supported file or property sorts. Returned rows are materialized back into VaultRecord objects before PEaQL executes over the BasesTable database context, so the renderer still owns final query semantics while AppDatabase can narrow and order the candidate set in worker or native SQLite-backed runtimes. When the AppDatabase query path is unavailable or fails, Bases falls back to the previous metadata-cache-derived renderer snapshot.

Indexed-Metadata Pushdown

Current Bases documents do not compile the full filter or formula surface into AppDatabase. The current pushdown boundary is:

Bases query shapeLowered into AppDatabase.queryIndexedMetadata()Notes
file.inFolder(...)YesBecomes pathPrefixes.
file.hasTag(...)YesBecomes requiredTags.
file.hasProperty(...) and !hasProperty(...)YesBecomes property existence checks.
file.ext = ...YesOnly direct extension equality is lowered.
note.foo =, !=, >, >=, <, <= and the same unprefixed property idsYesOnly scalar property comparisons are lowered.
Sort by file.path, file.ext, file.mtime, file.size, or a direct propertyYeslimit is only pushed down when every active sort lowers successfully.
OR groups, non-conjunctive filters, custom filter strings, and custom filter linesNoThe lowering pass only walks explicit and groups.
File predicates outside the subset above, such as file.name comparisonsNoThese stay renderer-owned even though Bases still evaluates them.
Link predicates such as file.hasLink(...)NoThe indexed metadata contract can carry resolved link targets, but the current Bases lowering does not emit them.
Formulas, group-by behavior, view search, and custom view logicNoThese continue to run after candidate rows are materialized back into VaultRecords.

Unsupported or unlowered clauses do not disable Bases queries. They simply are not pushed into AppDatabase, and PEaQL still applies the full Bases semantics in the renderer over the loaded candidate rows.

Explicit note.* properties declared in a Bases document and observed frontmatter keys inferred from rows are both materialized into the structured note.* query schema, which keeps generated plugin-owned .base documents and raw predicates like note.status compiling against the structured note object.

Rendered views include a TanStack Table data grid with sizing, sorting, filtering, visibility, and cell editing, plus a card/grid renderer with configurable image handling. Card image fields resolve string, wikilink/embed, markdown-image, and file values to vault image files and render them through Vault.getResourceUrl(), so Electron uses the scoped native resource protocol while browser vaults keep blob URL fallback behavior. Toolbar filter state, counts, and popover rows reflect both document-wide filters and view-local view.filter predicates, including structured filter-line objects such as inFolder rules stored in .base documents. Metadata invalidation is owned by QueryController rather than by each child renderer: a base now reacts once per metadata event and uses conservative dependency tracking over the current filters, visible or sorted properties, group-by state, and direct file-reference neighborhood to decide whether it needs to rerun the query. Static file-path fields such as file.name and inFolder no longer force a vault-wide reload on unrelated metadata changes, while mutable file fields such as file.mtime, custom formulas, and direct-link neighborhoods still keep the broader conservative fallback. The Sort popover and table column headers now both use @dnd-kit/svelte drag handles, so Bases keeps one drag stack for sort-clause and column-order reordering while persisting updated view.order values back into the .base document. The built-in Bases view registry also merges external view registrations supplied through Plugin.registerBasesView() so other plugins can contribute Bases-specific layouts through a public runtime path. Custom view registrations now persist across plugin startup order, and the Bases document normalizer preserves non-built-in view type ids when .base files round-trip through source mode, so generated plugin-owned task views can reopen without collapsing to the unknown-view fallback. Markdown-hosted embeds and fenced Bases blocks intentionally mount the same renderer in a read-only configuration: they reuse query execution, sorting, grouping, and rendering, but they do not write source document edits back through the vault or expose the file-view source/preview mode switcher.

Tag cells use the shared @lapis-notes/ui/tag renderer so Bases tag chips match Markdown preview tags and metadata property chips.

The shared Bases view-settings panel renders labeled controls for plugin-registered dropdown, slider, property, text, and toggle options, so custom .base views can expose editable per-view configuration directly from the sidebar. Filled check indicators in Bases popovers use --interactive-accent with --text-on-accent so selected property, group, sort, and visibility controls keep readable foreground contrast under custom themes.

CSS Ownership

The package follows the Plugin CSS Contract with workspace dist/app.css, standalone dist/styles.css, and bases-... selectors across the shell, toolbar, table, card/list, summary, and error surfaces.

Bases Parity Audit

This note audits the current Bases implementation in this repository against two upstream references:

  • Obsidian API types from obsidian.d.ts v1.12.3
  • Obsidian help pages for Bases, Views, Syntax, Functions, and Formulas

The audit covers two local surfaces:

  • the shared compatibility API in packages/api/src/lib/bases.ts
  • the working runtime in packages/plugins/plugin-bases/src/

The main finding is that the repository has partial Bases coverage, but parity is split across two different implementations:

  • @lapis-notes/api exports a compatibility-oriented Bases surface with several type and helper shims
  • plugin-bases owns the actual .base and .bases runtime, query execution, toolbar, and built-in views

That split means the repo currently has name-level parity in some places without behavior-level parity.

Progress since the initial audit

The first parity follow-up slice has landed since this note was originally written.

Completed work:

  • the shared API now carries the richer DateValue and DurationValue helpers that had previously lived only in the plugin-private model, including parsing, relative/date-only helpers, duration math, and millisecond conversion
  • the shared config and option surface now includes formulas, summaries, groupBy, recursive filter unions, and the upstream-style options?: (config) => ... callback shape
  • plugin-bases now consumes shared BasesPropertyId, shared BasesViewConfig, and shared view option types in more of the runtime surface instead of maintaining separate local copies
  • the function gap list is now backed by an executable plugin test in src/bases-view/functions-parity.spec.ts, which locks in the currently supported names and makes regressions visible immediately
  • plugin-bases now has a dedicated vitest.config.ts, which makes these TypeScript-only parity checks runnable without bringing up the package’s Svelte/Vite browser test path
  • the runtime now exposes additional file-derived metadata columns for file.links, file.embeds, file.backlinks, and file.properties, backed by focused tests around the shared derivation logic
  • the runtime now threads groupBy into its query result model so groupedData is populated when a Base view config includes a grouping key, backed by focused tests around the grouping helper, and the query generator now also projects the active group key even when that property is hidden from the table order so grouped renderers do not collapse into Ungrouped
  • the plugin now registers a built-in list view and includes a grouped list renderer with compact unordered-list rows built from the selected columns, reducing the built-in view gap to map
  • the plugin now also registers a first-class map view with an explicit unsupported-state renderer, so all documented built-in layout names are present even though real map capability is still missing
  • the source-to-preview document normalization path now preserves first-class cards views instead of dropping them during YAML reloads, so newly added card views survive mode switches
  • grouped cards views now keep their section headers sticky within the scrolling cards pane, matching the existing grouped list/table behavior more closely
  • grouped list and cards sections can now be folded from their sticky headers using local per-view collapse state
  • grouped table views can now also be folded from either the inline group row or the sticky active-group header while keeping the virtualized table body in sync
  • the documented file-property list is now fully exposed in the runtime, including the explicit file.file self-reference field
  • the shared API now implements BasesView.createFileForView() using the existing file-manager path allocation, optional frontmatter processing, and file-opening flow
  • the plugin toolbar now surfaces create-new-file through a direct New button, while copy/export CSV actions live in the result-limit popover backed by the same createFileForView() and CSV serialization helper flows
  • preview-mode Bases config edits now write the serialized .base document back to the underlying file immediately instead of waiting on the editor’s debounced autosave path, which keeps newly added views from disappearing on quick reloads
  • the plugin toolbar now exposes a search toggle that opens a search panel above the active view while filtering the current result set across the displayed properties, the toolbar result count now continues to reflect the underlying query result size instead of the transient search subset, and the search input now keeps the controller query state synchronized through focus changes and scrolling in the virtualized table layout while surfacing the active match count and a clear-search action inline
  • the table layout now applies base-level and per-view summary config in a footer row, backed by a focused summary-resolution helper test, and the shared width map now keeps headers and body cells aligned on first render while the trailing visible column absorbs spare horizontal room; active column resizing now keeps the virtualized header and body cells in lockstep while widths shrink or grow, the sticky header background strip now matches the rendered header height, the first row starts directly beneath that header without an extra spacer gap, the first visible row now renders a top border so that junction reads as the header’s bottom edge, table headers now use a visible full-cell hover background only for non-active columns while focused headers keep a stable active background without the old sorted-column ring treatment or the extra header focus shadow, active table searches now keep a sticky in-table query strip while the panel is closed and stay synchronized with the virtualized row body when the filtered result set shrinks or the previous scroll position would otherwise leave stale row indexes behind, and grouped table views render full-width section rows for the active group key with a sticky section label beneath the table header instead of staying flat
  • list, cards, and the map fallback now also expose configured summaries through a compact summary strip
  • the property editor now surfaces base-level and per-view summary selectors so configured summaries are no longer edit-only in raw .base JSON
  • the sort popover now includes a groupBy picker with direction controls above the sort list, and the popover itself scrolls when vertical space is constrained so those controls stay contained within the panel

The remaining parity work is still substantial, but the repo now has an executable checklist and less type/config duplication between @lapis-notes/api and plugin-bases.

Current implementation

Shared API

packages/api/src/lib/bases.ts currently exports the public Bases compatibility layer used by first-party plugins and parity tests. It includes:

  • core identifiers and config types such as BasesPropertyType, BasesPropertyId, BasesConfigFile, BasesViewConfig, and BasesViewRegistration
  • Value wrappers such as BooleanValue, NumberValue, StringValue, DateValue, DurationValue, FileValue, ListValue, and ObjectValue
  • query result helpers such as BasesEntry, BasesEntryGroup, BasesQueryResult, QueryController, parsePropertyId, and toValue

This file is useful as a compatibility surface, and it now owns more of the shared value/config behavior than it did during the initial audit. It is still not the complete source of truth for the running Bases feature, because runtime query execution and view/controller behavior remain plugin-local.

Runtime plugin

packages/plugins/plugin-bases/src/ contains the working Bases feature. Today it provides:

  • a BasesPlugin that registers .base and .bases files to the bases view type
  • a BasesView file view that supports source and preview modes
  • built-in table, cards, list, map, and fallback unknown renderers
  • a query controller that builds rows from metadataCache, frontmatter, and a PEaQL-backed in-memory table
  • toolbar actions for view selection, limit, sort, group, filters, search, CSV export/copy, note creation, and property visibility/formulas/summaries

The runtime supports:

  • global filters and per-view filters
  • formula columns
  • base-level and per-view summaries
  • grouped result derivation from groupBy
  • sort order
  • row limits
  • search across displayed properties
  • frontmatter-backed note properties
  • toolbar create/copy/export workflows
  • a small set of file-derived columns such as file.name, file.path, file.folder, file.ext, file.ctime, file.mtime, file.size, and file.tags

Internal duplication

The plugin also carries a second, private Bases runtime model in packages/plugins/plugin-bases/src/bases-view/bases.svelte.ts.

Some of the duplication identified in the original audit has now been reduced:

  • DateValue and DurationValue helper behavior has been moved into the shared API
  • shared BasesViewConfig and shared option types are now used by the plugin’s view configuration surface

The remaining private model still owns the runtime-specific pieces, especially:

  • QueryController
  • the plugin-local BasesView base class
  • the running query/result lifecycle

Because the plugin and shared API evolve independently, parity work done in one layer does not automatically reach the other.

Confirmed parity gaps

1. Shared API type drift from upstream

The shared API does not currently match the upstream Bases types closely enough to claim parity.

Confirmed drift includes:

  • BasesPropertyId is declared as string | ... locally, while upstream requires a prefixed form ${BasesPropertyType}.${string}
  • BasesConfigFile now includes upstream-style formulas and summaries, and the runtime executes more of that config than it did initially, but not with full upstream semantics
  • BasesConfigFileFilter is currently a broad Record<string, unknown> instead of the recursive upstream union of string | { and } | { or } | { not }
  • BasesConfigFileView now includes groupBy and summaries, and the runtime uses both fields, but grouped-view and summary behavior is still narrower than upstream
  • option interfaces have been expanded to cover fields such as placeholder, filter, instant, and shouldHide, but view/config semantics are still incomplete in the runtime
  • BasesOptionGroup and BasesViewRegistration.options now match the upstream grouped-options and callback shape more closely
  • BasesView.createFileForView() is now implemented in the shared API and surfaced in the plugin toolbar, but the public API still does not drive the full runtime directly
  • BasesViewConfig.getEvaluatedFormula() is a stub that wraps raw config values instead of evaluating a formula in Bases context

2. Runtime config semantics are narrower than Obsidian Bases

The current plugin runtime only implements a subset of the documented Bases config model.

Confirmed gaps include:

  • the runtime now groups groupedData when groupBy is present in the view config, and the toolbar can edit that config; the list, cards, and table layouts now render grouped sections explicitly, but other grouped-view semantics are still narrower than upstream
  • no evidence that this context is supported the way the docs describe for opened, embedded, and sidebar Bases

The runtime query generator currently applies formulas, filters, sort, limit, grouped result derivation, and summary aggregation, but not the broader grouped-view workflows or this semantics that Obsidian exposes.

3. Built-in view coverage is incomplete

Obsidian help documents four built-in view layouts:

  • table
  • cards
  • list
  • map

The current plugin now registers:

  • table
  • cards
  • list
  • map
  • unknown

For map, the upstream product also depends on the Maps plugin. The local runtime now provides an explicit fallback state for that layout name, but it still does not implement a real map-capable renderer.

4. Toolbar and workflow coverage is incomplete

Obsidian’s help pages document a broader toolbar and workflow surface than the current plugin provides.

Currently present:

  • view selection and editing
  • limit
  • sort
  • filter
  • property visibility and formula editing
  • search over displayed properties through a toggleable panel above the active view
  • base-level and per-view summary editing for the selected property
  • group configuration editing

Documented but currently missing or not surfaced in the plugin:

  • no additional toolbar/workflow gaps are currently called out by this audit beyond the remaining layout/semantic differences below

5. File property coverage is incomplete

The docs describe a broader file-property surface than the plugin currently provides. Verified local coverage today is limited to:

  • file
  • file.file
  • file.name
  • file.basename
  • file.fullname
  • file.path
  • file.folder
  • file.ext
  • file.ctime
  • file.mtime
  • file.size
  • file.tags
  • file.links
  • file.embeds
  • file.backlinks
  • file.properties

The documented file-property names are now exposed in the runtime. Some related helpers such as hasLink() and hasProperty() exist, but richer file-value semantics are still missing.

6. Formula function coverage is partial

Verified local function coverage comes from two places:

  • explicit Bases registrations in plugin-bases/src/bases-view/functions.ts
  • generic PEaQL functions in node_modules/peaql/src/lib/query/query_env.ts

Verified function names available today:

  • local Bases-specific: isEmpty, isTruthy, toString, startsWith, endsWith, contains, containsAny, containsAll, containsAnyOf, containsAllOf, inFolder, hasLink, hasTag, hasProperty, length, map, filter, reduce, icon, title, relative, time, trim, split, replace, repeat, reverse, slice, ceil, floor, list, number, max, min, random, flat, join, sort, unique, escapeHTML, date, duration, file, html, image, link, if, isType, asFile, asLink, linksTo, keys, values, matches
  • PEaQL-provided names that overlap partially with Bases docs: abs, round, toFixed, today, now, format, lower

The remaining function parity gaps are now mostly about value-model depth and method-style semantics rather than missing registered function names.

Documented method-style functions that are missing, only partially present, or exposed under different names:

  • any: thin isTruthy, toString, and isType helpers are now registered, but the broader method-style value API is still incomplete
  • date: date(), format(), time(), relative() as date methods rather than only ad hoc helpers
  • string: method-style dispatch still needs broader runtime coverage beyond the newly registered names
  • number: method-style dispatch still needs broader runtime coverage beyond ceil and floor
  • list: method-style dispatch semantics still need deeper runtime coverage even though the helper names are now registered
  • link: asFile and linksTo are now registered as thin helpers, but richer link-value semantics are still missing
  • file: asLink is now registered as a thin helper, but richer file-value semantics are still missing
  • object: broader value-model integration still needed beyond keys and values
  • regexp: broader value-model integration still needed beyond matches

There is also naming drift from the docs:

  • docs use containsAny and containsAll
  • the runtime now exposes both the upstream names and the older local aliases containsAnyOf and containsAllOf

That reduces one compatibility gap, but formula compatibility is still not aligned with Obsidian’s public Bases language overall.

7. Value-model parity is incomplete

The shared API exports some upstream value classes by name, but behavior is incomplete.

Examples:

  • shared DateValue and DurationValue now expose the core helper methods that were called out in the original audit, but other value wrappers remain thin
  • shared LinkValue, HTMLValue, IconValue, ImageValue, TagValue, UrlValue, and RegExpValue are still mostly marker subclasses without upstream behavior
  • the private plugin model still owns runtime-specific view/controller behavior that is not shared through @lapis-notes/api

8. Parity reporting is more optimistic than the actual implementation

packages/api/scripts/generate-obsidian-parity.mjs marks many Bases symbols as implemented through explicit overrides. The generated parity fixture therefore reports name-level coverage, but it does not validate:

  • type accuracy
  • method completeness
  • runtime semantics
  • formula language compatibility
  • UI workflow parity

The current parity report is useful as an inventory, but not as proof of working Bases parity.

Plan to attain parity

1. Make @lapis-notes/api the single source of truth for Bases types and values

Unify the duplicated Bases model so the plugin consumes the same public implementation that the rest of the repo imports.

Work items:

  • move the richer private value and config types out of plugin-bases and into packages/api/src/lib/bases.ts
  • update plugin-bases to import those shared types instead of maintaining a parallel copy
  • align all exported types and method signatures to upstream obsidian.d.ts unless the repo intentionally documents a divergence

2. Close the shared API type gaps first

Before adding more UI behavior, the public API should match the upstream Bases contract closely.

Work items:

  • correct BasesPropertyId, BasesConfigFile, BasesConfigFileFilter, BasesConfigFileView, and option interfaces
  • finish aligning the remaining upstream fields and semantics such as stricter filter typing, placeholder, filter, instant, and shouldHide
  • update BasesViewRegistration.options to the upstream callback signature
  • implement BasesViewConfig.getEvaluatedFormula() rather than leaving it as a stub

3. Implement the documented Bases value model and function surface

Formula compatibility depends on both type wrappers and function dispatch.

Work items:

  • finish the value wrappers for DateValue, DurationValue, LinkValue, HTMLValue, ImageValue, IconValue, FileValue, ListValue, ObjectValue, and RegExpValue
  • add the documented global and method-style Bases functions under the upstream names
  • add compatibility aliases only where needed, but prefer Obsidian’s names over local variants
  • explicitly map or wrap PEaQL helpers so formula authors can use Obsidian syntax instead of query-engine internals
  • add tests for every documented function name and for key return-type/rendering behavior

4. Expand the query engine to match Bases semantics

The runtime currently handles formula projection, filtering, sorting, and limit. It needs the rest of the documented Bases execution model.

Work items:

  • deepen grouped-view rendering beyond the current list, cards, and table grouped sections
  • implement custom summary formulas beyond the current keyed aggregate support
  • add this binding semantics for opened, embedded, and sidebar Bases
  • support the documented file-property fields such as backlinks, links, embeds, and properties with clear refresh behavior

5. Bring the built-in views and toolbar up to documented coverage

Once the shared model and query runtime are aligned, the UI should match the documented Bases workflows.

Work items:

  • design a map view strategy that can either integrate with a future map capability or fail explicitly behind capability detection
  • extend grouped rendering and richer grouped workflows to cards, table, and any eventual map implementation

6. Replace name-level parity overrides with executable parity checks

The current parity fixture should become a starting point, not the success criterion.

Work items:

  • keep the symbol inventory, but stop treating explicit overrides as proof of parity
  • add shared API tests that validate signatures and observable behavior for Bases-specific classes
  • add plugin-level tests that exercise .base parsing, formulas, filters, summaries, grouping, and view registration
  • add function-coverage tests that compare the documented Bases function list against the registered/effective runtime function list

To reduce churn, parity work should land in this order:

  1. Continue unifying the shared API and plugin-private Bases model, starting with the remaining runtime classes instead of recreating more config/value shims locally.
  2. Align config and value types with upstream obsidian.d.ts.
  3. Add missing function names and value behaviors with exhaustive tests.
  4. Deepen method-style value behavior, grouped rendering, this, and missing file semantics.
  5. Finish the remaining built-in view behavior, especially real map capability and non-list grouped layouts.
  6. Add map view only after the runtime has a clear map-capability story.
  7. Tighten parity tooling so future audits measure behavior, not just symbol presence.

Current parity status

Current status can be summarized as follows:

  • shared API parity: partial and type-shim heavy
  • runtime query parity: partial but broader than the initial audit
  • documented function parity: partial but largely name-complete
  • built-in view parity: near-complete on layout names, incomplete on grouped behavior and map capability
  • toolbar/workflow parity: broad for the documented day-to-day controls
  • parity reporting fidelity: optimistic

The repository is far enough along to support basic Bases workflows, but it should not yet be considered at parity with upstream Obsidian Bases.

Canvas Plugin (@lapis-notes/canvas)

Source: packages/plugins/plugin-canvas/src/

Manifest: id lapis-canvas, version 0.0.1, min app version 1.7.7.

The canvas package owns external official JSON Canvas editing for .canvas files. The package remains in the monorepo for building official release artifacts, but the workspace no longer bundles or registers it by default.

Runtime Surface

  • View type: canvas.
  • Extensions: .canvas.
  • Command: create-new-canvas.
  • Official registry identity: lapis-canvas.
  • Install location: /.obsidian/plugins/lapis-canvas; listed under Core plugins when installed provenance is official.
  • Package exports: plugin runtime entry at @lapis-notes/canvas, read-only embed helpers at @lapis-notes/canvas/embed, and standalone stylesheet artifact at @lapis-notes/canvas/styles.css.

Key Behaviors

  • Parses JSON Canvas documents into Svelte Flow nodes and edges.
  • Serializes edits back into JSON Canvas after changes.
  • Supports creating, editing, resizing, coloring, and connecting text, link, file, and group nodes, including containment-driven group membership that moves contained nodes with the group, adopts dragged-in nodes, releases dragged-out nodes, and leaves contained nodes in place when the group is deleted.
  • Renders whole-file markdown file nodes through the markdown package’s editable inline markdown surface, while markdown subpath targets and non-markdown file nodes reuse the markdown package’s read-only file-embed rendering path.
  • Shows file-node filenames in a badge anchored over the node’s top-left border so the file identity stays visible even when the body is an embedded renderer.
  • Leaves embedded markdown and file-preview surfaces inert until the node is selected, so wheel and pointer gestures keep panning the canvas for unselected nodes.
  • Suppresses per-node action toolbars during multi-node selections and renders one shared bulk toolbar above the selection bounds for actions that apply to the whole selection, including grouping the selection into a newly selected group with immediate label editing, delete, recolor, and zoom.
  • Renders group labels as top-right badge inputs that keep a larger minimum edit size around mid-to-low zoom, use the available badge width instead of shrink-wrapping to the text, and choose black or white text from the computed group-badge background brightness so saturated accents like purple stay readable.
  • Renders JSON Canvas palette colors as tinted node surfaces and maps directional connectors onto compact filled edge markers that stay visible without explicit connector colors.
  • Thickens hovered connectors to the selected stroke width and glow, keeps visible connector labels on normal zoom scaling as padded label chips that mask the line beneath them, routes connectors as curved paths with short straight exit segments from node sides before the bezier span begins, renders the inline connector label editor as a CSS-sized contenteditable surface with a larger minimum edit size at small zoom, and exposes edge-toolbar actions to edit or remove existing connector labels.
  • Switches text and file nodes into low-detail previews below zoom 0.3, showing skeleton placeholders for text nodes and centered filename pills for file nodes while leaving group headers and edge labels on their normal zoom scaling path.
  • Keeps connector handles hidden at rest while their edge anchors still sit on the node boundary, and reveals each side only when its matching resize control is hovered or focused while still exposing connection-state indicators during active drags.
  • Shows valid target handles as visible indicators during connector drags and ignores duplicate identical connect emissions so one drag persists only one edge.
  • Styles the drag-selection overlay with the interactive accent token for both its border and translucent fill.
  • Uses empty-space drag as the default box-selection gesture and switches that drag gesture back to viewport panning while the Space key is held.
  • Uses wheel scrolling to pan the canvas viewport by default, while ctrl-wheel zooms the viewport, with an explicit minimum zoom of 0.03.
  • Renders group nodes as lower-layer container surfaces.
  • Clips the interactive canvas surface to the active pane.
  • Creates new empty canvas files at the vault root.
  • Persists debounced canvas edits through the vault.
  • Exports a read-only CanvasEmbed surface plus JSON Canvas parsing, serialization, flow-conversion, and color helper utilities so external hosts can render supplied canvas data without file-backed view lifecycle or save behavior.
  • Registers .canvas with the markdown embed registry so markdown reading mode and live preview render canvas file embeds through the same read-only surface instead of falling back to raw text.
  • The first embed slice intentionally stays read-only and data-driven: it renders supplied text, file, link, and group nodes without the editable canvas node toolbars, drag/resize controls, or vault-backed file preview lifecycle used by the full plugin view.
  • Hosts may optionally provide explicit file-content previews to CanvasEmbed so file nodes render richer markdown bodies while still staying read-only and vault-independent.
  • Read-only embeds keep connector arrows and passive connector labels visible, and overflowing text/file preview content scrolls inside the node body instead of expanding the host frame.

CSS Ownership

The package injects Svelte Flow and canvas styling at plugin load time, uses canvas-... namespaced selectors, and should remain aligned with the shared Plugin CSS Contract as first-party CSS ownership evolves.

CSV Plugin (@lapis-notes/csv)

Source: packages/plugins/plugin-csv/src/

Package: @lapis-notes/csv. Manifest id: csv.

The CSV package owns bundled CSV and TSV editing with rainbow source highlighting and an editable table preview.

Registration

  • View type: csv
  • Core-plugin activation through the workspace shell
  • File associations: .csv, .tsv
  • Language-service provider: csv-lint (in-plugin, diagnostics for csv and tsv)

Current Functionality

  • Ships the first-party plugin package and manifest for bundled CSV support.
  • Registers a dedicated file-backed CSV leaf instead of falling back to a generic text editor.
  • Opens .csv and .tsv files in a dual-mode view: table preview (default) and rainbow-highlighted source.
  • Uses a vendored Lezer grammar adapted from codemirror-lang-csv for rainbow column highlighting in source mode.
  • Parses and serializes CSV text through Papa Parse with a mutable CsvGrid model for table preview edits.
  • Syncs preview and source through the shared text Editor instance attached to the leaf.
  • Renders the table preview with @lapis-notes/ui/table, sticky headers, optional 1-based # row numbers, rainbow column tinting in preview, border-positioned row/column grips (column grip on the header top border), rectangle multi-cell selection, @dnd-kit/svelte row/column drag reorder with a custom DragOverlay preview of the full row or column, a 5px pointer-move activation threshold on grips so click-to-select stays distinct from drag-to-reorder, accent drop indicators, and in-table source fading while dragging, context menus for clear/delete cells plus row/column actions, editable headers with automatic header-row promotion, column move menu actions, and markdown-inspired --interactive-accent selection chrome. Header column menus are absolutely positioned so selection borders align with body cells. Row drag uses a single draggable on the first body cell grip with row droppables on every body cell. Playwright coverage in packages/workspace/e2e/csv-editor.spec.ts includes grip click selection, column grip drag reorder, multi-cell drag-select, row reorder via the context-menu Move row down action, and computed-style checks that selected cells render the accent fill (--table-selection) and border (--interactive-accent) overlay.
  • Wires preview-mode undo/redo (Mod+Z, Mod+Shift+Z, Mod+Y) through a view scope while deferring to native input undo during active cell edits.
  • Marks preview serialization edits as CodeMirror user events so shared editor history groups sensibly.
  • Skips CodeMirror lint diagnostics in table preview mode; CSVLint runs only in source mode to avoid redundant parse work during table edits.
  • Registers an in-plugin CSVLint-inspired language-service provider (csv/field-count, csv/quote-consistency, csv/parse-error) with line-bounded diagnostic ranges and a document-size guard; surfaced in source mode through shared lint gutter/inline UI.
  • Shows Papa Parse errors in the table preview banner without re-parsing on programmatic editor sync echoes.
  • Persists plugin settings (header row, delimiter override, row numbers) through bundled plugin data.
  • Emits dist/app.css for workspace loading and dist/styles.css for standalone/plugin-local CSS consumption.
  • Namespaces plugin-owned chrome under csv-... selectors and maps colors through --csv-... theme variables.

E2E Vault Fixtures

Tracked under e2e-vault/plugin-csv/:

  • CSV Feature Tour.md — manual verification guide
  • assets/valid-basic.csv — table preview baseline
  • assets/valid-quoted-multiline.csv — quoted commas and multiline cells
  • assets/valid-sample.tsv — tab-delimited sample
  • assets/lint-ragged-columns.csv — ragged row lint fixture
  • assets/lint-quote-mismatch.csv — broken quoting lint fixture

Package Boundary

The plugin owns CSV-specific file views, parsing, table preview UI, CSV lint registration, and CodeMirror language support. Generic file-view lifecycle, plugin activation, shared lint UI, and vault I/O remain in @lapis-notes/api and the workspace shell.

Docs Plugin (@lapis-notes/docs)

Source: packages/plugins/plugin-docs/src/

Manifest: id lapis-docs, version 0.1.0, min app version 0.20.0.

The docs package owns Univer-backed editing for native .lapisdoc and .lapissheet files. It ships as the official installable lapis-docs registry plugin instead of through the bundled workspace bootstrap.

Runtime Surface

  • View types: docs-document, docs-sheet.
  • Extensions: .lapisdoc, .lapissheet.
  • Commands: lapis-docs:create-document, lapis-docs:create-sheet.
  • Package exports: plugin runtime entry at @lapis-notes/docs, app stylesheet at @lapis-notes/docs/app.css, and standalone stylesheet artifact at @lapis-notes/docs/styles.css.
  • Official registry entry: lapis-docs in the locked lapis-official source, advertising .lapisdoc and .lapissheet editor-view contributions.

File Format

Both native file types store plain JSON wrappers in the vault:

{
  "kind": "univer-doc | univer-sheet",
  "version": 1,
  "snapshot": {},
  "meta": {}
}
  • kind selects the matching Univer runtime and view.
  • version gates future migrations.
  • snapshot preserves Univer-owned document or workbook state without normalizing its schema inside Lapis.
  • meta is optional provenance space for future import/export workflows.

Key Behaviors

  • Registers dedicated editor views for .lapisdoc and .lapissheet after the official lapis-docs plugin is installed and enabled. Before installation, the workspace on-demand install prompt can offer Install and open from the signed official registry contribution summary.
  • Parses native snapshot wrappers, rejects malformed JSON and wrong file kinds, and shows explicit in-view error states instead of falling back to raw text.
  • Bootstraps Univer Docs and Sheets lazily through the documented @univerjs/presets createUniver() entry point, with directly composed OSS Univer plugin lists for each editor type. The runtime keeps preset CSS and locale assets but avoids preset JS facade side effects that register sheet-only observers against document sessions.
  • Waits for the mounted editor pane to have usable dimensions before creating Univer, so sheets do not initialize against zero-width panes.
  • Keeps the Univer runtime lazy-loaded, but browser builds must still load the runtime dependency shim before Univer so async-lock, React, and related CommonJS dependencies initialize without ESM interop failures.
  • Mirrors the app light/dark state into Univer and bridges app theme tokens into the embedded surface so sheet borders, buttons, and other chrome remain visible under the workspace design system.
  • Persists snapshot changes back to the vault as debounced JSON writes and surfaces save failures with reload and retry actions.
  • Creates new native document and sheet files at the vault root through plugin-scoped commands.
  • Seeds the demo vault with representative .lapisdoc and .lapissheet fixtures that render visible document and sheet content for smoke verification and workspace e2e coverage.
  • Workspace e2e coverage verifies editor readiness and rendered Univer output, not only shell mount, and fails on unexpected page errors or console warnings/errors from the docs runtime.

Deferred Scope

  • .lapisslide is reserved conceptually but not registered because Univer Slides is still outside the repo’s production-ready target.
  • .xlsx, .docx, .pptx, .csv, and .tsv remain out of scope for this plugin. CSV/TSV stays with the CSV plugin, and Office exchange work remains a future server-backed follow-up.
  • Existing vaults that have data under the old bundled docs ID are migrated by the workspace startup path by copying that data to lapis-docs when the new target ID has no data yet. The source docs data is preserved during this release.

CSS Ownership

The package ships a standalone stylesheet artifact that combines Univer preset CSS with package-local shell styling. Registry installation stores that styles.css artifact under /.obsidian/plugins/lapis-docs/, and the plugin manager injects it when the installed plugin is enabled. Package selectors stay under the docs-view__ namespace and follow the shared Plugin CSS Contract.

Fmode Plugin (@lapis-notes/fmode)

Source: packages/plugins/plugin-fmode/src/

Manifest: id fmode, version 0.0.1, min app version 1.7.7.

The fmode package owns bundled hint-based keyboard navigation for workspace chrome that explicitly opts into a shared hint-target contract.

Runtime Surface

  • Command: toggle-fmode
  • Default hotkey: Mod+Shift+F
  • Declarative settings namespace: fmode.*
  • Package exports: plugin runtime entry at @lapis-notes/fmode, inline overlay stylesheet at @lapis-notes/fmode/app.css, and standalone stylesheet artifact at @lapis-notes/fmode/styles.css

Key Behaviors

  • Opens a temporary fixed-position overlay that labels currently visible hint targets with unique prefix-free hint codes, so no visible hint is a prefix of another and longer hints remain selectable.
  • Keeps hint badges aligned to their targets while the current pane scrolls or resizes, and anchors each badge to the target’s bottom-right corner by default. Badge visibility and placement respect both the browser viewport and clipping ancestors such as split-pane scroll containers, so hints disappear when a target scrolls out of its pane and visible hints stay inside the clipped pane edge.
  • Collects targets through Workspace.getVisibleHintTargets(), which scans visible DOM nodes marked with data-hint-target and related data-hint-* metadata.
  • Uses a plugin-owned modal Scope registration with wildcard key matching plus a capture-phase document keydown listener so hint input and editor keys are consumed by the overlay instead of leaking into the active editor, view, or browser host while fmode is open.
  • Narrows the visible hint set as the user types, activates on an exact match, and exits on Escape or after a successful selection.
  • Supports click and focus targets plus command-backed targets through data-hint-command-id metadata.
  • Initial bundled target coverage includes markdown preview links, file explorer rows and toolbar actions, search result rows and match rows, view-header navigation and action buttons, and top, stacked, and sidebar tab chrome.
  • Reads declarative fmode.* configuration on each activation so users can customize the hint alphabet, opt surfaces in or out, switch between detailed and compact HUDs, choose whether invalid input flashes or closes the overlay, and override the overlay accent color or badge text detail.
  • fmode.hudMode = "minimal" hides the top-right HUD and renders only the hint code next to each target, suppressing label and description chrome for a less intrusive presentation.

CSS Ownership

The package owns the fmode-overlay__... selector family and follows the shared Plugin CSS Contract for first-party plugin chrome.

Testing

Focused Vitest coverage lives with the package for hint-label generation, query filtering, and settings normalization. Shared API coverage for target discovery and modal wildcard key handling stays in @lapis-notes/api, and workspace e2e coverage includes scroll-stack and split-pane clipping regressions for rendered badge visibility.

Graph Plugin (@lapis-notes/graph)

Source: packages/plugins/plugin-graph/src/

Manifest: id lapis-graph, version 0.0.1, min app version 1.7.7.

The graph package owns registry-installable global and local graph views driven by the metadata cache and a D3 force simulation. The package remains in the monorepo for building official release artifacts, but the workspace no longer bundles or registers it by default.

Runtime Surface

  • View types: graph, graph-local.
  • Commands: open-graph-view, open-local-graph, focus-active-file-in-graph.
  • Official registry identity: lapis-graph.
  • Install location: /.obsidian/plugins/lapis-graph; listed under Core plugins when installed provenance is official.
  • Package exports: plugin runtime entry at @lapis-notes/graph, read-only embed helpers at @lapis-notes/graph/embed, and standalone stylesheet artifact at @lapis-notes/graph/styles.css.

Key Behaviors

  • Builds the note graph from resolved links, unresolved links, markdown embeds, and cached tags.
  • Rebuilds after the metadata cache loaded event, which now fires after app-database restore, portable backup hydration, or first-open rebuild, so restored graph leaves populate during desktop cold boot without a manual refresh.
  • Exposes filters for tags, attachments, unresolved nodes, orphan visibility, and search-query label/path/tag matches.
  • Provides a local graph centered on the active note with configurable BFS depth, including deferred initial centering when a restored or hidden graph surface becomes visible after startup. The local graph no longer rebuilds on every metadata event: it reacts to the active file, files already present in the current local graph, and direct reference neighbors of the active file, while the global graph remains vault-wide.
  • Auto-fit and auto-center run after the force simulation settles and when a deferred surface first receives non-zero dimensions; fit-then-center is used when focusing a node before the viewport has been fitted. Window or split-pane resize refits the graph when the user has not manually adjusted the view, otherwise it preserves the world point at the previous viewport center.
  • Uses d3-force for simulation, link distance, repel force, collision spacing, and centering.
  • Supports pan, wheel zoom, keyboard navigation, hover neighbor highlighting, click-to-open, and node context menus.
  • Node clicks resolve a navigation leaf before opening files: local graph clicks use the workspace default document leaf (getLeaf() / activeRootLeaf), while global graph clicks prefer an existing non-graph root leaf when one is available and only replace the graph tab when it is the sole root navigation target.
  • Records graph leaves in leaf history so view-header Back can restore a graph view after in-tab file navigation; restored graph surfaces rebuild when the leaf becomes active again.
  • Persists graph display settings through the plugin settings file.
  • Exports a read-only GraphEmbed surface plus GraphRenderer, graph settings helpers, and graph data types so external hosts can render precomputed graph data without a workspace leaf or plugin lifecycle.

CSS Ownership

The package owns graph-... namespaced overlay styling and follows the shared Plugin CSS Contract for first-party plugin chrome. The .graph-view surface owns plugin-local graph role variables for links, node fills, node strokes, and labels, and those variables default to the shared graph/theme tokens instead of hardcoded canvas colors.

Testing

Focused Vitest coverage lives with the package for graph assembly, orphan filtering, local-graph depth derivation from metadata fixtures, and navigation-leaf resolution. Workspace e2e coverage in packages/workspace/e2e/graph-navigation.spec.ts exercises demo-vault graph clicks, local-graph routing to the main leaf, and view-header history restore for global graph tabs.

History Plugin (@lapis-notes/history)

Source target: packages/plugins/plugin-history/src/

Manifest target: id plugin-history, version 0.0.1, min app version 1.7.7.

The History plugin is a first-party core plugin for persisting text-file revision history so users can inspect previous versions of a file and restore a selected revision.

Ownership

The plugin owns the user-facing history workflow:

  • Capture debounced text-file snapshots from vault create, modify, rename, delete, and restore flows.
  • Provide a workspace history sidebar for the active file plus a main-area compare tab for selected revisions.
  • Render a revision list, a syntax-highlighted unified comparison editor, and restore actions.
  • Expose settings for revision retention, debounce timing, maximum tracked file size, and tracked extensions.

Generated revision storage belongs to AppDatabase; the plugin must not write history snapshots into user note folders or .obsidian/. The shared persistence contract is documented in File System, Metadata, and Search.

Revision Model

The first rollout stores bounded full-text snapshots rather than patch deltas. Each stored revision records:

  • stable file identity and current path
  • path at the time the revision was captured
  • event type: baseline, create, modify, rename, delete, or restore
  • creation time, source file mtime, source file size, and content hash
  • UTF-8 text content

The storage layer deduplicates consecutive revisions with the same content hash and prunes old revisions according to the configured per-file retention limit. The default policy is 100 revisions per file, a 2 MiB maximum tracked file size, and a 2 s per-path debounce window.

File Scope

The planned v1 scope is text files only. Markdown, canvas JSON, bases, notebook documents, and other UTF-8 text formats may be tracked when they are below the configured size limit. Binary files and files under .obsidian/ are skipped.

The plugin should treat read failures, oversized files, and invalid text content as skipped captures rather than plugin failures.

View, Commands, And Settings

Workspace surface:

  • View type: HistoryViewType = "history".
  • Compare view type: HistoryCompareViewType = "history-compare".
  • Command: plugin-history:open-file-history, opening the history view for the active file, preferably in the right sidebar.
  • Ribbon action: icon-only history action using the plugin command.
  • Settings tab: retention count, debounce window, maximum tracked file size, and tracked extension list.

The history sidebar shows the active file path and revision count above a compact VS Code-style timeline. Timeline rows are newest-first, use rail markers, relative timestamps, event labels such as File Saved or Restored, and active-row highlighting. Clicking a timeline row opens or reuses a normal main-area history-compare workspace tab and compares that revision against the current file by default.

The compare surface uses CodeMirror’s unified merge extension with history-specific per-chunk Take revision and Keep current controls on changed regions when comparing against the live file. Changed current-file lines are highlighted in green, and revision-only text appears in red deleted-chunk blocks above the editor. Unchanged regions are not collapsed, so the full compare document stays visible. Select an older revision to see differences against the current file; the newest revision usually matches the live file and shows no diff. Take revision applies the selected revision into the editable compare document; Keep current dismisses the chunk while keeping the current side. Those chunk actions update the in-memory compare buffer only until Save current file writes back through the vault API. The diff editor resolves the tracked file through determineViewTypeForPath() and injects the same source-mode editor extensions and workspace editor configuration that a normal file editor receives, so syntax, line numbers, wrap, indent settings, and source-mode plugin extensions stay consistent. The current-file editor becomes editable when comparing a revision against the live file and can be saved back through the normal vault API. Compare with previous is read-only and hides chunk action controls.

The compare tab keeps the diff body simple: it does not render a revision aside or component-local toolbar. Compare current, compare previous, save current file, restore, and close are exposed as icon-only workspace view-header actions when the tab title bar is enabled.

Restoring a revision writes through app.vault.modify(), records a restore revision, and avoids creating a duplicate follow-up modify revision for the same content hash.

The bundled workspace and web hosts register the plugin as a core plugin and include its CSS through the workspace app bundle.

Rename, Delete, And Restore Behavior

History follows file renames by moving the stable file identity to the new path and recording a rename revision. Deleting a file records delete state for the history identity without deleting prior revisions, so a user can still inspect or copy the latest known content from the history view.

Restore is intentionally file-first: it writes selected text back to the current vault file through the normal vault API, so metadata, search, and file watchers observe the same modify flow as ordinary edits.

Validation Expectations

Current validation covers:

  • App-database storage contracts across memory, IndexedDB, SQLite worker, and coordinated proxy paths.
  • Direct history-plugin runtime coverage for command and view registration, baseline capture, rename or delete behavior, and restore duplicate suppression.
  • Workspace Playwright UI-smoke coverage for opening history, selecting a revision, launching the compare tab, verifying the syntax-highlighted unified diff surface, visible green/red diff decorations, Take revision / Keep current chunk controls, saving current-file edits, and restoring a selected revision through the compare view.

Notifications Plugin (@lapis-notes/notifications)

Source: packages/plugins/plugin-notifications/src/

Manifest: id plugin-notifications, version 0.0.1, min app version 1.7.7.

The notifications package is a required bundled plugin that owns app-level notification presentation for the shared renderer. It consumes the host-neutral app.notifications API from @lapis-notes/api and renders the visible in-app surfaces.

Responsibilities

  • Render transient information, warning, and error toasts for immediate feedback.
  • Register API-owned declarative status-bar items through app.statusBar for one stable low-priority background-progress item plus one bell or bell-count item for persisted notifications.
  • Open a scrollable notification center panel from a notifications-owned command instead of mounting popover UI into the compatibility status-bar host.
  • Persist durable notification history through AppDatabase as generated per-vault app state.
  • Mirror persisted app notifications through the neutral native desktop bridge when the active host advertises native notification support.

The plugin is intentionally UI-only. Background workers and first-party plugins report through app.notifications; they do not import this plugin or call Svelte components directly.

Progress Model

Progress follows VS Code’s split between status-bar progress and progress notifications:

  • Low-priority background work uses location: "status" and appears only in the status bar and notification center.
  • Work that was directly user-triggered, needs cancellation, or needs prominent details can use location: "notification" to also show a toast-style progress notification.
  • Work that should be observable by tests or diagnostics but not visible can use location: "silent".

Progress can be determinate (current / total), incremental (increment accumulated toward 100), or indeterminate. Cancellation is cooperative: the plugin exposes cancel actions, app.notifications aborts the task signal, and the task stops only when its implementation checks that signal.

Low-priority status progress is intentionally compact. The declarative status bar shows a single aggregated item with stable inline text, for example a task title plus percent or N more when multiple background tasks are running, and the shared status-bar renderer animates that item’s loader icon while work is active. File names, paths, and changing step descriptions stay out of the inline label and move into the status-item tooltip plus the notification center panel, which remains the click-open task list for active work. The plugin drives status-item visibility through scoped context keys under plugin.plugin-notifications.*, so progress, unread count, and fallback bell visibility all flow through the shared when evaluator instead of renderer-owned DOM toggles.

Persistence And Host Boundary

Persisted notifications are generated app state and must not be written into the user’s canonical vault files. Web and desktop hosts share the same in-app notification center and history. When the active desktop host advertises the notifications capability, the plugin mirrors persisted notification records through the neutral bridge; Electron main owns the OS notification API call. The plugin does not call Electron APIs directly, and transient toast-only notifications remain renderer-local.

First-Party Reporters

The first rollout wires these app-owned jobs into the shared progress API:

  • metadata cache load and rebuild
  • search index rebuild and semantic embedding refresh
  • Tasks plugin full task-cache rebuild
  • notebook command and render-service execution
  • official plugin registry install and update downloads
  • OPFS local-folder import

Notebook Plugin

Status: Implemented in the first rollout with some richer notebook chrome and explorer surfaces still pending. packages/notebook now owns notebook authoring commands, note-level and scoped notebook execution, notebook document parsing, per-note runtime sessions, notebook execution transport selection, DuckDB-backed query services, notebook-owned output renderers, markdown preview mounting, and notebook generated-state persistence.

The notebook feature is a first-party official plugin that adds reactive, executable notebook behavior to markdown notes while using a dedicated notebook-flavored markdown file identity, .notebook.md.

This page describes the planned component boundary. The current runtime and markdown stack are documented in Plugin Packages, Workspace App, and Editor Rendering and Views.

Purpose

The plugin’s job is to let a .notebook.md note behave like a notebook through a notebook-owned file view, parsing pipeline, runtime, and output surfaces.

The current first-rollout feature set is:

  • multiple executable cells mixed with ordinary markdown prose
  • deterministic DAG-based execution similar to marimo or Pluto-style dataflow
  • browser and PWA-compatible execution with no required server process
  • DuckDB-backed data work over vault files and app-derived tables
  • typed rich outputs such as tables, charts, markdown, HTML, structured input outputs, trusted ephemeral DOM, images, and errors
  • plain const|let|var name = view(expression) inputs powered by the notebook-input runtime, with supported controls rendered from @lapis-notes/ui primitives, notebook-input-owned input styles imported by the plugin, and htl html and svg helpers for trusted live DOM outputs
  • generated outputs and session state stored outside the canonical markdown file

Package and Identity

The plugin identity is:

  • package name: @lapis-notes/notebook
  • manifest id: lapis-notebook
  • command prefix: notebook:

Notebook installs from the official registry under lapis-notebook. The workspace no longer bundles it by default; when no installed handler is present, opening a .notebook.md file uses the on-demand official install prompt before retrying the file open.

Official notebook releases package DuckDB wasm/workers and other heavy runtime chunks inside the signed .lapis-plugin bundle. First DuckDB-backed SQL execution resolves those already-installed plugin asset URLs through the shared plugin asset server. The main Vite build uses emptyOutDir: true so stale hashed chunks from prior builds are not copied into release inputs; wasm and workers land under dist/duckdb/ instead of inlined JS payloads.

The package root is also expected to stay startup-thin. Boot-critical imports should stop at plugin registration surfaces, while worker-backed execution code and notebook command helpers load lazily on first render or command use.

The plugin should follow the same reversible lifecycle rules described in Plugin Runtime.

Planned Package Refactor

The current rollout keeps notebook behavior in one first-party plugin package, but the intended direction is to split runtime-heavy and reusable notebook pieces into smaller packages while preserving @lapis-notes/notebook as the product plugin identity.

The target package boundaries are:

PackagePlanned responsibility
@lapis-notes/notebookProduct plugin: manifest, commands, views, settings, renderers, persistence integration, and lazy runtime wiring.
@lapis-notes/notebook-coreNotebook model, parser-facing types, output protocol, graph/session state contracts, execution contracts, and runtime feature flags.
@lapis-notes/notebook-stdlibLapis builtin registry, host-neutral lapis.* helper implementations, raw DOM helper loading, bundled dependency descriptors, and standard-library-style helper contracts.
@lapis-notes/notebook-inputPlain view(...) transform/runtime, the tracked wrapper over the DOM helper surface, UI-backed input factories for supported controls, and input value lifecycle hooks.
@lapis-notes/notebook-duckdbDuckDB runtime contracts, query normalization, catalog/preview helpers, vault file registration helpers, and app-table snapshot registration helpers.
@lapis-notes/notebook-duckdb-assetsDuckDB-Wasm and DuckDB worker asset ownership plus bundle URL resolution.
@lapis-notes/notebook-workerWorker protocol, worker shell transport helpers, command/host request brokers, host response helpers, and worker eligibility policy.

The split should happen incrementally. The first extraction should introduce contracts and package seams without changing notebook behavior. Each package must preserve the current startup-thin rule: opening the workspace or activating the product plugin must not eagerly load DuckDB-Wasm, TypeScript, Observable inputs, htl, d3, Plot, or dynamic dependency resolvers.

The first implementation slice created @lapis-notes/notebook-core for the output protocol. Later slices expanded notebook core with model contracts, session state/progress contracts, runtime feature flags, generic graph helpers, and execution contracts, and created @lapis-notes/notebook-stdlib for builtin registry contracts, the pure user-executed lapis helper implementations, documented bare global names, dependency-global validation helpers, source-level runtime feature detection, host-neutral vault/file helper implementations, the raw html/svg DOM helper loader, and shared Promises/Generators stdlib helpers, @lapis-notes/notebook-duckdb-assets for DuckDB-Wasm and worker asset URL resolution, @lapis-notes/notebook-input for the host-neutral plain view(...) source transform, tracked input metadata, DOM input binding, callback-driven view runtime helper, and the local UI-backed control factories, @lapis-notes/notebook-duckdb for DuckDB runtime contracts and pure query normalization helpers, and @lapis-notes/notebook-worker for generic worker transport contracts. @lapis-notes/notebook consumes those packages while the rest of the runtime remains in the product plugin.

The package split is implemented for the current official plugin boundary. Remaining notebook tasks track product features and runtime language-compatibility decisions rather than package extraction work.

The current execution boundary exposes host-neutral DuckDB, notebook file-read, and JavaScript-like compile provider seams. Browser and PWA sessions continue to use DuckDB-Wasm, vault adapter reads, and the lazy TypeScript compiler by default, while Electron sessions inject the native DuckDB sidecar provider when the desktop capability registry marks notebook support as available. Electron DuckDB vault-file registration sends the resolved notebook-relative path plus the native vault root through validated main-process IPC so CSV, Parquet, and JSON aliases can be linked or copied by the sidecar without renderer byte-buffer transfer. Native TypeScript compilation was evaluated and is not part of the current architecture because it would not move JavaScript-like cell evaluation or make in-process synchronous code preemptively cancellable. Host-only function providers stay on the in-process execution path rather than being serialized to the notebook worker.

Styling Ownership

Notebook-specific chrome and selectors are owned by the notebook package under the first-party Plugin CSS Contract.

  • notebook-owned wrapper classes use the notebook- prefix, for example notebook-cell, notebook-output, and notebook-dataframe
  • notebook BEM hooks cover the view, cells, outputs, dataframe viewer, inspector panel, and dependencies panel
  • notebook presentation may continue to mix Tailwind utility classes with those stable wrapper classes, but the stable selectors are the supported override surface
  • notebook-specific CSS rules should live in the notebook package stylesheet instead of workspace-level overrides
  • notebook-specific variables use the --notebook-... prefix and default to core theme variables such as --background-primary, --background-modifier-border, --text-normal, --text-muted, --interactive-accent, and --text-on-accent
  • the markdown plugin may still apply a notebook layout marker class when it owns the editor DOM for .notebook.md leaves, but the notebook-specific visual rules for that marker belong with the notebook plugin
  • the workspace imports notebook dist/app.css, which contains notebook BEM/custom rules only
  • standalone/native notebook installs and plugin-local testing use notebook dist/styles.css, which includes UI theme/full Tailwind output plus notebook addon rules
  • the current rollout loads notebook workspace CSS through an explicit workspace import of the built plugin CSS asset rather than through plugin-manager lifecycle CSS loading

File Ownership

Notebook documents remain ordinary markdown notes, but they are owned as a distinct file flavor.

  • canonical file extension: .notebook.md
  • canonical authoring surface: notebook-owned markdown source and preview modes
  • notebook identity: inferred from the file path suffix
  • optional notebook config: note frontmatter under a top-level notebook: key for runtime settings such as mode and mounts
  • plugin settings: app-level notebook preferences persisted by notebook, including dependency-view defaults, panel auto-open behavior, the validated runtime dependency registry, and a settings handoff into the dedicated Packages side view

The markdown plugin continues to own base .md parsing, file loading, and non-notebook rendering. The notebook plugin owns .notebook.md files through its own view registration and runtime lifecycle, instead of patching markdown leaves after they load.

Responsibilities

The plugin owns the following runtime surfaces.

Notebook document parsing

  • detect notebook files from the .notebook.md path suffix
  • parse optional notebook frontmatter config when present
  • parse notebook cell directives and their attributes
  • maintain stable cell ids and notebook document metadata

Cell UI and editing

  • render cell chrome and outputs, including a per-cell inline run control on the code action rail that flips to an inline stop control only for the currently running cell while queued cells stay visibly scheduled, plus subtle stale-state highlighting for cells whose inputs changed
  • split notebook cell presentation into a code card and a separate output surface, each with its own single-column left action rail that uses the normal app border color by default and promotes to the interactive accent for the currently selected cell so the source and output surfaces stay visually linked, a border-style collapse toggle, a clickable rail spacer below the top action icon that shares the same collapse behavior, independent collapse state, selection highlight, and collapsed summary row when the section body is hidden, with collapsed source previews truncated to a single-line ellipsis and collapsed code or output bodies reopening when clicked
  • persist per-cell code and output collapse state as notebook cell directive attributes so collapsed sections survive note reloads
  • show per-cell execution status and duration in the code surface footer, plus per-output actions such as clearing cell outputs from the output rail menu
  • expose a notebook-owned session inspector in the right side split with variables and DuckDB data sources for the active notebook, including clickable cell-id navigation from variable declarations and usages plus expandable DuckDB table previews with live row counts, inline row and column counts in each data-source row, and source and column type icons that match the shared property icon language
  • expose a separate notebook-owned dependencies side view in the left split for the active notebook, with a Marimo-inspired minimap tab for notebook-order dataflow tracing plus a graph tab that renders a fit-to-viewport notebook DAG with vertical or horizontal tree layouts, syntax-highlighted code nodes, and popup cell or edge details
  • persist notebook plugin preferences such as dependency-view default tab, dependency-graph layout, dependency-graph wheel zoom sensitivity, whether notebook sources or dependencies panels auto-open when a notebook becomes active, validated runtime dependency descriptors that the Packages side view manages for later lazy loading, and per-note notebook.autorun frontmatter overrides for stale-cell reruns that fall back to the global auto-run setting when omitted
  • provide insertion commands for code, SQL, and dynamic markdown cells
  • support notebook-aware reading-mode presentation and markdown-source authoring commands

Output rendering

  • define a typed notebook output protocol for cell results
  • map output kinds onto Svelte renderers through a renderer registry
  • render first-class table and chart outputs instead of treating them as opaque HTML blobs
  • render structured input outputs through notebook-owned input wrappers that can mount live input DOM nodes by notebook cell and variable identity, and render trusted live DOM outputs produced by htl or explicit DOM helpers through notebook-owned DOM mount points rather than persisting the DOM itself
  • preserve explicit error, loading, and stale-state renderers as notebook-owned UI, with loading and stale feedback kept in the per-cell footer while output is pending

Notebook session runtime

  • create and dispose per-notebook execution sessions and notebook-scoped execution runtimes so different notebook notes can run independently and stopping one note does not cancel another
  • build and update the dependency DAG
  • track stale, running, errored, and disabled cells
  • run notebook execution only from explicit user actions such as per-cell run and notebook-level run commands, while still reusing persisted outputs for unchanged notes when they are already available

Data access and query services

  • host a DuckDB-backed data engine for notebook sessions
  • register vault files and app-derived tables with notebook sessions
  • expose browser-safe helpers for loading CSV, Parquet, JSON, and SQLite-derived app state
  • expose a standard-library-style builtin surface where Lapis-provided helpers live canonically under lapis.*, including output helpers, DOM helpers, inputs, DuckDB, vault access, and bundled visualization libraries
  • support user-installed notebook dependencies as named globals that are not mirrored under lapis.*

Persistence

  • persist outputs, run state, and cached notebook session artifacts as generated state
  • keep canonical note bytes free of rendered outputs and transient session state

User-Facing Surfaces

The first rollout exposes notebook behavior through notebook-owned markdown files instead of through a separate JSON notebook format.

Current user-facing surfaces include:

  • notebook view for .notebook.md leaves, including a grouped notebook pane menu for auto-run on cell changes plus notebook packages, dependencies, and sources, with auto-run stored in notebook.autorun frontmatter for the current note and falling back to the global plugin setting when the field is omitted
  • a notebook session side view on the right split that lists active notebook variables and DuckDB-backed data sources for the active note, lets declared-by and used-by cell ids jump back to the corresponding notebook cell, and can expand live DuckDB table previews in place
  • a notebook dependencies side view on the left split that follows the active notebook, provides a minimap tab for direct and transitive dependency tracing in notebook order, and renders a fit-to-viewport dependency graph with selectable syntax-highlighted code nodes, edge overlays, duplicate-definition and cycle summaries, and vertical or horizontal tree modes
  • a notebook packages side view on the right split that follows the active notebook, groups configured runtime package descriptors into direct imports, indirect imports, and available imports, resolves transitive package metadata from pinned direct imports when registry metadata is available, offers npm-registry-backed package-name autocomplete plus inline install progress, keeps the search, custom-install, and available-import sections available even when no notebook is active while hiding notebook-specific direct and indirect package usage sections in that state, keeps the package-detail pane hidden until the user explicitly selects a package row, starts the count-based direct, indirect, and available package accordions collapsed when their counts are zero while reopening them when packages first appear, lets direct, indirect, and available package rows toggle selection off again to hide the detail pane, highlights the selected available import with the theme secondary color scheme, keeps selected search results visible so their descriptions remain readable, renders search results as a flat white divider list with a separate selected-row highlight, shows inline install and customize actions below the selected uninstalled search result description, expands and scrolls to the manual custom-install accordion when Customize is chosen, marks already-installed search results with uninstall and in-place update actions, lets the user add, update, or remove validated remote-ESM package descriptors, recognizes direct notebook package usage from configured globals or explicit package imports, and shows per-package usage and validation details without replacing the existing dependency graph panel
  • notebook leaves use a full-width reading and editing layout instead of the centered readable-line-width markdown layout
  • convert active markdown note to notebook file, insert notebook cell, and demo notebook scaffolding commands
  • slash-menu notebook authoring suggestions in .notebook.md source editors for inserting canonical code, SQL, input, and markdown cell templates via /code, /sql, /input, and /markdown
  • run notebook now from commands, the notebook view header action, and a notebook-owned status-bar action for the active notebook leaf
  • notebook execution commands and runtime entrypoints now short-circuit when the app is in Safe Mode with notebook execution disabled, surfacing a user-visible notice instead of starting execution against a recovery session
  • active notebook leaves register notebook-owned status-bar items that surface Safe Mode execution disablement, whether the current notebook is using the worker runtime or an in-process fallback plus the concrete fallback reason when applicable, whether visible outputs were restored from generated state or recomputed in the current session, and compact notebook-owned run, rerun-stale, and clear-output actions independent of workspace header chrome
  • run or rerun a single cell from the inline code-rail action without requiring the cell output surface to be empty first, while showing an inline stop control only on the actively running cell during execution and using both the rail border control and the empty rail spacer beneath the top icon to collapse the code or output section
  • live-preview notebook cell chrome with a status footer for scheduled, running, stale, errored, completed, and idle cells, including setup-phase detail text, elapsed runtime display, stale styling, and an output options menu for clearing cell outputs
  • run current notebook cell from notebook source or live preview based on the active cursor
  • run stale notebook cells from commands, the notebook view header action, and a notebook-owned status-bar action for the active notebook leaf
  • insert notebook input cell creates a TypeScript cell with plain const|let|var name = view(Inputs...) syntax
  • clear all notebook outputs from the notebook view header action, the notebook-owned status-bar action for the active notebook leaf, and a command-palette command while keeping .notebook.md source cells untouched
  • notebook-aware cell outputs in reading mode
  • notebook-aware cell outputs beneath cell blocks in live preview
  • interactive table outputs for SQL and data-oriented code cells, including a notebook-owned dataframe viewer with marimo-inspired stacked column headers, compact per-column summary previews, client-side search and filtering, column visibility toggles, a right-side column profile sheet, row expansion for smaller result sets, and virtualized scrolling when rendered pages grow beyond a couple hundred rows
  • plain view(...) inputs such as const gain = view(Inputs.range(...)); input changes update the session value and automatically rerun downstream notebook cells while avoiding reruns of the input-producing cell itself, waking only affected cell renderers and preserving existing outputs while those cells are queued or running
  • htl-backed trusted DOM outputs using the injected html and svg tagged template helpers; these DOM nodes are live only for the current runtime session and are recreated by rerunning the cell
  • spec-driven chart outputs for notebook visualizations
  • a demo-notebook/ vault-root sample suite with representative notes and assets for notebook command, DuckDB, renderer, plain view(...) input, and htl testing

Runtime Services

The plugin uses dedicated notebook services layered beside the current markdown renderer.

Current internal service boundaries:

  • NotebookDocument - parsed notebook note structure
  • NotebookRuntimeController - product-local facade over per-note runtime sessions that owns session lookup, graph-backed snapshots and state queries for notebook chrome, commands, and render services, render-layer cell output clearing, input value updates plus downstream dependent resolution, execution preparation, checkpoint rollback, execution-state snapshots, filtered runtime subscriptions, and the shared boundary used by notebook commands, render services, and plugin chrome
  • NotebookSessionManager - lower-level lifecycle owner for per-note runtime sessions used underneath the controller facade
  • NotebookGraph - refs, defs, topological sort, stale propagation
  • NotebookWorkerClient - notebook execution boundary used by the plugin today, loaded lazily by the plugin on first notebook execution or render that needs execution, backed by a dedicated worker transport for notebooks that do not require DuckDB, live browser DOM features, or host-only execution providers plus an in-process path for DuckDB-backed, live-DOM-backed, or provider-injected notebooks, with helper-only DOM usages such as DOM.uid remaining worker-eligible; the client, worker shell, DuckDB service, and TypeScript compiler are split so simple notebook runs do not load DuckDB-Wasm and JavaScript cells do not load the TypeScript compiler
  • Notebook execution providers - host-neutral DuckDB, file reader, and JavaScript-like compile contracts keep cell syntax stable while allowing the product plugin or a desktop host to replace DuckDB execution, notebook-relative file reads, or TypeScript/static-import compilation. Electron currently supplies the DuckDB provider through a native sidecar and registers vault-backed CSV, Parquet, and JSON aliases from validated native vault paths when the active vault is an Electron desktop folder; native TypeScript compilation is outside the current architecture because it needs separate telemetry, compiler timeout, and JavaScript-like evaluation cancellation designs; function-backed host-only providers stay on the in-process path instead of being serialized to the worker, while default browser execution continues to use worker host requests, the app vault adapter, DuckDB-Wasm, byte-buffer file registration, and the lazy TypeScript compiler.
  • notebook input runtime - a notebook-local in-process runtime feature that exposes Lapis-provided input and DOM helpers canonically under lapis.*, may expose documented bare convenience globals such as Inputs, html, and svg, tracks scalar input values in the session, emits structured input outputs when tracked metadata is available, mounts supported live input DOM nodes through @lapis-notes/ui primitives, emits downstream rerun requests when inputs change, and clears same-cell runtime definitions before reruns so input cells can be executed repeatedly
  • @lapis-notes/notebook-input - reusable host-neutral input source helpers, lazy DOM helper loading, UI-backed Inputs factories for supported controls, explicit unsupported errors for the remaining unimplemented factories, DOM input node binding, and callback-driven view runtime helpers consumed by notebook execution
  • @lapis-notes/notebook-duckdb - reusable DuckDB runtime contracts, app-table and vault-file registration contracts, query-result types, catalog/preview types, DuckDB type mapping, pure query normalization helpers, and pure catalog/identifier helpers consumed by the concrete product-plugin DuckDB service and inspector surfaces
  • NotebookDuckDbService - DuckDB-Wasm lifecycle and table registration
  • @lapis-notes/notebook-core - reusable notebook model contracts, output protocol, generic execution result/options contracts, pure lapis output helper functions, and generic notebook graph helpers consumed by the product plugin
  • notebook language-service projection - pure helpers in notebook core concatenate TypeScript cells into a serializable virtual TypeScript document, track per-cell segment offsets/lines, and map provider positions/ranges back to cell-local positions
  • @lapis-notes/notebook-duckdb-assets - reusable DuckDB-Wasm and DuckDB worker asset URL resolver consumed by the product plugin’s DuckDB service
  • @lapis-notes/notebook-stdlib - reusable notebook builtin registry contracts, documented bare global names, dependency-global validation helpers, source-level runtime feature detection, host-neutral vault/file helper implementations, and the bundled stdlib bootstrap consumed by the product plugin
  • @lapis-notes/notebook-worker - reusable generic worker transport message contracts consumed through a product-plugin alias layer, main-thread command promise plumbing and host request response wrapping used by the product-plugin worker client, worker-side shell dispatch and host request promise plumbing used by the concrete product-plugin worker entrypoint, and pure worker eligibility policy consumed by the product-plugin worker client
  • NotebookOutputRenderer - notebook output protocol and renderer registry
  • NotebookOutputStore - generated-state persistence for outputs and session snapshots

Notebook execution reports per-note progress through app.notifications. Command-triggered runs can surface as progress notifications with cancel actions, while automatic or input-triggered reruns use lower-priority status progress. Cancellation delegates to the existing per-notebook runtime termination path.

Notebook also registers the browser TypeScript provider from @lapis-notes/language-service and contributes minimal global declaration text for documented notebook globals such as lapis, Inputs, html, svg, Generators, and Promises. Direct cell editors pass a languageServiceDocumentContext Facet into their CodeMirror instance so diagnostics, completions, and hover operate against the full virtual notebook TypeScript document while displaying ranges in the focused cell.

Builtins And Runtime Dependencies

The intended standard-library direction is inspired by Observable’s standard library but adapted to Lapis’ markdown-first, vault-backed, generated-state model.

Lapis-provided builtins are canonically exposed under the lapis namespace. Planned examples include:

  • lapis.table, lapis.chart, lapis.text, lapis.markdown, lapis.html, lapis.svg, lapis.image, and lapis.error for typed notebook outputs and trusted DOM helpers
  • lapis.Inputs for Observable-style input controls, with supported controls rendered from shared @lapis-notes/ui primitives during the current migration
  • lapis.duckdb for query execution and vault file registration
  • lapis.vault for browser-safe note and asset reads through readText and readBinary
  • lapis.FileAttachment for Observable-style file attachment access backed by vault files, currently including arrayBuffer, text, json, and blob
  • lapis.d3 and lapis.Plot for bundled visualization helpers, also exposed as documented bare d3 and Plot globals
  • lapis.DOM for Observable-style DOM helpers such as canvas, context2d, download, and uid, also exposed as the documented bare DOM global
  • lapis.Generators and lapis.Promises for Observable-style generator and promise helpers, also exposed as documented bare Generators and Promises globals

Documented bare convenience globals may also exist for notebook-native helpers, for example Inputs, html, svg, d3, Plot, DOM, Generators, and Promises, but lapis.* remains the canonical Lapis-owned namespace. User-installed dependencies are different: they are available only through the selected global name, or a validated package-name-derived identifier when the user does not choose a name. They must not be attached to lapis, including under a nested namespace such as lapis.user.

The dependency-management UI should validate global names as JavaScript identifiers, pin package versions, block collisions with lapis, documented bare builtins, and other dependency globals, and surface load errors before a dependency is used by notebook execution. Shared descriptor, validation, and referenced-runtime-loader helpers now live in @lapis-notes/notebook-stdlib, and the product plugin now exposes a dedicated Packages side view from settings and notebook chrome to manage validated runtime dependency descriptors. That Packages surface probes pinned package metadata before saving, shows inline progress while installing, and uses npm-registry search suggestions to accelerate package-name entry. JavaScript-like cells lazily resolve referenced remote ESM dependencies during execution, including worker-backed execution when descriptors are serializable, and expose them only as their selected or derived bare globals. Future desktop hosts may add cached or offline resolvers behind the same registry contract.

The runtime should keep builtins and user dependencies startup-thin so unused builtins and dependency resolvers do not affect workspace boot or simple notebook execution. Host-neutral helpers such as vault-backed file access and bundled visualization bootstrap may still live in the shared stdlib package even when concrete host wiring remains lazy in the product runtime.

Data Model Direction

The notebook plugin should treat the existing app database as a source of read-only generated state rather than as the notebook runtime itself.

The current AppDatabase contract is optimized for metadata and search persistence, not arbitrary notebook execution. The proposed notebook runtime therefore uses its own execution model and exposes app-derived data to cells through notebook tables or notebook helpers.

Examples of proposed built-in notebook data sources:

  • notes metadata
  • resolved and unresolved links
  • tags
  • frontmatter properties
  • search documents and snippets

Current rollout boundaries

The first notebook rollout is intentionally narrower than the longer-term design:

  • notebook reading mode and live preview beneath rendered cell blocks are the notebook-owned presentation surfaces today
  • notebook authoring happens in notebook-owned markdown source with notebook commands, not in a separate non-markdown editor
  • execution is explicit-only: notebooks do not auto-run on load, and fresh execution happens only through note-level, current-cell, stale-cell, or inline per-cell run actions
  • notebook execution uses a dedicated worker transport for non-DuckDB/non-DOM notebooks, while DuckDB-backed and live DOM notebooks currently fall back to the in-process runtime path so browser and PWA data access stays reliable
  • notebooks that use DuckDB or live DOM features such as Observable inputs and htl execute in process; non-DuckDB, non-DOM notebooks keep the worker path
  • notebook-owned status-bar chrome makes that transport choice visible to users and distinguishes restored generated outputs from outputs recomputed after an explicit run so reload behavior is understandable without inspecting generated state
  • notebook outputs persist through typed app-database notebook state keyed by note path with file-level mtime and per-cell source fingerprints, with live DOM outputs omitted and serializable input values stored separately as generated state; tracked view(...) inputs can now persist structured input outputs alongside scalar values, reconstructable controls can rebuild directly from that stored metadata when no live input node is available, and only non-reconstructable inputs still fall back to rerunning the owning cell

First-Rollout Non-Goals

The initial notebook plugin should not try to solve every notebook problem at once.

Planned non-goals for the first rollout:

  • full Jupyter kernel compatibility
  • a primary .ipynb file format
  • unrestricted community-plugin notebook kernels
  • hidden mutable kernel state that bypasses the notebook graph
  • server-required execution for the default browser and PWA path
  • full Observable JavaScript compatibility beyond constrained top-level const|let|var name = view(expression)
  • persistence of live DOM nodes or event handlers

The first rollout should still include first-class rich outputs, but with an intentionally narrow scope:

  • table outputs are in scope
  • chart outputs are in scope when they use approved chart-spec renderers
  • trusted DOM outputs from notebook-authored htl and Observable inputs are in scope, while untrusted arbitrary community DOM capability gating remains a later host/runtime concern

Notebook Documents and Runtime

Status: Implemented in the first rollout. .notebook.md notes are parsed into notebook documents, notebook cells execute through a per-note session runtime, non-DuckDB notebooks use a dedicated worker transport, DuckDB-backed notebooks currently fall back to the in-process runtime path in the browser, scoped notebook execution is available through notebook commands, and notebook results persist as generated state in the app database.

This page describes the markdown-native notebook document format and the runtime model used to execute notebook cells in the browser and PWA host.

The implementation follows the component boundary described in Notebook Plugin and the architectural decision in ADR-002: Notebook Plugin and Markdown Cell Model.

Implementation snapshot

The current rollout includes the following runtime pieces:

  • notebook file identity from the .notebook.md path suffix plus optional notebook frontmatter normalization and :::cell{...} parsing inside plugin-notebook
  • explicit note-level, current-cell, stale-cell, and inline per-cell notebook execution entry points for notebook-owned markdown notes
  • notebook document loading keyed off file identity, with optional frontmatter parsing for runtime config when metadata cache entries are not warm yet
  • static dependency analysis for ts/js, sql, dynamic md, and html cells
  • per-note session graphs with stale propagation and duplicate/cycle detection
  • a notebook execution bridge that runs JavaScript, TypeScript, SQL, and dynamic markdown cells behind notebook-scoped runtime instances, using a dedicated worker transport when a notebook does not depend on DuckDB or live DOM APIs and otherwise falling back to a notebook-local in-process runtime path; the bridge keeps DuckDB-Wasm and the TypeScript compiler behind secondary lazy imports so cold worker/client startup stays smaller for simple notebooks
  • static ESM import support in JavaScript and TypeScript notebook cells for configured notebook runtime packages, for bare package imports whose versions are known from the host workspace package manifest, and for vault-scoped relative JavaScript modules stored next to the notebook note; absolute filesystem-like specifiers remain rejected
  • DuckDB-Wasm app tables exposed as lapis.notes, lapis.links, and lapis.search_documents
  • notebook-owned renderers for table, chart, markdown, HTML, image, and error outputs
  • host-neutral execution providers for DuckDB runtimes, notebook vault file reads, and JavaScript-like cell compilation; Electron injects a native DuckDB sidecar provider when its preload capability registry advertises notebook support and registers DuckDB vault-file aliases from validated native vault paths, while browser/PWA execution keeps the existing DuckDB-Wasm service, vault adapter reads, byte-buffer file registration, and lazy TypeScript compiler by default
  • plain top-level const|let|var name = view(expression) input cells powered by the notebook-input runtime, with supported controls rendered through @lapis-notes/ui primitives and live controls mounted through notebook-owned structured input outputs and DOM mounts while the remaining upstream compatibility surface is still being retired
  • plain top-level const|let|var name = view(expression) input cells powered by the notebook-input runtime, with supported controls rendered through shared notebook-owned input surfaces, including direct Inputs.table(...) support for attachment-backed row selection with sortable headers, and live controls mounted through notebook-owned structured input outputs and DOM mounts while the remaining upstream compatibility surface is still being retired
  • htl html and svg helpers for trusted live DOM outputs from TypeScript and JavaScript cells
  • markdown directive rendering that mounts notebook cells through the shared markdown component tree in both reading mode and live preview
  • live-preview notebook cell widgets suppress direct raw-source activation on in-widget clicks and keep source selection behind the explicit code/edit affordance used by shared markdown block widgets
  • inline notebook cell controls allow rerunning cells even when outputs are already present, switch the per-cell play control to an inline stop control only on the actively running cell while queued cells remain visibly scheduled, and restore the prior checkpoint when an inline cancellation interrupts a running cell execution
  • the right-side notebook session view derives declared and used variables from the notebook graph, lets those declared-by and used-by cell ids reveal the corresponding notebook cell in the owning note, queries the in-process DuckDB runtime for live schemas and row counts when the active notebook uses DuckDB-backed execution, renders each DuckDB data source as a row-style expandable entry with inline source and column type icons plus row and column counts, and lazily requests small in-panel table previews when the user expands a DuckDB table
  • the left-side notebook dependencies view reuses the same notebook graph plus per-cell runtime state to build a view-specific dependency snapshot, renders a Marimo-inspired minimap in notebook order with direct and transitive dependency glyphs, and renders a fit-to-viewport graph tab with vertical or horizontal tree layouts, syntax-highlighted code cards, selectable dependency edges, and popup cell or edge details
  • a separate right-side notebook packages view follows the active notebook, groups runtime package descriptors into direct imports, indirect imports, and available imports, resolves transitive package metadata from pinned direct imports when the npm registry provides manifest metadata, lets settings and notebook pane chrome open the package manager directly, and supports adding or removing validated package descriptors without altering the existing dependency graph panel
  • plugin settings persisted through the plugin data store now control notebook-level preferences such as default dependency tab, dependency-graph layout, dependency-graph wheel zoom sensitivity, whether notebook sources or dependencies panels auto-open on notebook file open, whether notebook document edits should automatically rerun the current stale-cell set after a short debounce, and the validated runtime dependency registry that the Packages view manages for lazy execution-time loading
  • app-database persistence for notebook outputs keyed by note path with file-level mtime and per-cell source fingerprints
  • app-database persistence for serializable input values and structured input outputs, while live DOM outputs are treated as ephemeral runtime state and are recreated by rerunning the owning cell
  • per-cell runtime metadata for explicit execution state, success or error status, and last-run duration so the notebook UI can show timing and status chrome without storing rendered output in note bytes
  • notebook-owned status-bar chrome for the active notebook leaf that reports Safe Mode execution disablement, current runtime transport selection, in-process fallback reasons when worker execution is unavailable, and whether the visible outputs were restored from generated state or recomputed during the current session, with notebook-owned run, rerun-stale, and clear-output actions available from the same surface
  • note-level notebook runs publish queued and running session state immediately from the command path, before lazy runtime boot or DuckDB setup completes, so per-cell status bars and inline stop controls can reflect scheduled and running work during cold starts, while notebook stop actions only tear down the runtime that belongs to the targeted note
  • workspace Playwright coverage for cursor-scoped execution and notebook-relative CSV loading in the legacy browser vault

The main remaining follow-on work is language and syntax scope rather than basic runtime correctness. NotebookWorkerClient still uses a dedicated worker for non-DuckDB/non-DOM notebooks and host callbacks to fetch app-table snapshots and vault file bytes from the main thread, but DuckDB-backed, live DOM, and host-only provider notebooks execute in process because that path is more reliable in the browser/PWA host today and carries Electron’s nonserializable native DuckDB provider. The shipped session inspector depends on that in-process DuckDB runtime for live schema discovery. The notebook view now exposes that runtime choice directly so users can tell whether a note is on the worker path or on an intentional in-process fallback.

Notebook cell presentation now splits the source preview and the rendered output into separate surfaces. The code surface keeps the rounded notebook card treatment and hosts execution status, duration, and run actions. The output surface renders directly into the document flow without the code-card background, but keeps its own left action rail for collapse, selection highlight, and output-specific actions such as clearing cell outputs. Selection is tracked at the notebook-cell level so only one cell is selected at a time within a note, and the source and output rails for that selected cell switch together from the normal app border color to the interactive accent.

At the notebook level, the active notebook leaf contributes notebook-owned status-bar items so note-wide execution state remains visible without adding sticky notebook chrome to the document body. Those items report when Safe Mode has disabled notebook execution, label worker-backed runs explicitly, spell out the current in-process fallback reason for notebooks that require DuckDB, live DOM helpers, or host-only providers, and distinguish outputs restored from generated state after reload from outputs recomputed in the current session after an explicit run.

Goals

The document model is designed to satisfy the following constraints:

  • notebook notes remain markdown files with a dedicated .notebook.md suffix
  • multiple executable cells can be mixed with ordinary prose
  • the runtime computes a DAG from cell dependencies, marks changed cells plus their rerun closure stale in lazy mode, and reruns the required dependency closure for scoped or stale executions while reusing already-satisfied upstream dependency values so targeted reruns do not rebuild unchanged input cells
  • vault data can be loaded in the browser without a server
  • app-derived data can be queried from cells
  • rich outputs such as tables and charts can be rendered in notebook-aware presentations
  • generated outputs stay out of canonical note bytes

Notebook Identity

Notebook behavior is enabled by file identity.

Notebook notes use the .notebook.md suffix. That path-based identity decides which view owns the file and whether notebook parsing/runtime behavior applies.

Notebook notes may also carry optional frontmatter for runtime settings.

---
notebook:
  version: 1
  autorun: false
  mode: lazy
  mounts:
    notes: app://notes
    tags: app://tags
    links: app://links
---

Frontmatter keys:

  • version - notebook document model version
  • autorun - optional boolean override for stale-cell auto-runs on note edits; when omitted the notebook falls back to the global notebook plugin setting
  • mode - reserved runtime mode metadata; the current notebook UI does not auto-run on load and only executes cells from explicit user actions
  • mounts - optional aliases for app-backed tables or vault-backed data sources

Notes without a notebook: key still remain notebooks when their file path ends with .notebook.md; they simply fall back to default notebook settings.

Canonical Cell Syntax

The canonical cell unit is a markdown container directive named cell.

This fits the current parser direction because the markdown stack already supports container directives in both CodeMirror parsing and preview rendering.

When the directive omits lang, notebook parsing infers the cell language from a fenced code body’s info string when present. If there is no fenced info string, or it does not map to one of the supported notebook cell languages, the cell still falls back to ts.

Code Cell Example

:::cell{#load-data lang="ts"}

```ts
const activeTag = "project/alpha";
await lapis.duckdb.registerVaultFile("todos_csv", "data/todos.csv");
```

:::

SQL Cell Example

:::cell{#filter-todos lang="sql"}

```sql
SELECT *
FROM read_csv_auto('todos_csv')
WHERE tag = {{activeTag}}
```

:::

Dynamic Markdown Cell Example

:::cell{#summary lang="md"}

## Summary

Current tag: `${activeTag}`
:::

Markdown cells support ${...} expressions evaluated against resolved notebook state. The legacy {{name}} placeholder remains supported as a markdown-native convenience and for SQL scalar interpolation.

HTML Source Cell Example

:::cell{#summary-card lang="html"}

<article><strong>${region}</strong> uses gain <code>${gain}</code>.</article>

:::

HTML source cells render through the notebook HTML output renderer. Static HTML in the cell source is trusted notebook source, while values interpolated through ${...} are escaped before rendering so strings are treated as text by default in both element text and attribute contexts. Use JavaScript-like cells with htl-backed html/svg helpers when a notebook intentionally needs live DOM nodes or richer trusted DOM composition.

Notebook Input Example

Notebook TypeScript and JavaScript cells support a constrained top-level view(...) form inspired by Observable notebooks.

:::cell{#controls lang="ts"}

```ts
const gain = view(Inputs.range([0, 10], { value: 5, step: 1, label: "Gain" }));
const region = view(Inputs.text({ value: "North", label: "Region" }));
```

:::

:::cell{#summary lang="md"}
Selected gain: **{{gain}}**

Selected region: **{{region}}**
:::

The current implementation supports only the explicit top-level shape const|let|var name = view(expression). The expression must evaluate to a DOM-backed input node whose .value property represents the current value and that emits input or change events when it changes. Supported control rendering now lives under @lapis-notes/notebook-input and uses shared @lapis-notes/ui primitives. Full Observable JavaScript syntax remains a follow-on task.

When a user changes an input, the notebook session updates the exported scalar value, finds downstream dependents in the notebook graph, and automatically reruns those dependent cells without rerunning the input-producing cell. Input-triggered execution wakes only affected cell renderers, preserving existing outputs and their live DOM registrations while queued or running states are shown, so unrelated cells do not reload or remount their DOM. Rerunning an input-producing cell clears its previous runtime definitions before evaluation so transformed view(...) declarations can be declared again in the same session.

htl DOM Output Example

TypeScript and JavaScript cells also receive htl-backed html and svg tagged template helpers.

:::cell{#status-card lang="ts"}

```ts
return lapis.html`<div><strong>${region}</strong> uses gain <code>${gain}</code>.</div>`;
```

:::

htl outputs are trusted live DOM outputs, not sanitized strings. htl still escapes interpolated text according to its library semantics, but event handlers and DOM nodes run with the same trust level as the notebook cell code. The notebook output protocol stores only an ephemeral DOM output id; the live node itself is not persisted.

Builtin Runtime Surface

The current notebook cell runtime no longer exposes a public nb namespace. Lapis-provided notebook helpers live canonically under lapis.*:

  • lapis.display(value)
  • lapis.duckdb.query(sql)
  • lapis.duckdb.registerVaultFile(alias, path)
  • lapis.vault.readText(path)
  • lapis.vault.readBinary(path)
  • lapis.Inputs
  • lapis.html and lapis.svg
  • lapis.md
  • lapis.tex and lapis.tex.block
  • lapis.invalidation and lapis.now
  • lapis.aapl, lapis.alphabet, lapis.cars, lapis.citywages, lapis.diamonds, lapis.flare, lapis.industries, lapis.miserables, lapis.olympians, lapis.penguins, lapis.pizza, and lapis.weather
  • lapis.DOM
  • lapis.Generators and lapis.Promises
  • lapis.table, lapis.chart, lapis.markdown, lapis.text, lapis.image, and lapis.error

Documented bare convenience globals may mirror selected Lapis builtins when that keeps notebook cells terse, for example display, Inputs, html, svg, md, tex, d3, Plot, DOM, Generators, Promises, now, invalidation, and the upstream sample dataset globals aapl, alphabet, cars, citywages, diamonds, flare, industries, miserables, olympians, penguins, pizza, and weather. The current DOM-backed runtime exposes display, Inputs, html, and svg as bare convenience globals while also attaching display, Inputs, html, and svg as lapis.display, lapis.Inputs, lapis.html, and lapis.svg, with lapis.view(name, input) remaining the runtime-owned binding hook behind transformed view(...) declarations. The supported Inputs.* controls are owned inside @lapis-notes/notebook-input, including Inputs.table(...) for serializable row selection with sortable headers, and mounted from shared notebook input surfaces so notebook controls match app-level styling, with reconstructable metadata persisted under source.library: "@lapis-notes/notebook-input". JavaScript-like notebook execution also injects lapis.md, lapis.tex, lapis.DOM, lapis.Generators, lapis.Promises, lapis.now, lapis.invalidation, the bundled sample dataset globals, and the documented bare md, tex, DOM, Generators, Promises, now, invalidation, and sample dataset globals by default from the shared stdlib bootstrap. invalidation resolves when the current cell is rerun or its outputs are explicitly cleared, while now is an execution-time snapshot rather than an ambient auto-refreshing observer. User-installed dependencies are not Lapis builtins and should not be copied under lapis.*; they remain available through configured globals when a notebook runtime package descriptor declares one, or through direct static import syntax for configured packages and host-installed bare package imports.

Package Imports

Notebook JavaScript and TypeScript cells now support top-level static ESM import declarations. The runtime rewrites those declarations before evaluation so cells can keep running inside the notebook execution sandbox instead of relying on browser-native module loading from the raw cell source.

The current import rules are:

  • bare imports that match a configured notebook runtime package descriptor resolve through that descriptor’s pinned version and resolver settings
  • bare imports that match a package listed in the host workspace package manifest resolve through the package name plus the installed version range known to the workspace app
  • absolute URL imports still execute as direct runtime module imports
  • vault-scoped relative JavaScript module specifiers such as ./helpers.js and ../shared/math.mjs resolve from the importing notebook or notebook-local module path, reject vault-root escapes, and currently support only .js and .mjs sources
  • absolute filesystem-like specifiers remain rejected so notebook modules cannot bypass vault-relative resolution

When a cell uses static import syntax, imported binding names take precedence over configured runtime globals with the same identifier so the legacy global loader does not eagerly fetch the wrong package for that cell.

This keeps the legacy configured-global workflow available while letting notebook cells use ordinary import syntax for locally installed packages such as lodash or configured remote runtime packages.

The internal notebook-input transform compiles constrained const|let|var name = view(expression) declarations to lapis.view(name, expression) so generated notebook cell code stays on the canonical Lapis namespace.

System Guide Acceptance Fixture

The repo now keeps a markdown-native parity anchor at e2e-vault/plugin-notebook/System Guide.notebook.md with supporting attachments under e2e-vault/plugin-notebook/assets/.

That fixture intentionally covers the current Lapis-owned subset of Observable’s System Guide rather than the full Observable surface:

  • async JavaScript cells that resolve notebook state before downstream cells render
  • explicit display(...) calls from JavaScript program cells, including multiple displays in one cell
  • plain view(...) inputs and downstream reactivity into markdown, HTML source, and DOM outputs
  • direct Inputs.table(...) row selection with sortable headers backed by attachment-loaded CSV data and downstream reactivity into markdown outputs
  • ${...} interpolation in markdown and HTML source cells, with escaped HTML interpolation values
  • md and tex tagged template helpers plus now execution snapshots that produce notebook markdown outputs
  • trusted html output mounting in JavaScript cells
  • vault-backed lapis.FileAttachment() JSON and CSV reads rendered through notebook-aware markdown and table outputs
  • bundled Observable sample dataset access from JavaScript cells, including both row-array datasets and the graph-shaped miserables dataset
  • vault-scoped relative JavaScript module imports used by notebook cells to share small helper functions between neighboring files

Workspace Playwright acceptance seeds those exact fixture files into the browser test vault and verifies the rendered and interactive behavior end to end. This note is the regression target for the current Observable parity slice and should stay readable enough to explain what the runtime is expected to support.

Compilation And Cancellation

Notebook JavaScript-like cells currently compile through the notebook runtime that owns the cell execution. Worker-eligible notebooks load the TypeScript compiler inside the notebook worker when static imports or TypeScript syntax require it. Browser, PWA, Electron DuckDB-backed, live-DOM-backed, and host-provider-backed notebooks use the in-process execution path and lazily load the same compiler only when a JavaScript-like cell actually needs rewriting or transpilation.

Native TypeScript compilation was evaluated after the Electron DuckDB sidecar landed. The current decision is to keep the existing lazy compiler path and the host-neutral compileProvider seam, without adding an Electron compiler sidecar. Moving only transpilation to Electron main or to a new compiler child would not move notebook evaluation itself: JavaScript-like cells still execute in the renderer/in-process runtime when they need DOM access or nonserializable native providers. It would also not solve cancellation for already-running synchronous user code, because TypeScript transpilation is only one pre-execution step and the current cancellation model restores the notebook checkpoint and terminates the owned worker or DuckDB runtime rather than preempting arbitrary in-process JavaScript.

The compileProvider contract remains a host seam, but native compilation requires a separate proposal backed by renderer memory, cold TypeScript import, and long-compile timing data. Such a proposal also needs its own timeout boundary for compiler requests and a distinct design for cooperative or isolated cancellation of JavaScript-like cell evaluation. In the current architecture, Electron’s native notebook work stays focused on DuckDB query and vault-file execution, where the sidecar removes large data movement and native query work from the renderer.

Mixed Notebook Note Example

# Weekly Review

Ordinary markdown outside `cell` directives stays ordinary prose and is not
itself an executable cell.

:::cell{#load-data lang="ts"}

```ts
const selectedFolder = "Projects";
```

:::

:::cell{#matching-notes lang="sql"}

```sql
SELECT path, name
FROM notes
WHERE path LIKE {{selectedFolder}} || '/%'
ORDER BY name
```

:::

:::cell{#report lang="md"}

## Matching Notes

Folder: `{{selectedFolder}}`
:::

Rich Output Model

Executable cells need a typed output protocol in addition to executable source.

The markdown cell syntax only defines where cells live in the note. marimo-style rich rendering comes from the runtime’s cell result model and from notebook-owned renderers that mount those results into the UI.

Output kinds

The implemented output protocol supports:

  • text - plain text
  • markdown - rendered markdown fragments
  • html - sanitized HTML fragments when explicitly allowed
  • input - structured reconstructable input controls for tracked Observable-style notebook inputs
  • dom - trusted live DOM nodes produced by Observable inputs, htl, or explicit DOM output helpers; this output kind is ephemeral and is not persisted
  • table - structured tabular data for interactive rendering
  • chart - chart specs rendered by approved chart libraries
  • image - inline images or binary image artifacts
  • error - structured runtime and graph errors

An illustrative TypeScript shape:

type NotebookOutput =
  | { kind: "text"; text: string }
  | { kind: "markdown"; markdown: string }
  | { kind: "html"; html: string }
  | {
      kind: "input";
      cellId: string;
      variableName: string;
      inputType: string;
      value: unknown;
      label?: string;
      config?: Record<string, unknown>;
      reconstructable: boolean;
      source?: {
        library?: string;
        factory: string;
        version?: string;
      };
    }
  | {
      kind: "dom";
      outputId: string;
      cellId: string;
      label?: string;
      role: "input" | "html" | "svg" | "dom";
      persist?: false;
    }
  | {
      kind: "table";
      columns: string[];
      rows: unknown[][];
      columnMeta?: {
        key: string;
        label?: string;
        type?:
          | "string"
          | "number"
          | "date"
          | "boolean"
          | "json"
          | "null"
          | "unknown";
        displayType?: string;
        nullable?: boolean;
      }[];
      schema?: {
        rowCount?: number;
        truncated?: boolean;
        queryTimeMs?: number;
        sourceSql?: string;
      };
    }
  | {
      kind: "chart";
      library: "vega-lite" | "plot";
      spec: unknown;
    }
  | { kind: "image"; mime: string; data: string }
  | { kind: "error"; message: string; stack?: string };

Renderer model

Notebook-aware presentations should not treat rich outputs as arbitrary DOM mutations owned by the executing cell.

Instead, the notebook plugin does the following:

  • define the output protocol
  • keep a renderer registry keyed by output kind and optional library id
  • mount notebook-owned Svelte components for each supported output kind
  • keep live DOM nodes in a runtime registry and mount them through notebook-owned DOM wrappers

This keeps output rendering consistent across notebook mode, live preview, and reading mode.

First-rollout output priorities

The first rollout should support rich outputs, but in a narrow, explicit way.

Recommended order:

  1. interactive table outputs
  2. markdown and HTML outputs
  3. chart-spec outputs
  4. richer interactive widgets such as filters or dataframe explorers

Tables are the most natural first rich output because DuckDB-Wasm and SQL cells already produce tabular results. Charts should be spec-driven rather than imperative so the notebook plugin retains rendering ownership.

The current notebook table renderer now uses a notebook-owned dataframe viewer rather than a plain HTML table: column titles and type metadata stay in the header control row, compact summary blocks render beneath each column header, client-side search, sort, filter, pagination, and column visibility are composed from shared shadcn-style UI primitives, a right-side sheet exposes richer per-column summary detail, and rendered pages switch to virtualized row scrolling when the current page exceeds a couple hundred rows. SQL-backed table outputs now also attach additive schema metadata automatically at execution time, including inferred column types plus rowCount, queryTimeMs, and the executed SQL string when available.

Example table cell

:::cell{#sales-table lang="ts"}

```ts
const result = await lapis.duckdb.query(`
  SELECT region, SUM(amount) AS revenue
  FROM read_csv_auto('sales.csv')
  GROUP BY region
  ORDER BY revenue DESC
`);

lapis.table(result);
```

:::

Example chart cell

:::cell{#sales-chart lang="ts"}

```ts
const result = await lapis.duckdb.query(`
  SELECT region, SUM(amount) AS revenue
  FROM read_csv_auto('sales.csv')
  GROUP BY region
  ORDER BY revenue DESC
`);

lapis.chart("vega-lite", {
  data: { values: result.toArray() },
  mark: "bar",
  encoding: {
    x: { field: "region", type: "nominal" },
    y: { field: "revenue", type: "quantitative" },
  },
});
```

:::

Example dynamic markdown cell

:::cell{#sales-summary lang="md"}

## Revenue Summary

Top region: **{{topRegion}}**

Rows in the table: **{{rowCount}}**
:::

Why Container Directives

The directive-based shape is proposed because it aligns with the existing markdown implementation better than ad hoc fence parsing.

Benefits:

  • keeps ordinary fenced code blocks available for documentation snippets
  • gives cells explicit identity and metadata through directive attributes
  • maps naturally onto block widgets in live preview and reading mode
  • reuses parser support already present for markdown directives

Proposed Cell Attributes

The first draft attribute set is:

  • #id - stable cell id used for outputs, persistence, and graph diagnostics
  • lang - ts, js, sql, or md; py/python cells are not recognized by the current core runtime
  • run - optional override: autorun, lazy, or manual
  • hide - optional flag to hide source in notebook presentation mode
  • folded - optional initial folded state hint

If an authored cell omits an id, the editor should generate one and persist it back into the directive on structural save.

Python Cells Decision

The current decision is to keep Pyodide-backed Python cells out of the core notebook runtime. The supported core language set remains JavaScript, TypeScript, SQL, and dynamic markdown.

Python cells would require a second interpreter lifecycle rather than a small language-mode addition. A viable design would need an optional package or runtime provider boundary, explicit Pyodide asset loading and package policy, cancellation and timeout semantics, value conversion between Python and the JavaScript notebook session graph, table/dataframe exchange with DuckDB outputs, generated-state rules, and browser/Electron parity. Those requirements overlap enough runtime ownership boundaries that they should not be added as an implicit lang="py" branch in the existing JavaScript-like execution path.

Future Python support can be reconsidered as a separate proposal when there is a user-facing workflow that justifies the bundle/runtime cost and when the proposal includes host-neutral tests for parsing, dependency graph behavior, execution isolation, cancellation, output rendering, generated-state hydration, and Electron/browser fallback behavior. Until then, py and python cell languages remain unsupported and continue to normalize to the current TypeScript fallback during parsing.

Dependency Graph Rules

JavaScript and TypeScript cells

The runtime performs static analysis over top-level bindings.

  • definitions are top-level exported names, functions, classes, and imports introduced by the cell
  • references are top-level names read by the cell but not defined inside it
  • _local names are considered cell-local and are not exported to other cells

SQL cells

SQL cells should be notebook-graph aware without requiring a server-side parser.

The proposed first-pass rule is:

  • table names introduced by notebook mounts or prior cells are available directly in SQL
  • scalar or structured values from other cells are referenced through {{name}} placeholders
  • each placeholder is treated as a dependency edge in the notebook graph

This keeps SQL dependency extraction deterministic and browser-safe.

Markdown cells

Dynamic markdown cells do not define names. They participate in the graph only by referencing {{name}} placeholders.

Shared runtime rules

The notebook runtime should adopt marimo-like consistency rules:

  • deleting a cell removes its definitions from notebook state
  • descendants of a changed cell rerun automatically in autorun mode
  • changed cells and the dependency closure required to recompute their stale dependents are marked stale in lazy mode
  • mutations are not tracked across cells; new values should be created rather than mutating shared objects elsewhere
  • multiply defined exported names are a notebook error
  • cycles are notebook errors

Data Access Model

DuckDB-Wasm

DuckDB-Wasm is the proposed query and relational execution engine for browser and PWA notebook sessions.

Why:

  • worker-friendly browser runtime
  • CSV, Parquet, JSON, and Arrow support
  • direct registration of in-memory file buffers from vault reads
  • good fit for analytics and notebook-style tabular work

Vault-backed data

Notebook sessions should expose helpers for registering vault files into DuckDB.

Implemented helper shapes:

  • lapis.vault.readText(path)
  • lapis.vault.readBinary(path)
  • lapis.FileAttachment(path).arrayBuffer()
  • lapis.FileAttachment(path).text()
  • lapis.FileAttachment(path).json()
  • lapis.FileAttachment(path).blob(type?)
  • lapis.duckdb.registerVaultFile(alias, path)
  • lapis.duckdb.query(sql)

The existing vault adapter layer already supports binary reads, so CSV, Parquet, JSON, and SQLite files can be loaded from vault bytes in the browser. Electron desktop sessions keep the same lapis.duckdb.registerVaultFile(alias, path) notebook syntax but register CSV, Parquet, and JSON aliases by sending the resolved vault-relative path and native vault root to Electron main, where the path is validated under the selected vault before the DuckDB sidecar links or copies the file into its temporary working directory.

The current implementation builds the host-neutral lapis.vault and lapis.FileAttachment behavior from @lapis-notes/notebook-stdlib and supplies only the host file-reader callback from the notebook product runtime. DuckDB vault-file registration continues to use @lapis-notes/notebook-duckdb, which shares the same notebook-relative path-normalization rules through the stdlib helper and prefers a native runtime registrar when one exists before falling back to host-read file buffers.

Visualization Helpers

Notebook JavaScript and TypeScript cells expose bundled visualization helpers as Lapis-owned builtins:

  • lapis.d3 and bare d3
  • lapis.Plot and bare Plot
  • lapis.DOM and bare DOM
  • lapis.Generators and bare Generators
  • lapis.Promises and bare Promises

The current implementation assembles these bundled helpers through the shared @lapis-notes/notebook-stdlib runtime bootstrap when JavaScript-like cells execute, so lapis.d3, lapis.Plot, lapis.DOM, lapis.Generators, lapis.Promises, and the documented bare d3/Plot/DOM/Generators/Promises globals are present by default within that execution scope without per-cell source-triggered loading. Plot references still count as live DOM-backed notebook execution because Plot outputs are DOM/SVG-oriented. Helper-only DOM usages such as DOM.uid remain worker-eligible, while live browser DOM usages such as Inputs, html, svg, viewof, Plot, and browser-owned DOM helpers still force the in-process path.

Dynamic Runtime Dependencies

The dependency registry lets users describe additional JavaScript packages from plugin settings before runtime loading is implemented. A dependency entry includes the package name, pinned version, optional resolver metadata, and optional global name. If the user does not choose a global name, settings validation derives a valid JavaScript identifier from the package name before saving.

Examples:

PackageSelected or derived global
arqueroaq when selected, otherwise arquero
lodash-eslodashEs
@uwdata/mosaicmosaic or another validated user choice

These globals participate in dependency analysis as reserved external values. The notebook graph should not treat them as cell-local unresolved refs, and notebook authoring should block collisions with lapis, documented bare builtins, and other configured dependency globals.

The current settings UI no longer owns raw JSON editing for runtime dependencies. Instead it links to the dedicated Packages side view, which persists only registries that pass the shared @lapis-notes/notebook-stdlib validation contract and shows the configured descriptors as direct notebook imports, indirect import metadata when available, and all available configured imports. The package-name field now queries the npm registry for autocomplete suggestions, and install actions probe the pinned package metadata before saving while reporting inline progress back to the Packages panel. Notebook execution passes the validated registry into runtime options and JavaScript-like cells lazily resolve only configured dependencies whose globals appear in the executed source. Remote ESM descriptors resolve through dynamic import() from the configured baseUrl, defaulting to https://esm.sh, and expose the module value only as the selected or derived bare global. Serializable remote ESM descriptors travel through the worker protocol for worker-backed notebooks; non-serializable test loaders and future host-specific resolver handles stay on the main-thread path until a host/cached resolver policy exists. Desktop hosts may later add a cached or offline resolver behind the same registry contract.

DuckDB-Wasm Asset Split

DuckDB runtime code should move toward a package boundary where the service accepts DuckDB bundle URLs from a dedicated asset resolver rather than importing WASM and worker files directly from the product plugin package. The intended split is:

  • @lapis-notes/notebook-duckdb owns runtime contracts, query normalization, table registration helpers, and inspector-facing catalog helpers.
  • @lapis-notes/notebook-duckdb-assets owns DuckDB-Wasm and worker asset URLs, static-copy rules, and bundle selection inputs.
  • @lapis-notes/notebook owns the concrete browser/PWA DuckDB-Wasm service lifecycle, query execution, app snapshot collection, and vault reads.

This keeps the product plugin startup-thin and makes DuckDB asset loading explicit for browser, PWA, and future desktop hosts.

Observable Runtime Evaluation

Observable’s standard library is a useful API reference for DOM helpers, FileAttachment, generator helpers, promises, and builtin naming. The @observablehq/runtime compatibility spike concluded that Lapis should keep its own explicit notebook runtime as the core execution engine and borrow Observable-compatible APIs selectively.

The current decision is not to implement full Observable JavaScript compatibility in the core notebook runtime. The supported compatibility boundary is constrained top-level viewof name = expression input declarations, Observable Inputs controls, htl-backed html/svg, vault-backed lapis.FileAttachment, and selected standard-library helper names that can be hosted behind Lapis runtime contracts.

Full Observable semantics stay out of scope for core execution because they conflict with these Lapis-specific requirements:

  • explicit note-level, stale-cell, current-cell, and inline execution commands
  • generated-state persistence keyed by markdown cell ids and source fingerprints
  • TypeScript cells
  • SQL and dynamic markdown cells
  • worker/main execution selection for DOM, DuckDB, and non-DOM notebooks
  • notebook-owned typed output renderers rather than Observable inspectors as the primary output surface

Observable notebook imports, mutable variables, generator cells, implicit invalidation, and observer-driven output lifecycles should not be accepted as ambient JavaScript syntax in .notebook.md cells. Future compatibility work can propose one specific Observable concept at a time only when it preserves explicit Lapis execution commands, cell-id-based persistence, worker/in-process selection, and typed output rendering. There is no standing implementation task to adopt @observablehq/runtime or to pursue wholesale Observable syntax compatibility.

App-backed tables

Notebook sessions should expose read-only app-derived tables through reserved mounts or aliases.

Suggested built-in tables:

  • notes
  • links
  • tags
  • properties
  • search_docs

For the first rollout, these should be materialized from MetadataCache and AppDatabase snapshots into the notebook runtime rather than treated as direct arbitrary SQL access into the app database.

That choice keeps the current AppDatabase contract honest: it remains a generated-state API for metadata and search persistence, not the notebook runtime’s primary query boundary.

Persistence

Notebook outputs and transient execution state should be generated state, not canonical markdown.

The proposed persistence split is:

  • markdown note bytes store frontmatter, prose, and cell source
  • generated state stores outputs, stale state, execution errors, and cached session artifacts

Likely persistence targets:

  • per-vault generated-state tables in the app database
  • IndexedDB fallback when SQLite is unavailable

Generated state should store the typed output payloads rather than only pre-rendered HTML so notebook presentations can choose the correct renderer for tables, charts, markdown, and errors. Each persisted cell state stores a deterministic source fingerprint derived from the cell language and source. When the note file mtime still matches the persisted snapshot, the whole snapshot can hydrate as before. When only the file mtime changed, the notebook can still hydrate cells whose fingerprints match the current parsed cells; cells whose fingerprints changed, plus their graph dependents, reopen with their last output marked stale.

Live DOM outputs are the exception: generated state stores serializable input values plus any structured input outputs, but still omits the DOM output payload itself. Restoring a lazy notebook can seed the input value on the next run. For Observable-style viewof cells whose source fingerprint still matches, reconstructable structured inputs can now rebuild their controls directly from persisted metadata when no live input node is available, without re-executing the owning cell. Non-reconstructable inputs still fall back to rerunning the cell on first render, and other htl or DOM nodes are recreated when the owning cell executes.

Presentation Modes

The notebook syntax should be legible in source mode and richer in notebook-aware presentations.

Source mode

  • raw markdown directives and fences remain editable
  • cell ids and attributes are visible
  • notebook actions are available through gutter or command affordances

Live preview and notebook mode

  • cell directives mount as block widgets with toolbar chrome
  • code cells keep the live-preview source block visible and mount rendered notebook outputs underneath the block
  • markdown cells keep the live-preview directive block visible and mount rendered markdown with placeholder interpolation underneath
  • table outputs mount as interactive notebook-owned table components
  • chart outputs mount through approved chart-spec renderers
  • Observable inputs and htl outputs mount as trusted live DOM outputs through notebook-owned wrappers
  • stale and errored cells render explicit state badges

Reading mode

  • ordinary prose renders as normal markdown
  • code and markdown notebook cells render outputs and, when configured, their source blocks through the shared markdown code renderer, including syntax highlighting and line-number directives

Open Questions

This draft intentionally leaves several details open for later implementation work.

  • whether notebook presentation should be a new markdown mode or a dedicated notebook leaf state
  • whether SQL cells eventually gain deeper static analysis beyond {{name}} placeholder extraction
  • whether notebook outputs should be cached by cell hash, dependency hash, or both
  • which initial chart-spec libraries should be treated as first-class notebook renderers
  • how much sanitized HTML output should be allowed before it overlaps too much with explicit output kinds

Observable System Guide Parity

Status: Snapshot of the current markdown-native notebook compatibility boundary against Observable Notebook Kit’s System Guide. This page records what the shipped Lapis notebook runtime supports today, what is a reasonable future parity target, and what remains intentionally out of scope.

This audit complements Notebook Documents and Runtime by comparing the current .notebook.md runtime to the user-facing concepts in Observable’s System Guide rather than restating the full notebook implementation surface.

Purpose

Lapis notebooks intentionally borrow selected Observable-style runtime concepts while preserving a markdown-native file format, explicit notebook execution commands, notebook-owned typed renderers, and generated-state persistence keyed by notebook cell ids.

The parity question for the System Guide is therefore not whether Lapis accepts Observable source unchanged. The practical question is which System Guide behaviors are already covered by the current markdown-native notebook runtime, which additional behaviors are reasonable next targets, and which Observable semantics currently conflict with the Lapis notebook architecture.

Supported Today

The current notebook runtime supports the following System Guide-aligned behaviors today:

  • async JavaScript and TypeScript cells that resolve state before downstream cells render
  • JavaScript program cells that can explicitly render one or more values through display(...)
  • trusted live DOM output from htl-backed html and svg tagged templates in JavaScript-like cells
  • first-class html source cells with ${...} interpolation and escaped interpolated values
  • plain top-level const|let|var name = view(expression) input declarations, with downstream reruns when the input value changes
  • direct Inputs.table(...) support for serializable row selection over attachment-backed tabular data, including sortable column headers
  • vault-backed lapis.FileAttachment(path) reads for arrayBuffer, text, json, blob, csv, and tsv
  • bundled notebook globals that overlap Observable concepts where Lapis already has a stable ownership story, including display, Inputs, html, svg, md, tex, d3, Plot, DOM, Generators, Promises, now, invalidation, and the upstream Observable sample dataset bundle (aapl, alphabet, cars, citywages, diamonds, flare, industries, miserables, olympians, penguins, pizza, and weather)
  • JavaScript and TypeScript cells that can use top-level static ESM imports for configured runtime packages and host-installed bare packages
  • JavaScript and TypeScript cells that can use vault-scoped relative JavaScript imports for notebook-local helper modules
  • notebook-aware markdown outputs and dynamic markdown cells that can reference values from other cells through ${...} expressions or legacy {{name}} placeholders

The checked-in acceptance anchor for this boundary is the markdown-native System Guide fixture at e2e-vault/plugin-notebook/System Guide.notebook.md, which currently covers async setup, display(...), view(...), sortable Inputs.table(...), markdown and HTML source interpolation, escaped HTML interpolation, trusted DOM output, md/tex, now, bundled sample dataset access, and JSON/CSV FileAttachment-backed data loading.

Supported Differently

Some System Guide concepts exist in Lapis today, but with intentionally different authoring or execution rules:

  • Lapis notebooks use .notebook.md files with :::cell{...} directives instead of Observable’s HTML notebook source format
  • notebook execution is explicit-first: notebooks do not auto-run on load, though stale cells can rerun automatically after edits when notebook settings allow it
  • markdown reactivity keeps {{name}} placeholders as a Lapis convenience while also supporting Observable-style ${...} expressions inside Markdown source
  • notebook inputs use the constrained top-level view(...) declaration form rather than arbitrary Observable source transformations
  • notebook outputs render through notebook-owned typed renderers and DOM mount points rather than the Observable inspector as the primary presentation surface

These differences are part of the current product design, not accidental gaps.

Not Supported Today

The following Observable System Guide behaviors are not currently supported by the shipped Lapis notebook runtime:

  • the layout-sensitive System Guide built-ins visibility and width

These are the clearest remaining gaps between the current System Guide examples and the shipped markdown-native notebook runtime.

Out Of Scope By Design

Some Observable semantics remain intentionally outside the current Lapis notebook architecture:

  • adopting @observablehq/runtime as the core notebook engine
  • accepting Observable’s ambient source model wholesale inside .notebook.md cells
  • Observable mutable variables
  • generator cells as ambient notebook syntax
  • observer-driven output lifecycle semantics tied directly to Observable runtime observers
  • implicit invalidation or resource lifecycle behavior that bypasses explicit Lapis notebook execution ownership

These items currently conflict with explicit notebook execution commands, typed output rendering, generated-state persistence keyed by notebook cell ids and source fingerprints, and the worker versus in-process execution split used by the Lapis runtime.

Next Parity Targets

The next reasonable parity targets are the remaining System Guide behaviors that improve user-facing compatibility without requiring a wholesale Observable runtime model:

  1. Layout-sensitive helper parity. Evaluate visibility and width only if the notebook runtime gains a stable per-cell DOM lifecycle contract that works across worker fallback, restored outputs, and explicit reruns.
  2. Broader table interactions. Revisit optional filtering or larger-data presentation improvements for Inputs.table(...) now that the first serializable selection and sortable-header contracts have proven stable.

These targets are suitable for separate follow-up tasks because each can be evaluated against the existing notebook execution model, worker/in-process split, and typed output protocol without reopening the broader decision against full Observable source compatibility.

Validation Anchor

When parity changes land, they should extend both the markdown-native System Guide fixture and the focused workspace acceptance coverage so this page remains a current description of shipped behavior rather than a speculative wishlist.

Observable Runtime Spike

Date: 2026-05-09

Outcome

Do not adopt @observablehq/runtime as the core Lapis notebook runtime for the current refactor.

Lapis should keep its own explicit notebook execution runtime and continue borrowing Observable-compatible API shapes selectively, especially for viewof, Inputs, html, svg, FileAttachment, generators, and helper naming. A future compatibility layer may still use Observable concepts or selected packages, but the core runtime should remain Lapis-owned until there is a concrete design that preserves Lapis execution, persistence, worker, and renderer contracts without forcing an Observable notebook execution model into the product plugin.

Spike Inputs

The upstream @observablehq/runtime package exposes a reactive module/variable runtime:

  • new Runtime(builtins, global) creates a runtime with builtins and unresolved-global resolution.
  • runtime.module(define, observer) creates modules and observer factories.
  • module.variable(observer).define(name, inputs, definition) defines reactive variables.
  • module.import(...), module.derive(...), and module.redefine(...) support imports and replacement variables.
  • module.value(name) resolves the next value of a named variable.
  • observers receive pending, fulfilled, and rejected notifications.
  • variables compute when observed, and generator variables are pulled by the runtime.

Those mechanics are well-suited to Observable notebooks, where the runtime owns reactive variables and observers are the primary output surface.

Compatibility Findings

Observable runtime can model dependency-driven JavaScript values, but it does not directly match the Lapis notebook execution contract.

RequirementFinding
Explicit note-level, stale-cell, current-cell, and inline execution commandsObservable variables recompute through runtime dependency invalidation and observers. Lapis commands need deterministic command-scoped execution, cancellation, progress, and selective downstream rerun policy.
Generated-state persistence keyed by markdown cell ids and source fingerprintsObservable variables are runtime entities whose names can change or be anonymous. Lapis persists output and cell state by stable parsed markdown cell ids and source fingerprints.
TypeScript cellsObservable runtime accepts JavaScript definitions; Lapis still needs TypeScript transform and error mapping before execution.
SQL and dynamic markdown cellsObservable runtime does not provide Lapis SQL interpolation, DuckDB table registration, or dynamic markdown output semantics. These remain Lapis runtime responsibilities.
Worker/main execution selectionLapis chooses worker versus in-process execution from DOM, DuckDB, dependency-loader, and host capability facts. Observable runtime does not solve the product-specific worker and host bridge split.
Notebook-owned typed output renderersObservable observers/inspectors are output-oriented, while Lapis needs typed table, chart, markdown, DOM, image, and error outputs routed through notebook-owned renderers and generated-state persistence.

Decision

Keep the Lapis runtime as the core execution engine.

Use Observable as an API-shape reference rather than as the execution substrate:

  • keep constrained viewof name = expression support through @lapis-notes/notebook-input
  • keep Observable Inputs and htl loading lazy and DOM-scoped
  • keep lapis.FileAttachment vault-backed rather than Observable-host-backed
  • keep user dependencies as validated bare globals rather than Observable module imports for now
  • evaluate specific Observable concepts later only when they can be adapted behind the existing Lapis runtime contracts

This closes the current compatibility spike. Fuller Observable JavaScript syntax, imports, mutable values, generators, and invalidation should not be adopted wholesale or tracked as one standing implementation task. Future compatibility work may propose a specific Observable concept only when it preserves the existing Lapis execution, persistence, worker, and renderer contracts.

PDF Plugin (@lapis-notes/pdf)

Source: packages/plugins/plugin-pdf/src/

Package: @lapis-notes/pdf. Manifest id: lapis-pdf.

The PDF package owns registry-installable PDF viewing for .pdf files plus PDF-specific embed rendering for markdown note embeds. The package remains in the monorepo for building official release artifacts, but the workspace no longer bundles or registers it by default.

Registration

  • View type: pdf
  • Official registry identity: lapis-pdf
  • Install location: /.obsidian/plugins/lapis-pdf; listed under Core plugins when installed provenance is official
  • File association: .pdf

Current Functionality

  • Ships the first-party plugin package and manifest for official registry installation.
  • Registers a dedicated file-backed PDF leaf instead of falling back to a text editor.
  • Requests PDF file resources through Vault.getResourceUrl() and mounts an EmbedPDF-backed viewer inside plugin-owned chrome.
  • Reuses the same viewer runtime for markdown ![[file.pdf]] embeds by registering a PDF embed renderer through the app embed registry.
  • Loads the heavy EmbedPDF runtime lazily from the plugin package so core-plugin registration does not eagerly pay the PDF engine startup cost.
  • Gives the EmbedPDF mount target an explicit, observed pixel height from its plugin-owned host so EmbedPDF’s shadow-DOM layout can render the document pages instead of measuring only its toolbar chrome.
  • Persists PDF viewer defaults through external plugin data under /.obsidian/plugins/lapis-pdf/data.json, with canonical plugin data mirrored through app configuration.
  • Exposes plugin settings for default zoom, scroll direction, page gap, and an optional dark-mode page inversion filter; the built-in default zoom is fit width so opened PDFs use the available pane width before any user zoom change.
  • Subscribes to EmbedPDF viewer state and writes viewer zoom/layout changes back into the plugin settings data so reloads reuse the last chosen viewer defaults.
  • Initializes EmbedPDF theme preference from the current Lapis app theme instead of OS-level system theme and updates open viewers when the app toggles between theme-dark and theme-light.
  • Disables EmbedPDF’s built-in document-open and document-close actions so the viewer stays scoped to the Lapis-owned file or embed source instead of opening a replacement browser file picker flow inside the viewer.
  • Exposes the dark-page inversion option in the PDF settings tab and in the PDF pane menu for file-backed views.
  • Applies the dark-page inversion filter only while the app is in dark mode, preferring page-image styling inside EmbedPDF’s shadow root and falling back to a scoped host-level filter only when shadow-root injection is unavailable.
  • Maps Lapis PDF theme tokens onto EmbedPDF’s --ep-* theme variables so toolbar chrome, overlays, inputs, borders, tooltips, and status colors inherit the active app theme instead of EmbedPDF’s stock dark/light palette.
  • Leaves file naming to workspace tab/header chrome and keeps the PDF leaf body focused on status messages plus the viewer surface.
  • Emits dist/app.css for workspace loading and dist/styles.css for standalone/plugin-local CSS consumption.
  • Namespaces plugin-owned chrome under pdf-... selectors so file views and inline embeds stay within the package boundary.
  • Uses --pdf-... theme variables to map PDF shell, surface, elevated/overlay/input backgrounds, borders, text, state colors, accent states, focus rings, tooltips, and EmbedPDF runtime chrome back to the shared workspace theme tokens.

Package Boundary

The plugin owns PDF-specific file views, renderer-provider integration, and PDF embed rendering. Generic file-view lifecycle, plugin activation, markdown post-processing hooks, and shared sanitization rules remain in @lapis-notes/api and the workspace shell unless PDF support proves they need an explicit contract extension.

Slides Plugin (@lapis-notes/slides)

Source: packages/plugins/plugin-slides/src/

Manifest: id lapis-slides, version 0.0.1, min app version 1.7.7.

The slides package owns registry-installable presentation mode for Markdown notes backed by reveal.js. The package remains in the monorepo for building official release artifacts, but the workspace no longer bundles or registers it by default.

Runtime Surface

  • View type: SlidesViewType.
  • Command: start-presentation.
  • Official registry identity: lapis-slides.
  • Install location: /.obsidian/plugins/lapis-slides; listed under Core plugins when installed provenance is official.

Key Behaviors

  • Opens the current Markdown file in a dedicated slide view without changing the file association.
  • Adds Start presentation near the start of the Markdown pane menu alongside other view-mode actions.
  • Splits slides on blank-line-delimited --- markers, with ---- producing vertical child slides.
  • Supports speaker notes with notes: blocks or >[!notes]: callouts.
  • Reuses the markdown renderer so embeds, callouts, links, math, and other markdown features stay consistent.

CSS Ownership

The package owns slides.css and uses slides-... namespaced selectors for the reveal.js view. It should stay aligned with the shared Plugin CSS Contract.

Telemetry Plugin (@lapis-notes/telemetry)

Source: packages/plugins/plugin-telemetry/src/

Package: @lapis-notes/telemetry. Manifest id: lapis-telemetry.

The telemetry package owns the registry-installable diagnostics leaf UI and persisted browser telemetry settings. The workspace shell still hosts the browser telemetry controller. The package remains in the monorepo for building official release artifacts, but the workspace no longer bundles or registers it by default.

Registration

  • View type: plugin:diagnostics.
  • Commands: app:open-telemetry-diagnostics, app:clear-telemetry-traces.
  • Settings tab under Core plugins.
  • Official registry identity: lapis-telemetry.
  • Install location: /.obsidian/plugins/lapis-telemetry; listed under Core plugins when installed provenance is official.
  • Official ESM runtime artifact: dist/main.mjs.

UI Ownership

The diagnostics view includes a sortable and filterable recent-trace table, segmented trace summary, selected trace detail header, span waterfall/tree, selected-span details sidebar, web vitals, measurements, and a clear-diagnostics action.

Diagnostics chrome inherits shared Obsidian-aligned theme surfaces, hairline borders, compact radii, and semantic accent/warning variables while preserving trace status, slow-span, and error state distinctions.

Runtime Contract

  • Reads app.telemetry.diagnostics.
  • manifest.json declares lapis.runtime.entries.workspace with ESM main.mjs, no CommonJS fallback, shared dependencies for @lapis-notes/api, svelte, and clsx, and requiresReloadOnUpdate: false.
  • The build validates retained bare imports against the generated public plugin-host shared externals list so private Svelte internals and UI subpaths are bundled instead of exposed as host APIs.
  • Loads and saves settings through external plugin data under /.obsidian/plugins/lapis-telemetry/data.json, with canonical plugin data mirrored through app configuration.
  • Applies settings through app.telemetry.configure(...) during plugin load.
  • Can persist diagnostics in vault-scoped IndexedDB.
  • Can clear both live and persisted diagnostics through the clear command.
  • Depends on the browser telemetry controller retaining recent trace/span structure.

Settings

  • enabled
  • captureWebVitals
  • debugLogging
  • persistDiagnostics
  • sampleRate
  • slowSpanThresholdMs
  • otlpEndpoint

Spellchecker Plugin

Source: packages/plugins/plugin-spellchecker/src/

Manifest: id harper, version 0.30.0, min app version 1.7.7. Desktop only. Author: Elijah Potter.

This is an Obsidian-targeted package colocated in the monorepo. It provides grammar and spelling diagnostics through the Harper WASM engine.

Registration

  • CodeMirror linter extension.
  • Settings tab.
  • Status bar dialect selector.

Linting

The package can use WorkerLinter in a Web Worker or LocalLinter on the main thread. Each diagnostic includes source ranges, severity, message, actions, and ignore state; quick-fix actions apply replacements through the active editor view.

Electron Native Evaluation

Lapis does not currently add a dedicated Electron-native Harper engine for this package. The supported execution policy remains the upstream Harper.js path: prefer WorkerLinter with inlined WASM when worker support is available, and fall back to LocalLinter only when worker execution is unavailable or explicitly disabled by settings.

The package is UI-coupled today: it registers CodeMirror lint extensions, a settings tab, and a status-bar dialect menu through the Obsidian plugin API. Moving the whole plugin into Electron main would either require tunnelling live renderer objects across IPC or weakening the plugin-host boundary. That is not allowed by the Community Plugin Host Boundary.

If a native Harper path is reconsidered later, it should be a dedicated child-process or utility-process lint engine owned by Electron main, not a LocalLinter running inside Electron main itself. Main would broker lifecycle, request timeouts, and crash recovery; the sidecar would receive structured-clone-safe text/settings payloads and return serializable diagnostics. Renderer-owned CodeMirror decorations, quick fixes, settings UI, and status affordances would remain renderer contributions. Until measurements show the renderer worker path is insufficient, there is no active follow-up to implement that split.

Settings

{
  ignoredLints?: string;
  useWebWorker: boolean;
  dialect?: Dialect;
  lintSettings: LintConfig;
  userDictionary?: string[];
  delay?: number;
}

The settings tab includes worker mode, dialect, lint delay, ignored-suggestion reset, and searchable rule toggles. The status bar exposes the Harper logo and dialect switching affordance.

File System, Metadata, and Search

The data flow is file-first. Storage updates propagate into metadata indexing, then into higher-level features such as search and views.

Storage and Vault

The vault is the source of truth for files, folders, reads, writes, renames, and deletes. VaultAdapter is the preferred high-level canonical file interface; it currently extends the existing DataAdapter contract so plugins and older call sites can continue to use DataAdapter while the codebase migrates toward runtime-neutral vault sessions. Saved vault profiles can also carry optional demo metadata that identifies a generated demo fixture (source, fixtureVersion) so chooser and command surfaces can distinguish bundled demo vaults from user-created vaults without overloading the storage kind.

VaultSession is resolved before App construction. A session carries the active runtime, optional browser vault profile, canonical vaultAdapter, and per-vault appDatabase. Browser bootstrap now resolves this session after the user picks OPFS, File System Access, or legacy storage.

The desktop host now resolves the same session boundary through a native bridge. Desktop vault profiles use the desktop-folder kind (legacy tauri-folder profiles are migrated on open), restore a user-selected root path through NativeDesktopVaultAdapter, and keep all generated metadata/search/app state outside the selected folder.

Browser Adapters

AdapterAPIUse Case
OpfsVaultAdapterOrigin Private File SystemDefault for new browser-local vaults. No permission prompts. Multiple vaults live under vaults/<vaultId>/. listOpfsVaultIds() and listOrphanOpfsVaultIds() discover OPFS directories missing from the saved profile store so the chooser can offer recovery without deleting stored files.
BrowserHandleVaultAdapterFile System Access APIUser-selected real folders. Requires user gesture for authorization.
Lightning FSIndexedDB-backed FSLegacy/manual use. Multiple browser-local legacy vaults use per-profile Lightning FS store names; profile id lightningfs-default keeps the original lightning-fs-1 store.

OPFS and File System Access store canonical file bytes through the vault adapter. Handle-backed browser adapters retry recoverable NotFoundError, NotReadableError, NoModificationAllowedError, InvalidStateError, and ENOENT failures on reads, writes, deletes, and directory listings, and replacement writes use truncating browser writable streams so shorter content cannot leave stale bytes behind. Generated app state is separate from those canonical files. Browser vault profiles remain in IndexedDB-backed profile storage, but metadata and search-derived state now go through AppDatabase.

For normal browser sessions, the app database resolver attempts SqliteWasmAppDatabase first only when OPFS is available and the host is cross-origin isolated, because the current SQLite target uses the OPFS SAH pool VFS. That implementation stores a per-vault SQLite database outside the user-visible vault tree. Browser SQLite execution now lives behind a dedicated worker, with the main thread proxy mirroring metadata/search state for local reads while routing SQLite work through message passing. When the browser exposes the Web Locks API, the resolver now treats lock contention as a coordination state instead of as a fallback condition: it takes an exclusive per-vault lock before opening the SAH pool, starts SQLite only in the lock-owning tab, and returns a coordinated AppDatabase proxy to competing tabs when BroadcastChannel is available. Those secondary tabs forward AppDatabase requests to the owner over a per-vault RPC channel while still listening for heartbeats and retrying lock acquisition with jitter when the owner disappears. If a proxied request is still in flight when the owner disappears, the coordinated database now replays that request locally as soon as the secondary tab finishes promoting itself to owner instead of surfacing a stale RPC timeout back to the app. The blocked session state remains as a fallback when delegation cannot be established. If the prerequisites are missing, Web Locks are unavailable, or SQLite WASM / OPFS startup fails after ownership is acquired, it falls back to IndexedDbAppDatabase, which persists the same AppDatabase state shape in vault-scoped IndexedDB. This fallback remains the compatibility path rather than the normal multi-tab path.

The File System Access adapter works with real folders but requires re-authorization on each session. Its generated app database is still per-vault and must not be written into the selected folder.

Desktop Native Adapter

AdapterAPIUse Case
NativeDesktopVaultAdapterNativeDesktopBridge IPC commandsUser-selected real folders in the Electron desktop shell.

The desktop host exposes a narrow bridge for folder selection, canonical file operations scoped to the selected vault root, resource URL conversion, optional native drag starts, optional native file-watch events, and native file actions. NativeDesktopVaultAdapter lives in packages/api so the shared runtime continues to talk to a single vault contract, while the host package owns the actual filesystem work. Electron resource URLs are served through the host-owned lapis-vault-resource:// protocol rather than by copying media bytes through renderer IPC. The protocol resolves only vault-relative paths under the selected root and is consumed through Vault.getResourceUrl() / Vault.revokeResourceUrl() so browser adapters can keep blob URL fallbacks while native hosts can stream images, PDFs, and other read-only embeds from the filesystem. Native file actions resolve vault-relative paths to host paths, open them through the default app, and reveal them in the file manager only after validating that the target remains under the selected vault root.

Desktop generated state now uses NativeDesktopAppDatabase, which persists the serializable AppDatabase state shape in a per-vault native SQLite file under the host user-data directory outside the selected vault folder. The current native backend stores one serialized state payload per vault id inside that SQLite state database and still treats that store as disposable generated state; the follow-on native search schema work owns table-level search/query parity. Search refreshes can now open an explicit search-index batch through AppDatabase, letting the native desktop backend defer per-document native search mirroring and full-state persistence until the batch ends, then flush once with a full native replacement pass. Desktop generated state must never move the primary app database into the selected user vault folder, but metadata cache snapshots are also mirrored to a vault-local generated backup at .lapis/cache/metadata-cache.json. That backup is rebuildable, safe to delete, and exists only to hydrate imported or copied vaults when the host-owned native database is missing. Electron state-load probing returns null when neither SQLite nor legacy JSON state exists instead of creating an empty SQLite file during the probe. Electron now also exposes reveal-folder and per-item reveal/open commands through the native bridge so users can inspect, copy, or open canonical files outside the renderer.

When the active vault is an OPFS-backed browser vault, the app now exposes an import command that prompts for a local File System Access folder, copies that folder tree into the current OPFS vault with overwrite semantics on matching paths, updates the in-memory vault tree and metadata cache, and surfaces progress through app.notifications. Browser-local vaults now also expose the inverse export command, which copies the current vault tree into a user-picked File System Access folder with determinate progress and error reporting. This gives browser and agent sessions a predictable way to move canonical vault files into or out of opaque browser-local storage without depending on a persistent File System Access permission grant for the primary vault itself. The web renderer still presents the progress in-app, while native desktop hosts can mirror durable outcomes through their own notification capability.

App Database

AppDatabase is the per-vault generated-state contract beneath App:

The SQLite schema currently includes schema_meta, files, metadata, links, tags, properties, search_docs, and a search_fts FTS5 virtual table. Canonical note bytes are not stored in SQLite. The database is disposable generated state: it can be deleted and rebuilt from canonical vault files. Electron’s native desktop backend mirrors prepared search documents into host SQLite and uses FTS5 for plain lexical candidate selection. When a platform-compatible sqlite-vec extension is available, Electron also mirrors ready chunk embeddings into a native vec0 table for vector candidate selection; otherwise vector and hybrid search modes fall back to the shared in-memory vector scorer over the same mirrored documents.

Notebook generated state also belongs behind AppDatabase. Notebook cells may persist typed serializable outputs, execution metadata, scalar input values used to seed future runs, and per-cell source fingerprints used to decide whether saved outputs still correspond to the current cell source. Live DOM nodes produced by notebook inputs or htl are runtime state only and must not be written into the generated-state store. Notebook recovery flows therefore keep canonical .notebook.md source separate from generated outputs: users can clear persisted notebook outputs for the active note without deleting notebook source cells.

File history generated by the History Plugin also belongs behind AppDatabase. History revisions are per-vault app state, not canonical vault files, and must stay out of .obsidian/ and user note folders. The first history rollout stores debounced full-text snapshots for text files only, excluding .obsidian/, with content-hash deduplication and bounded per-file retention. Rename, delete, and restore flows are part of this app-database contract: history follows renames by stable file identity, delete state does not discard prior revisions, and restores write through the vault before recording a restore revision.

The browser SQLite implementation currently uses the OPFS SAH pool VFS, so web hosts must emit COOP/COEP response headers to enable the SharedArrayBuffer/Atomics path it requires. Multi-tab coordination now happens at session startup on the main thread: Web Locks provide exclusive ownership, BroadcastChannel heartbeats signal liveness, a per-vault RPC channel forwards AppDatabase calls from secondary tabs to the owner, and the worker is created only after ownership is confirmed. SQLite execution remains isolated behind a dedicated worker so indexing and query work do not run on the UI thread. Search-document upserts now return the worker-prepared document back to the renderer mirror so browser sessions do not rerun the embedding preparation path on the main thread after a successful worker write.

Blob Stores

BlobStoreLike is available as a lower-level byte-store seam for adapters that benefit from it, especially OPFS and native desktop resource handling. It sits below the vault layer and is not a replacement for canonical file operations.

File Tree

The Vault maintains an in-memory file tree: files: Record<string, TAbstractFile>. This is populated by vault.load() which recursively lists the adapter. The tree is the source of truth for path lookups (getAbstractFileByPath, getFileByPath).

Vault Events

EventPayloadTrigger
loadFile tree loaded
createfile: TAbstractFileFile/folder created
modifyfile: TAbstractFileFile content modified
deletefile: TAbstractFileFile/folder deleted
renamefile: TAbstractFile, oldPath: stringFile/folder renamed
allevent: string, file, contextAny event (discriminated)

File-System Watching

packages/workspace/src/lib/hooks/watch-vault.svelte.ts wires a watcher over the vault adapter.

When an adapter reports nativeWatch: true, DirectoryWatcher now prefers adapter-provided native watch events over the polling fallback. Electron currently satisfies that contract by forwarding preload subscriptions to main-process chokidar watchers and translating host events back into normalized vault-relative paths.

Responsibilities:

  • Detect create, modify, delete, and rename activity.
  • Close open file views when files are deleted.
  • Retarget open file views when files or folders are renamed.
  • Emit workspace-level "file-change" events for active features.

Debouncing: A recentInternalEvents map tracks recent vault operations with a 7.5 s TTL to distinguish internal writes from external file system changes.

File-system synchronization is coordinated by the workspace shell, but effects are applied through runtime objects (views, leaves). Events for the generated metadata backup path .lapis/cache/metadata-cache.json and its generated parent folders are ignored before vault reload handling so periodic backup writes do not re-enter metadata indexing or trigger reload loops.

Metadata Cache

MetadataCache (packages/api/src/lib/cache.svelte.ts) indexes every file’s structural content.

What Gets Indexed

For each file, the cache stores:

  • File tracking: {mtime, size, hash} for change detection.
  • Parsed metadata: CachedMetadata keyed by content hash.

The CachedMetadata structure captures:

  • Frontmatter (YAML key-value pairs and position).
  • Internal links ([[link]]) with display text and position.
  • Embeds (![[embed]]) with position.
  • Tags (#tag) with position.
  • Headings with level (1-6) and position.
  • Sections (code blocks, headings, lists) with type and position.
  • List items with optional task state and parent index.
  • Footnote definitions.
  • Block IDs (^block-id).

Metadata extraction must tolerate the markdown package’s custom mdast nodes such as wikilinks and embeds during rebuilds. Heading text and original link spans are derived from node text and source offsets rather than relying on a generic mdast serializer that may reject custom node types.

Metadata Processors

Plugins register MetadataProcessor objects for specific file extensions. The markdown plugin registers a worker-backed YAML processor for .md files that runs asynchronously via PromiseWorker.

interface MetadataProcessor {
  read(data: string, ctx: { cache; file }): Promise<CachedMetadata>;
  write(cache: CachedMetadata): string;
}
  • getFirstLinkpathDest(linkpath, sourcePath) — Resolves wikilinks. Tries: exact path → .md.markdown → basename match.
  • resolvedLinks: Record<string, Record<string, number>> — Forward link adjacency map.
  • unresolvedLinks: Record<string, Record<string, number>> — Broken link adjacency map.
  • resolveSubpath(cache, subpath) — Resolves #heading, ^block, [^footnote] within a file.

Persistence

Caches are persisted primarily through app.appDatabase, keyed by vault ID. MetadataCache keeps the same public API and event surface but writes file tracking, parsed metadata, links, tags, and frontmatter properties into the app database. Metadata snapshots are also periodically mirrored to .lapis/cache/metadata-cache.json using a versioned generated-state envelope. On load, the restore order is app-database snapshot first, portable metadata backup second, legacy ScopedVaultStore metadata snapshot third, and a vault-driven rebuild only when none of those sources exists or validates. Restoring from the portable or legacy snapshot hydrates AppDatabase by saving the snapshot and upserting indexed file records derived from the restored cache.

The database records are derived from canonical vault files. Stale or incompatible metadata can be discarded and rebuilt by rereading files through Vault.

Events: changed(file, data, cache), deleted(file, prevCache), loaded.

Metadata cache events remain per-file and intentionally do not expose a precomputed reverse-dependency graph. Views and plugins that do not own vault-wide derived state are expected to scope their reactions to direct impact instead of reloading on every metadata event. The shared runtime now exposes direct-impact helpers on MetadataCache for this purpose: callers can enumerate a note’s directly resolved references, list the current files that directly reference a given path, and ask whether a changed or deleted path is the same file or a direct reference neighbor of a watched file. That contract is intentionally conservative and first-order only: it covers the active file plus directly linked or embedded targets, but rename remains a vault concern and deeper reverse-dependency expansion is still out of scope.

Metadata cache background load and explicit rebuilds report through app.notifications. Rebuild progress is determinate over the current vault file list and supports cooperative cancellation between files. Workspace command surfaces now group these repair paths under explicit rebuild actions so users can refresh metadata and search generated state without touching canonical vault files. Snapshot writes debounce the primary app-database save and throttle portable backup writes to at most once every 30 seconds, while explicit rebuilds and shell teardown force a backup flush. Loaded snapshots emit loaded before background stale-entry reconciliation, and no-state sessions emit loaded only after the first-open rebuild completes. Metadata events and persistence semantics remain unchanged.

Boot-Time Ordering

Metadata cache now loads immediately after the shell mounts instead of blocking the startup screen. The background load still happens after vault, config, plugins, and layout. This means:

  • Layout and plugins exist before metadata is ready.
  • Metadata-driven features must tolerate boot-time lag and react to the later loaded event.
  • Features like search rebuild incrementally as metadata arrives.

Metadata Type Tracking

MetadataTypeManager tracks frontmatter property types across all files:

  • types: Record<string, MetadataTypeDef> — Persisted to types.json.
  • properties: Record<string, MetadataTypeProperty> — Runtime index: { name, type, count, files }.
  • Auto-infers types: Number, Boolean, Tags, Aliases, Date, DateTime, Multitext, Array, Object, Text.
  • Plugins register custom TypeWidget renderers for frontmatter editing.
  • trackChanges() listens to metadata cache events and updates properties on file change/delete.
  • normalizeMetadataValue(type, value) in @lapis-notes/api performs best-effort write-path coercion against the declared property type. When coercion is unambiguous (for example string "false" on a checkbox column or "42" on a number column), frontmatter writers persist the normalized native value. When coercion is impossible, the original value is written unchanged and the Properties UI may continue to show a type-mismatch warning.

FileManager.processFrontMatter() is the shared mutation entrypoint for note-local frontmatter edits. It now seeds writes from either the current metadata cache or the current file contents, suppresses no-op writes, creates or removes the whole YAML block when the first or last property changes, and retries against a fresh snapshot when the file changes between the initial read and the vault write callback. Frontmatter clients should route note-local property adds, updates, and removals through that API instead of rewriting YAML blocks directly. Shared frontmatter update helpers and Bases inline cell writes call normalizeMetadataValue() against the declared types.json entry (or the caller’s fallback type) before persisting.

MetadataTypeManager now tracks exact nested property paths on a per-file basis instead of only counting top-level property names. That lets property rename operations target only the files that actually contain a given nested path, preserve falsey values such as false and 0, report partial failures when a destination path already exists in a file, and move the persisted types.json definition to the new id only after every targeted file update succeeds. Nested delete and type-change helpers use the same path semantics. For vault-wide top-level maintenance, the manager also exposes exact-key bulk rename, delete, and type-change operations that do not interpret dotted YAML keys as nested paths.

Indexed Metadata Queries

AppDatabase now owns a plugin-neutral indexed metadata query contract alongside metadata persistence and search. The contract returns serializable rows shaped as file records plus the mirrored metadata, property, tag, and link records for each candidate path. Browser SQLite and native SQLite-backed runtimes may use generated-state tables to prefilter, sort, and limit candidate paths before materializing the full rows, while memory and IndexedDB fallback targets preserve the same public contract with deterministic in-process evaluation.

The first consumer is Bases. Its renderer lowers only conservative conjunctive constraints into AppDatabase.queryIndexedMetadata() and still runs PEaQL in the renderer for final semantics. Today that pushdown is intentionally narrower than the full Bases query surface:

  • lowered from Bases: file.inFolder(...), file.hasTag(...), file.hasProperty(...), !hasProperty(...), file.ext = ..., scalar property comparisons (=, !=, >, >=, <, <=), supported file or property sorts, and limit when every active sort lowered.
  • not currently lowered from Bases: OR groups, custom filter strings, non-conjunctive or custom filter lines, file-field predicates outside the subset above, link predicates such as file.hasLink(...), formulas, group-by, view search, and custom view logic.

Unlowered clauses do not make the query invalid. They simply remain renderer-owned even when AppDatabase has already narrowed the candidate set.

Search Indexing and Retrieval

packages/plugins/plugin-search/src/search-manager.ts and AppDatabase remain the only approved search boundary, but the approved direction now extends that boundary from file-level full-text search into chunk-aware hybrid retrieval. The browser runtime continues to execute SQLite behind the dedicated app-database worker, and the same worker becomes the owner of any browser-local embedding runtime needed for vector search. The main thread and Svelte views do not talk to SQLite, sqlite-vec, or transformer inference directly.

Markdown link sidebars also consume the generated search-document boundary. Backlinks and Outgoing Links unlinked mention sections scan SearchDocumentRecord.content for exact markdown basename and frontmatter-alias matches, use sourceMetadata.frontmatterEndOffset to skip frontmatter, and exclude ranges already covered by metadata links or embeds. The sidebar runtime may read markdown vault files as a temporary fallback for files missing from listSearchDocuments() so the UI remains useful while the generated search index is disabled, rebuilding, or catching up; indexed records remain the preferred source when present.

Search Data Model

The approved target separates file-level search metadata from content-chunk retrieval state:

StoreRole
search_docsOne row per source file for path, title, extension, tags, selected metadata text, and file-level change tracking
search_ftsFTS5 index over file-level lexical fields such as path, title, tags, and selected metadata text
search_chunksDerived markdown-aware content chunks keyed by a stable chunk id, with source path, ordinal, offsets, section or heading context, and chunk text
sqlite-vec virtual tableChunk embeddings keyed by the same stable chunk id, plus enough metadata to verify model identity and vector dimension

File-level records remain the source of truth for path and metadata matches. Chunk rows are the source of truth for content snippets, offsets, and vector retrieval.

Worker Ownership and Runtime Model

  • Browser sessions continue to host SQLite inside the dedicated app-database worker.
  • The same worker owns browser-local embedding inference and model lifecycle for hybrid search.
  • The first real browser embedding runtime uses Transformers.js feature extraction with browser-managed model caching; model selection and remote-vs-local loading policy stay in provider configuration persisted through AppDatabase.
  • The main thread reaches search only through the existing AppDatabase bridge.
  • Secondary tabs continue to delegate through the coordinated proxy path instead of opening independent generated-state stores.
  • Search diagnostics such as document count, vector count, model readiness, live model download progress, embedding error state, and long-running indexing progress are part of the approved worker-facing search contract and feed the optional semantic-search status-bar popover.
  • AppDatabase now also exposes incremental search-index stats and explicit search-index batch boundaries so UI polling and long-running refresh flows can stay on cheap counters instead of rescanning the full mirrored corpus.

The current implementation now includes both the original provider seam and a real browser-hosted transformer path. Search storage and query plumbing accept a serializable embedding-provider configuration, and the browser worker can host a Transformers.js feature-extraction runtime that downloads and caches model files in the browser while keeping inference off the UI thread. Provider configuration now also starts background warmup so the runtime can surface live download progress before the first semantic query, normalizes the runtime’s remote host and remote path template when the worker bundle leaves them unset, only enables local model resolution when a local model path is explicitly configured, disables the browser Cache API path when the provider runs inside a native desktop renderer so wedged Chromium profile caches cannot stall runtime initialization, and surfaces a runtime message that distinguishes a model that was downloaded in the current session from one that was loaded from existing cache or local files. The deterministic token-hash provider remains available as the no-download fallback and test/runtime compatibility path while sqlite-vec is still pending.

This keeps hybrid search off the UI thread and preserves the existing multi-tab ownership model.

Indexing Behavior

  • Metadata cache change events remain the trigger for reactive search updates.
  • SearchManager writes file-level search documents plus the raw content and serializable metadata hints needed to rebuild derived chunk state through AppDatabase.
  • The app-database preparation path reuses CachedMetadata-derived headings, sections, and frontmatter positions to score candidate markdown boundaries, search backward from a target chunk size, and cut at the highest-value nearby breakpoint instead of blindly splitting on every parser section.
  • Tag hierarchy parsing remains part of file-level indexing so #parent/child continues to contribute searchable path-like tag levels.
  • Embeddings are generated locally and tracked by model id, vector dimension, and chunk fingerprint so unchanged chunks are not re-embedded unnecessarily. When the runtime cannot load or fetch a model, chunk embedding state records an error and lexical search remains available.
  • Search settings that affect chunking or embeddings now trigger a background vault-driven refresh path so the canonical markdown files are reread and re-indexed with the current provider and chunking settings.
  • The same vault-driven refresh path now deletes stale generated search documents whose paths are no longer present in the current vault file list, so repair passes can converge after missed delete events or earlier interrupted semantic refresh runs.
  • Search-manager refreshes now yield between stale-document deletes, per-file upserts, and late stale cleanup so desktop renderers can keep input and layout responsive while indexing progresses.
  • Metadata-cache-driven search updates are now debounced per source path before they reach AppDatabase, which bounds reactive indexing work during rapid edits and preserves the latest content snapshot for each note.
  • Search-view presentation settings such as result sorting and header-panel toggles are persisted in the search plugin settings so the sidebar restores the same layout and ordering across reopen and reload.
  • Reconfiguring the embedding provider no longer re-embeds every stored chunk inline inside the worker RPC. Existing chunk embeddings are rewritten to a pending state immediately, and the background refresh path performs the actual re-embedding work afterward so plugin startup and metadata persistence do not block on model warmup or downloads.
  • When a semantic provider is already configured at startup, chunks whose embedding metadata is missing or stale for the active model are treated as pending and the search plugin quietly schedules a retrying background backfill refresh once provider configuration completes so existing indexed notes converge on the active provider without waiting for a manual rebuild.
  • Vault-driven search refreshes tolerate temporarily missing metadata cache entries for markdown files by indexing them with empty source-metadata snapshots instead of skipping the file entirely; later metadata cache change events can enrich the stored search document with headings, sections, tags, and frontmatter once that data becomes available.
  • AppDatabase.rebuildSearchIndex() becomes the explicit repair path for lexical, chunk, and vector-derived state.
  • Delete and rename flows must keep file, chunk, and vector state aligned with the owning canonical note.

Search still depends on healthy file content and metadata, and all derived search state remains disposable generated state that must not pollute the user’s canonical vault contents.

Query Behavior

  • The shared packages/api search-query parser owns the concrete syntax tree and semantic AST for the Obsidian-style query language. AppDatabase.searchDocuments() evaluates plain literal conjunctions, phrases, OR, parentheses, negation, regex literals, field filters, value-bearing property queries, quoted property names such as ["note.status"], nested object and array property queries such as ["project.name"] or ["items.name"], property null checks, property comparisons, nested property subqueries, case-mode operators, line/block/section scopes, and task scopes through a shared evaluator. Exact top-level property names win first, so a literal note.status key is not treated as a nested note.status path unless no exact key exists. SQLite-backed sessions still use FTS and vector tables for simple candidate selection, but structured or case-sensitive lexical queries route through the same evaluator over the mirrored in-memory search document set so semantics stay consistent across database backends.
  • Lexical retrieval continues to use SQLite FTS for file-level fields and chunk-level content candidates.
  • SQLite-backed runtimes use vec-capable storage when available: browser OPFS sessions load the vec-capable SQLite WASM package, while Electron attempts to load a platform-compatible native sqlite-vec extension in the main process. Ready chunk embeddings are mirrored into the active SQLite vec table, and semantic candidate retrieval queries that table first; if vec lookup is unavailable or incompatible with the active embedding dimensions, the app-database layer falls back to direct chunk-vector scoring so the public search contract remains stable.
  • Memory and IndexedDB fallback app-database targets preserve the same public query contract with a deterministic non-SQLite fallback implementation.
  • Hybrid retrieval combines lexical and vector candidates with deterministic Reciprocal Rank Fusion over the shared lexical and vector score ranks. Results keep the raw lexical/vector scores plus the RRF ranks and per-signal contributions so callers can explain why a hybrid hit ranked where it did. SQLite-backed paths union semantic candidates with the lexical shortlist instead of dropping vector-only hits whenever FTS already returned some other file.
  • The search sidebar persists a view-level toggle for whether structured queries such as field filters, OR, negation, and comparisons are allowed to use semantic retrieval; when disabled, those queries are forced onto the lexical path while plain literal queries continue using the normal automatic retrieval selection.
  • The first rollout stops at lexical plus vector fusion. The desktop search contract now includes a serializable Electron-only local query-enhancement request plus diagnostics for unsupported-runtime, strong-signal, and provider-unavailable skips, but actual local LLM expansion and cross-encoder reranking remain blocked until the desktop runtime has a runnable local provider behind that contract. The feature continues to stay disabled by default, settings-gated, Electron-only, and hidden from the UI until that provider exists.
  • AppDatabase.searchDocuments() returns grouped file results with chunk-level snippets, offsets, section labels, backend provenance, retrieval mode, raw lexical/vector scores, and an explainable fused score breakdown so the search UI can render highlights, explain whether a hit was lexical or semantic, and navigate to matches without a second client-side search engine. Runtime state such as model download progress, embedding readiness, and background indexing progress is surfaced through the optional semantic-search status-bar popover, which continues polling while the embedding runtime reports downloading or embedding.
  • The search sidebar keeps the query controls in a fixed header above a separate scrolling results region, shows a total-results summary row, and applies user-selected client-side sort modes for filename and file timestamps after AppDatabase returns the ranked hits.

Approved Search Target

The approved target search layer is a SQLite-backed hybrid SearchIndex abstraction over file metadata, chunk-level content, and chunk embeddings. Browser OPFS sessions continue to execute that work behind the app-database worker, and the search UI consumes the returned snippets, ranges, provenance, and progress signals directly. Follow-on slices should build on that single subsystem rather than introducing a second search engine.

Plugin and Config Persistence

FilePurpose
/.obsidian/community-plugins.jsonEnabled community plugin IDs
/.obsidian/app.jsonMain app configuration defaults
/.obsidian/types.jsonMetadata type definitions
/.obsidian/plugins/{id}/data.jsonPer-plugin persistent data
/.obsidian/plugins/{id}/manifest.jsonPlugin manifest
/.obsidian/plugins/{id}/main.jsPlugin code
/.obsidian/plugins/{id}/styles.cssPlugin stylesheet (optional)

These files form the persisted control plane for workspace behavior.

Generated metadata, backlinks, app-derived indexes, and search state must stay out of .obsidian/ and out of user note folders. .obsidian/* remains canonical only for app/plugin compatibility files such as configuration, layout, plugin manifests/code/data, enablement lists, and types.json.

First-Party Source Resolution

All first-party monorepo development and validation flows must resolve local @lapis-notes/* package imports from source under each dependency’s src/ tree. Resolving those imports through package dist/ outputs during development is forbidden.

Rule

  • Apply this rule to every Vite-driven local flow in the repo, including app hosts, package-local vite dev sessions, Vitest or Vite-powered validation, and any other development-time module graph evaluation.
  • Apply the same source-first expectation to package-local TypeScript and Svelte diagnostics. Checks that do not load Vite must provide equivalent tsconfig path mappings for any first-party self-imports they validate.
  • Apply the same source-first expectation to other repo-local check tools that compile or typecheck first-party imports, including TypeDoc and package-local custom tsconfig files such as typedoc.tsconfig.json or tsconfig.check.json.
  • Resolve both root imports such as @lapis-notes/search and subpath imports such as @lapis-notes/api/icon or @lapis-notes/ui/button to source.
  • Apply the same rule to first-party stylesheet imports. CSS or SCSS @import specifiers such as @lapis-notes/ui/theme.css or plugin app.css entries must resolve into dependency src/ trees during local development instead of packaged dist/ CSS artifacts.
  • Apply the same rule to self-imports. A package importing its own published name during development must still resolve back into its own src/ tree instead of its packaged dist/ exports.
  • Do not add or preserve package-specific dist/ fallbacks in dev because a source file currently fails to compile. Fix the underlying source or tooling issue instead.
  • tsconfig.pack.json is for svelte-package prepack only. Do not point validation, TypeDoc, or other check tools at pack tsconfigs; those configs intentionally omit cross-package source paths.
  • Published package manifests may continue to export dist/ artifacts for packaging and external consumption, but repo-local development resolution must override those exports back to source.

Enforcement

  • The repo-root pnpm check:source-resolution command is the executable enforcement point for this contract.
  • That guard loads each package’s Vite config in development mode, resolves real first-party @lapis-notes/* imports from package source files and stylesheet @import sources, and fails if any resolution lands outside the dependency’s src/ tree.
  • The same guard resolves explicit subpath entrypoints (for example @lapis-notes/ui/table-dnd/sensors) through workspace, desktop-electron, and web app-host Vite configs using real cross-package importers, because those hosts keep separate dev resolver registries from package-local Vite configs.
  • The same command runs scripts/check-first-party-tsconfig-resolution.mjs, which resolves real first-party imports through each package’s Svelte/TypeScript tsconfig paths mappings without relying on packaged dist/ outputs. It also auto-discovers package.json export subpaths that wildcard paths patterns cannot reach and requires them to stay listed in firstPartySourceExplicitSubpathEntrypoints.
  • The same command runs scripts/check-first-party-vitest-resolution.mjs, which loads each package’s vitest.config or vite.config in test mode and resolves first-party imports from Vitest test files (including vi.mock("@lapis-notes/...") targets) to dependency src/ trees instead of packaged dist/ outputs.
  • The same command runs scripts/sync-first-party-source-tsconfig-paths.mjs --check so package tsconfig.json and tsconfig.app.json files keep first-party paths mappings in sync with the Vite source-resolution registry for Svelte and TypeScript diagnostics. When that sync script writes updates, it also runs Prettier on the touched tsconfig files so package check:format stays aligned without a separate manual pnpm fmt pass.
  • The same command validates plugin vite.styles.config.ts build configs that @reference workspace app.css can still resolve shared UI CSS entrypoints from source (for example @lapis-notes/ui/theme.css imported by the workspace stylesheet).
  • The same command rejects tsconfig.pack.json references outside prepack/build scripts so check and docs tooling cannot accidentally depend on packaged dist/ outputs.
  • Agents and contributors must treat failures from that guard as contract violations, not optional warnings.

Editor Rendering and Views

Views are the primary unit of content presentation. The architecture combines runtime view classes from @lapis-notes/api with Svelte components mounted by the workspace shell or plugins.

View System

View Hierarchy

Component (lifecycle base)
└── View (abstract: getViewType, getDisplayText)
    └── ItemView (toolbar actions, contentEl)
        └── FileView (file association, onLoadFile/onUnloadFile/onRename)
            ├── TextFileView (CodeMirror editor, save, data)
            │   └── MarkdownView (preview mode, live preview)
            └── EditableFileView (marker for editable views)

View Registration

Plugins register views during onload():

plugin.registerView(type, (leaf) => new MyView(leaf));
plugin.registerEditorView({
  id: "my.editor",
  viewType: type,
  label: "My editor",
  filenamePatterns: ["*.myext"],
});
plugin.registerExtensions([".ext"], type);

When a file is opened:

  1. workspace.determineViewTypeForPath(path) checks workspace.editorAssociations, an ordered record map from glob pattern to editor-view ID.
  2. Glob matching uses the maintained minimatch implementation with VS Code-style path separators and platform-aware case sensitivity.
  3. If no configured association resolves to a currently registered editor view, the workspace checks registered editor-view filename patterns.
  4. If no editor-view metadata matches, the legacy extension map remains the fallback; when multiple registered extensions match, the longest suffix wins, so .notebook.md beats .md.
  5. If no registered handler exists, the workspace may consult verified official registry contribution summaries and open an on-demand install prompt for a matching file handler.
  6. The view constructor is called with the target leaf.
  7. The view mounts its DOM into leaf.containerEl.
  8. For file-backed views, onLoadFile(file) is called.

EditorViewRegistry is the metadata registry for selectable file-backed editor views. It stores stable IDs, display labels, optional plugin ownership, view type, filename patterns, and priority. The constructor registry remains separate: an association can remain persisted even when a plugin is disabled or missing, but it only affects file opening when the associated editor metadata and view constructor are both currently registered. WorkspaceLeaf.openFile() activates a matched associated view type before creating the file view, so lazy code-backed views can be registered before construction.

The workspace registers compatibility metadata for registerExtensions() callers, and first-party file views register explicit editor-view metadata for Markdown, notebook, canvas, bases, PDF, media, and workspace-local code/text views. Users can override defaults through workspace.editorAssociations without breaking layout serialization because existing view type strings remain stable.

On-demand install prompts are a fallback only. Existing registered editor-view resolution wins when a plugin is already installed, bundled, or enabled. When the fallback runs, it uses the signed registry index contribution summaries already available to the distribution layer; it does not fetch plugin detail or release metadata merely to decide whether a prompt should be shown. The prompt installs only after user action and calls PluginDistributionManager.install() with requireOfficial: true and enable: true, then reopens the original file after the plugin registers its handler.

The workspace view header renders toolbar actions from the standard view.actions array shape instead of relying only on instanceof ItemView. First-party packages can be source-resolved independently during development, so structural action rendering avoids hiding valid actions when class identity differs across package boundaries.

For new Lapis-native extension surfaces, the preferred direction is declarative or provider-backed view ownership rather than arbitrary plugin DOM mounted into the shared renderer. Obsidian-compatible plugins still keep their compatibility path, but new view-oriented features should favor constrained contracts such as registered tree data providers, output channels, view badges, and isolated webviews.

Hybrid Rendering Pattern

The repo consistently uses a hybrid approach:

  • Runtime classes own lifecycle, state transitions, and file associations.
  • Svelte components own visible rendering and interaction affordances.

The bridge is leaf.svelte, which injects leaf.containerEl (the view’s native DOM) into the Svelte component tree via a Svelte action.

Shell-Level View Chrome

leaf.svelte

Attaches the runtime-generated containerEl to Svelte. Uses the createView action to inject the leaf’s DOM element. Re-renders on leaf.id change.

view-header.svelte

Tab header providing:

  • Back/forward navigation buttons (from leaf’s HistoryManager).
  • Breadcrumb trail (file path segments with hover reveal).
  • Inline file rename (editable title field).
  • Context menu: split down, split right, close.
  • Ellipsis menu button.

tabs-split.svelte

Recursive rendering of the workspace tree:

  • WorkspaceSplitResizable.PaneGroup (paneforge).
  • WorkspaceTabs → Tab container component (top, sidebar, or stacked).
  • Empty splits show an alert with drag hint.
  • Pane resize events trigger requestSaveLayout().

Empty View

View type "empty". Displayed when a leaf has no file. Centered UI with buttons:

  • “Create new note (⌘N)” — Invokes app:new-note, which creates and opens a unique Untitled*.md note at the vault root.
  • “Go to file (⌘O)”
  • “See recent files (⌘O)”
  • “Close”
  • If a leaf is asked to render an unknown view type, layout restoration and runtime fallback now switch to empty and preserve the missing view type in state.__missingViewType so the empty tab title shows that unresolved type. The centered empty-view surface also switches from the normal create/open actions to a dedicated unavailable-plugin message and close action.

Workspace Side Views

The workspace package also owns non-file side views that are registered through bundled core plugins rather than the markdown plugin.

Tags View

View type "tags". Mounted by TagsPlugin from packages/workspace/src/lib/views/tags.

  • Aggregates slash-delimited tag hierarchy counts from app.metadataCache.getAllItems().
  • Counts each tag path at most once per file so the displayed totals match tag-facet semantics instead of raw tag occurrences.
  • Renders as a sidebar browser with search, sort, nested-tree, and hierarchy-scoped expand/collapse-all toggles.
  • Refreshes from metadata cache changed, deleted, and loaded events, but scopes changed and deleted handling to files that currently contribute tags or still carry tags in the incoming cache payload.

Planned View Contributions

The backlog now tracks several VS Code-style view surfaces that extend the existing view system without replacing it:

  • tree views backed by provider APIs instead of plugin-owned custom DOM
  • view badges and view-scoped context keys for focused or active view state
  • output-channel views for user-visible plugin and service logs
  • isolated webview panels for rich UI that should not run directly in the shared renderer DOM

These planned surfaces should integrate with the existing workspace leaf, view, and sidebar registration model so layout restore, cleanup, and plugin ownership stay centralized.

Text Editing Pipeline

TextView

packages/workspace/src/lib/views/text/index.ts defines TextView as a TextFileView subclass. It:

  • Hosts a file-backed editor.
  • Mounts the shared NoteEditor component from @lapis-notes/api.
  • Reports display text and search support for the leaf.

Used by the LangCode plugin for JavaScript, JSON, YAML, CSS, and plain text files.

Editor Integration

The Editor class wraps CodeMirror 6:

  • view: EditorView — The CM6 editor instance.
  • getValue() / setValue(content) — Full document access.
  • replaceContent(content) — Efficient replace via CM6 dispatch.
  • save() — Debounced 500 ms write to vault.
  • updateExtensions(extensions?, context?) — Reconfigures CM6 extensions.
  • trackChanges(callback?) — Syncs external file changes → editor.

The editor pipeline now emits telemetry spans around setValue() and updateExtensions() so full CodeMirror state rebuilds are visible in file-open traces. These spans are intentionally aimed at infrequent lifecycle work rather than high-frequency text entry.

The shared NoteEditor renders its inline file-title element inside the editor scroll area immediately before the .cm-editor-content editor host. The normal appearence.interface.showInlineTitle setting controls that title on desktop, while mobile workspace display mode forces the same in-scroll title on so file-backed mobile pages can scroll the title away with the document instead of mounting separate shell chrome.

When the shared editor rebuilds extensions for an already-open document, it now seeds the new CodeMirror state from the live document text and current selection instead of the last debounced mirror, so opportunistic extension refreshes do not bounce the caret back to the top of the file.

When an editor surface is mounted into a secondary document, such as a browser or Electron popup host, the shared editor action rebases the EditorView onto that document root with EditorView.setRoot(node.getRootNode()) before appending the DOM. Popup-hosted CodeMirror instances therefore keep caret painting, focus, and IME behavior attached to the correct document instead of inheriting ambient root assumptions from the main window.

The shared editor configuration also owns Tab-based indentation. editor.behaviour.indentUsingTabs determines whether Tab and Shift-Tab indent with literal tab characters or spaces, while editor.behaviour.indentVisualWidth drives both the rendered tab width and the number of spaces inserted when tab characters are disabled.

The shared classHighlighter used by API-owned editors also emits compatible highlight.js token classes alongside the existing cm-* classes where there is a direct vocabulary match, so editor surfaces that already ship highlight.js themes can style CodeMirror tokens through either class family.

CM6 State Fields

State fields make app context accessible inside extensions:

FieldTypePurpose
editorViewFieldStateField<MarkdownFileInfo>{ app, file, editor }
editorEditorFieldStateField<EditorView>Editor instance
editorLivePreviewFieldStateField<boolean>Live preview mode flag

Language Service Adapters

Language-service UI is shared in @lapis-notes/api/editor/language-service. File-backed editors resolve App, TFile, and document text from editorViewField, then call app.languageServices for diagnostics, completions, hover, definition, and code actions. Diagnostics are rendered through the shared lapisCodeMirrorLint() / mapToLapisLintDiagnostic() layer: CodeMirror keeps diagnostic ranges and gutter markers, but its stock lint hover tooltip is disabled so a Lapis-owned view plugin mounts the lapis-lint-tooltip.svelte surface directly under .cm-lapis-tooltip without CodeMirror’s .cm-tooltip wrapper. The same plugin opens that tooltip from lint range hover plus lint gutter marker hover and click, with a short hide delay so users can move into the details. Markdownlint diagnostics that only report a first-column single-character range are displayed across the full source line to match line-level diagnostic behavior. Workspace-loaded codemirror-lint.css supplies lint range, marker, custom tooltip, panel, and inline-problem chrome, including lint-gutter alignment with the styled line-number gutter. The adapters do not import concrete markdownlint, TypeScript, Electron, or LSP providers.

That shared lint-tooltip path is document-aware. Tooltip mount, teardown, event listeners, and viewport measurements use view.dom.ownerDocument plus its defaultView rather than ambient document and window, so popup-hosted editors keep lint hover and tooltip positioning inside the correct renderer document. The same popup-document rule now also applies to search-panel focus, inline-problem widget DOM, and language-service hover DOM, so secondary-window editor chrome stays attached to the popup document instead of the parent renderer.

On Electron desktop, preload advertises language-service when the main process exposes the desktop_ls_* IPC family. Renderer bootstrap wires native LanguageServiceProvider registrations (runtime metadata "native", higher numeric priority) that forward serializable payloads through NativeDesktopBridge.invoke into a forked Node sidecar. Browser/PWA continues to rely on @lapis-notes/language-service worker providers; identical manager APIs keep adapters unchanged. LanguageServiceManager prefers the highest numeric priority when merging diagnostics/code actions across providers sharing a language-capability pairing, treats definitions as first-success descending priority after failures, and already short-circuits completions/hover on the earliest successful provider in that ordering.

Notebook cell editors and other direct EditorView surfaces use the languageServiceDocumentContext Facet to provide a serializable virtual document plus position/range mapping functions. This lets the TypeScript provider see a full virtual notebook document while CodeMirror diagnostics and hover map back to the focused cell.

Extension Registration

Extensions can be registered globally or per-view-type:

// Global — applies to all editors
app.registerEditorExtension(myExtension);

// Scoped — applies only to editors of a specific view type
plugin.registerEditorExtension(myExtension, "markdown");

workspace.updateOptions() re-applies extensions to all open editors when registrations change.

The shared editor stack still uses CodeMirror’s native line wrapping, but markdown-owned wrapping uses a fallback-plus-measurement contract for every indented .cm-line. List, plain-indent, and rich-editor decorations emit deterministic first-paint ch fallbacks through --hmd-indent-padding-fallback and --hmd-indent-prefix-fallback; list and quote fallbacks are derived from the full authored markdown prefix through the first content column, while plain continuation paragraphs inside list items use the parent item content column for padding and their own raw prefix for text-indent. When a visual unordered-list marker slot differs from the authored dash-space marker width, markdown line decorations add the marker-slot delta from --hmd-unordered-list-marker-slot-width to list fallbacks and anchored continuation padding instead of baking the value into per-mode CSS. A dedicated measuredIndentPlugin view plugin is registered after plainIndentPlugin() and refines visible lines by writing --hmd-indent-padding-measured and --hmd-indent-prefix-measured directly onto the visible line DOM; shared CSS prefers measured variables when present and keeps the fallback variables otherwise. For each visible .cm-line carrying data-indent-prefix, the plugin reads the raw prefix width via view.coordsAtPos or DOM range measurement, falls back to getBoundingClientRect() on rendered indent widgets when the prefix is hidden, combines quote/list marker widths by variant, and caches measurements by prefix plus variant/list context rather than line position. Top-level list items still emit data-indent-prefix="" so the same measured pass can hang wrapped rows under the rendered marker width even when there is no authored leading whitespace. Plain-indent continuation paragraphs inside list items also carry data-indent-anchor-line-from; measured padding is anchored to the parent list item’s first content glyph while the negative text-indent remains tied to the continuation line’s own prefix width. Measurements run inside view.requestMeasure({ read, write }), skip while the view is out of view, run a bounded startup retry loop so delayed line decorations receive measured variables, and remove stale measured variables from visible lines before applying the current measurements so recycled line DOM cannot carry old pixel widths. Geometry changes clear the measurement cache; document, viewport, and selection changes schedule a fresh measurement pass without dispatching a CodeMirror transaction. Shared CSS supplies only the base .cm-indent width from --list-indent plus guide styling. A companion shared view plugin still mirrors the computed line-height and padding-top from visible content rows onto visible gutter rows so line numbers and fold controls stay aligned even when headings or font-size changes give the content a different pixel height than the gutter font would imply on its own.

Indentation guides are painted at the line level via a repeating-linear-gradient background image on .indented-wrapped-line::before, sized by the per-line --indent-guide-count custom property the markdown decorations emit alongside data-indent-prefix. Lines whose guides derive from widened unordered-list marker slots may also emit --hmd-indent-guide-offset, normally resolved from --hmd-unordered-list-marker-guide-offset, so visible guide stripes remain centered on the bullet. This keeps the guides spanning the full height of wrapped continuation rows and surviving caret-on-prefix widget removal — the line-level pseudo-element is independent of the inline indent widgets, which now suppress their own legacy ::after guide pseudo-elements to avoid double-painting.

Markdown Specialization

The markdown plugin (packages/plugins/plugin-markdown) is the most substantial view module.

In addition to the leaf-backed MarkdownView, the markdown package now also exposes an EditableMarkdownPreview component for plugin UIs that need the same preview renderer plus the shared markdown CodeMirror editor outside a workspace leaf. The component supports both file-backed and value-backed modes: when given a file it reads and edits that file through the normal Editor lifecycle, and when given only a value it creates a local markdown editor configured through the same app.editorExtensions("markdown", { mode: "live-preview" }) path used by the main markdown editor. The package also exposes a read-only FileEmbed surface for plugin UIs that need the same file-embed rendering path used by markdown embeds without depending on markdown-renderer context.

EditableMarkdownPreview reuses MarkdownPreview for reading mode and the shared NoteEditor surface from @lapis-notes/api/editor for editing mode. A plain primary click on rendered markdown enters editing and, when possible, places the caret near the clicked source offset using preview data-offset metadata. Modifier clicks continue to preserve preview behavior, while explicitly interactive embedded controls opt out of the mode switch. Leaving the editor returns the component to preview mode on blur, but only after confirming focus actually left the editor surface so internal focus movement inside CodeMirror, inline-title chrome, or markdown widgets does not collapse the editor prematurely.

Dual-Mode Editing

MarkdownView extends TextFileView and supports three modes:

  • Source mode — Plain CodeMirror text editing.
  • Live preview — Rich editor with inline rendering (decorations for headings, links, etc.).
  • Reading/preview mode — HTML rendering via MarkdownPreviewView.

Notebook-owned .notebook.md notes now opt out of the shared readable-line-width centering in all three markdown-derived modes. The notebook view inherits the shared markdown editor and preview stack, while notebook-aware layout switches to the full-width notebook treatment based on file identity instead of post-load markdown patching.

When a markdown leaf switches between source mode, live preview, and reading mode, it now preserves the current per-leaf reading position by capturing a source-line anchor before the old surface unmounts and restoring against the newly mounted surface. Editor-mode restores use the leaf’s scroll-area viewport plus the current selection or caret neighborhood, while reading mode restores use the rendered DOM nodes annotated with data-line source metadata. This first pass is intentionally leaf-local: the anchor lives in leaf ephemeral state and is not yet serialized into saved workspace layout or leaf history.

Internal markdown link navigation now inherits the source leaf’s markdown mode when opening another note. Same-leaf opens reuse the current markdown view instead of constructing a fresh view with the default live-preview mode, and link/file-embed open paths pass the active markdown mode through App.openFile() / WorkspaceLeaf.openFile() so reading mode stays in reading mode, source stays in source, and live preview stays in live preview. Modifier-click opens and reveals of already-open target leaves apply the same inherited mode. Leaf history snapshots the live view state, including markdown mode, before each file navigation so the view-header back control restores the prior note in the same mode rather than a stale default.

Same-mode markdown file opens reuse the editor extension configuration established during onLoadFile() and only reconfigure the editor when the leaf’s markdown mode actually changes. This avoids a redundant second CodeMirror state rebuild during normal file switching.

The markdown view now emits spans for markdown.set_state and markdown.load, while TextFileView.onLoadFile() contributes nested view.text_file.on_load_file and vault.read spans. This keeps the hot path for file-open regressions attributable down to the view-mode transition, file read, and editor state rebuild layers.

In live preview edit mode, ATX headings (#) use heading decorations and may hide their hash markers when the cursor is elsewhere. Setext headings (=== / ---) remain semantically valid markdown, but their underline marker lines stay visible so they behave like plain text while editing.

Markdown editors now decorate the whole markdown link range with .cm-external-link for external destinations and .cm-internal-link for every non-external destination in both source mode and live preview. The authored display label inside markdown link syntax also gets .cm-link-text plus .cm-link-string, while the destination token gets .cm-link-target in addition to .cm-url or .cm-path, so label and target styling can be controlled independently without relying on generic .cm-link token selectors. Link-string styling remains non-underlined while raw path or URL destinations keep the link-decoration styling. On the active line in live preview the wrapper spans both the rendered label text and the hidden destination tokens, raw-space local path destinations still carry .cm-path on the destination token itself, and the external-link icon styling is attached only to the external URL token rather than the whole wrapped link range.

Non-external standard markdown links now share the same internal note-link rendering path as path links and wiki links outside the active live-preview line. Reading mode resolves them through the markdown-owned note-link component instead of leaving them as plain <a href="..."> anchors, and inactive live-preview lines replace the authored markdown link syntax with the same inline rendered note-link widget. External markdown links remain plain anchors in reading mode and stay on the authored-syntax path in live preview.

Bare external autolink URLs such as http://www.g.com also stay on the authored-syntax path in live preview. Inactive lines keep the autolink text visible with the same external-link decoration classes, while URL destination tokens inside [label](url) syntax continue to be hidden when a separate label is present.

When the shared note-link component cannot resolve an internal markdown or wiki-style destination to a vault file, it no longer opens the embed hover preview. Instead the rendered trigger stays visibly muted and exposes the unresolved path in a tooltip so reading mode and live-preview rendered widgets communicate the broken destination without implying a working inline preview.

Wiki links follow that same editor-decoration split while their authored syntax is visible. In source mode, and on the active line in live preview, the whole [[...]] range gets .cm-internal-link, the target portion before | is decorated with .cm-link-target, and the optional display label after | is decorated with .cm-link-text plus .cm-link-string. When a wiki-link line is not active in live preview, the authored [[...]] syntax is replaced with the same rendered internal note-link component used by reading preview instead of remaining visible as decorated source.

Standard markdown links whose destinations are local note or file paths containing raw spaces, such as [Example](My Note.md), are treated as a markdown-plugin extension rather than falling back to CommonMark’s URL parser. Reading mode resolves them through the same markdown-owned internal note-link component used for wikilinks, while CodeMirror source/live-preview keep the label on the normal .cm-link styling path and decorate the destination text with .cm-path instead of the shared .cm-url URL token class.

Bracket-only markdown links such as [My Note] are also treated as internal note links across reading mode, source mode, and live preview. Their label text doubles as the resolved internal link target in reading mode, and the editor continues to decorate that authored label with the markdown-owned internal-link classes used for other non-external markdown links.

Standard Markdown image syntax now follows that same inline-widget path in live preview: when the caret is elsewhere, ![alt](src) renders as an actual image, the rendered widget exposes the same code/edit affordance used by other rendered markdown widgets so a user can jump back to the underlying source range, and failed image loads fall back to an inline SVG placeholder that keeps the alt text and source path visible instead of leaving a browser-default broken-image badge.

Block-level markdown widgets in live preview, including YAML frontmatter, now mount through a compact block-widget shell rather than the inline widget wrapper or the full-page preview chrome. This keeps full-width rendered blocks such as the frontmatter property editor aligned to the markdown column without pulling in the page-level bottom padding used by standalone markdown previews.

Markdown editors render fold controls inline at the first non-whitespace column of foldable lines rather than relying on the shared fold gutter, so nested list folds sit beside the list item or indented block they control in both source mode and live preview.

Nested list prefixes in source mode and live preview use Obsidian-compatible indent wrappers and depth classes so wrapped lines and indentation guides align with the authored markdown prefix instead of a regex-derived fallback. Guide segments are positioned from ancestor list-marker columns so nested unordered items stay aligned with their parent markers, the active guide segment propagates to descendant lines when the cursor is inside the parent item, and plain indented non-list lines render the same markdown-owned indent guide segments and wrapped-indent styling instead of falling back to the generic editor-wide wrapper.

Source-mode list lines now also wrap the visible item content after the authored prefix in a .cm-list-n span, where n is the computed list depth. Ordered and unordered list markers now both keep the authored marker plus trailing markdown space inside their own formatting span instead of leaking into the content wrapper, and the list-formatting classes move off the whole line onto that marker span so DOM consumers can target marker and content separately with the same DOM shape for ol and ul items. In live preview, unordered-marker decorations also zero out the inherited marker padding on that span so the styled bullet glyph stays visually separate from the rendered label text instead of overlapping it.

The list decoration pass also tolerates transient collapsed marker spans that can occur while tab-indenting nested source lines, so marker styling does not crash the CodeMirror plugin or drop indentation-guide rendering mid-edit.

When indentation guides are enabled, plain indented source lines still reuse the live-preview list-line class shape so the shared fold widget and guide rendering stay aligned with the rendered prefix treatment, but they no longer emit a synthetic bullet marker. That plain-indent handling still starts from the first leading whitespace column rather than waiting for a two-space threshold, so any wrapped indented paragraph shifts the whole block immediately, and their guide units still paint on the line box so wrapped continuation rows keep the guide running for the full rendered paragraph height. When those plain-indent lines are continuation paragraphs inside a list item, they also carry the parent list line’s data-line-from as an anchor so the measured-indent pass can hang wrapped rows under the parent item’s text column instead of under the literal rendered width of the authored spaces.

Active-line editing across plain indents, nested lists, and blockquote-style prefixes inherits the same fallback-plus-measurement style as the non-editing state. The padding-inline-start and matching negative text-indent are refined by measuredIndentPlugin whenever the visible prefix changes (caret entering or leaving the prefix, document edits, viewport scrolling), but the cache is keyed by prefix plus rendering context so unchanged lines reuse their cached measurement. Revealing a raw prefix on the active line does not jitter — the line’s hanging indent stays put while the authored leading whitespace, list marker, or quote prefix becomes editable in place.

Markdown-owned indented editor lines now carry first-paint ch fallback variables for hanging-indent padding and prefix width. The measured-indent pass refines those same lines with measured pixel variables once layout is available, and editor CSS prefers measured values while retaining the fallback whenever a measurement is not yet present. This keeps opening, scrolling, and caret movement into raw list, continuation, or quote prefixes from briefly dropping wrapped rows back to the unindented column.

That plain-indent path now also stays active for structured-prefix lines whose leading whitespace is present but whose current parse pass has not yet produced a ListMark, which keeps nested guides stable while a user is in the middle of turning an over-indented line into a list item.

Reading mode and live-preview rendered markdown share the same list-item indentation rules under both .markdown and .markdown-rendered, so nested ordered lists stay visibly indented in preview instead of collapsing flush-left even when the parsed DOM already contains nested <ol> elements.

When editor.display.showIndentationGuides or editor.display.foldIndent is enabled, reading mode list items now resolve those flags from the shared editor.display configuration section, including schema defaults when the vault has no explicit persisted value, and emit the same markdown-owned guide wrappers, bullet hooks, and inline collapse controls as live preview so nested preview lists expose matching indentation markers and fold affordances instead of rendering as plain HTML list items. The reading-view CSS currently pins those affordances with explicit ch-based offsets so the collapse icon sits farther into the marker gutter while unordered bullets are nudged back toward the authored marker lane, the collapse control only becomes visible while the list item is hovered or focused, and the bullet hook keeps its own hoverable gutter hit area.

Live-preview blockquote widgets normalize quote-prefix indentation before reparsing isolated blockquote source and replace from the first rendered line start, so indented multi-line quotes do not leak literal quote markers or leave behind an empty indentation-only editor row before the widget. Reading mode, live-preview widgets, and editable blockquote source lines continue to use the shared markdown renderer blockquote padding, while rendered paragraphs keep the shared line-break behavior.

Quoted list lines in live preview emit both .cm-formatting-quote on the blockquote markers and list marker formatting spans on the checklist or bullet prefix. The list decoration pass only replaces structural whitespace before the quote markers with indent widgets; it must not replace or hide the > characters themselves. Those lines use the quote-list indent variant so measured-indent combines quote-prefix width with list-marker width for hanging-indent layout.

CodeMirror Extensions

In source mode, markdown table and grid-table lines now carry a shared cm-table line class in addition to the existing formatting classes, and the workspace CodeMirror styles pin those lines to the app monospace font stack so column alignment remains legible while editing raw markdown.

Source-mode fenced code blocks now also decorate each code-block line with HyperMD-codeblock HyperMD-codeblock-bg, matching the class shape expected by HyperMD-style selectors without relying on live-preview-only rich-editor decorations.

Markdown editors also decorate every YAML frontmatter source line with cm-hmd-frontmatter, including the opening and closing --- delimiter lines, so source-mode and editable live-preview frontmatter can share consistent block-level styling such as monospace typography.

When fenced code blocks appear inside markdown container directives such as notebook :::cell blocks, source mode now supplements CodeMirror’s normal syntax highlighting with a markdown-owned decoration pass for the fence markers, info string, and mounted inner-language tokens. This keeps directive-contained code blocks emitting the same cm-* token classes as ordinary fenced code in source mode.

When a notebook-style :::cell directive omits an inner fence but still carries a recognized code lang attribute such as ts, js, or sql, source mode applies that same markdown-owned code-block decoration pass to the directive body lines. This gives unfenced code cell bodies the same line treatment and cm-* token classes as fenced code while leaving non-code cells such as lang="md" on the normal markdown path.

The markdown plugin registers 14+ CodeMirror extensions:

ExtensionFunction
TableMarkdown table editing, Tab/Shift-Tab cell navigation; live-preview table chrome is hover-revealed on (hover: hover) and dimmed-but-visible on (hover: none)
HeadingHeading decorations with fold support and heading-aware gutter classes
ListList styling, task checkboxes
Grid TableComplex tables with cell spanning
WikiLink parser[[link]] syntax in Lezer
Tag parser#tag support
EmbedLink parser![[embed]] links
DirectivesMarkdown directive support
LaTeXMath rendering
PastePaste behavior
Input handlersCustom input
CompletionFile, header, suggestion autocomplete
Hash tags#tag chip decorations (cm-hashtag-begin / cm-hashtag-end)
Rich editorLive preview decorations and widgets

Source-mode #tag chips use split cm-hashtag-begin / cm-hashtag-end marks. When language-service linters add cm-lintRange marks on the same line, editor-shared.css keeps the pill visually connected across sibling and nested lint DOM shapes. Workspace Playwright coverage injects synthetic lint range geometries (not specific rule IDs) through __LAPIS_SYNTHETIC_LINT__ while __LAPIS_SUPPRESS_LANGUAGE_SERVICE_LINT__ is set in the browser test context.

Custom Lezer Language

Extends CodeMirror’s Markdown Lezer parser with custom node types: GridTable, Table, WikiLink, Tag, and container directives. YAML frontmatter support. Fold support for GridTable and ContainerDirective nodes.

Container directive parsing treats the standalone closing delimiter line as part of the enclosing ContainerDirective node range rather than a sibling trailing line. Live-preview block widgets depend on that full-range contract so replacing a directive-backed block also consumes the final ::: marker instead of leaving it visible in the editor surface.

Metadata Processing

Worker-backed (PromiseWorker) YAML processor extracts frontmatter, links, tags, headings, sections, list items, footnotes, and block IDs into CachedMetadata. Runs asynchronously on a Web Worker to avoid blocking the main thread.

Side Views

View TypePurpose
OutlineViewReal-time heading extraction, hierarchical collapsible tree that clears to an empty state when the active tab has no heading metadata and repopulates when metadata-cache updates arrive for the active file
FilePropertiesViewFrontmatter editor bound to active file
AllPropertiesViewProperty browser across all indexed files

Post-Processing Pipeline

Markdown rendered to HTML passes through sorted MarkdownPostProcessor functions:

  1. Each processor receives (el: HTMLElement, ctx: MarkdownPostProcessorContext).
  2. Context provides: docId, sourcePath, frontmatter, addChild(child), getSectionInfo(el).
  3. MarkdownRenderChild extends Component for lifecycle management of rendered elements.
  4. Processors sorted by sortOrder (default 0, higher = later).

Plugin-registered markdown post processors and code-block processors are wrapped in plugin-scoped telemetry spans, so expensive renderer hooks can be attributed back to the owning plugin instead of appearing as anonymous markdown work.

Markdown directives can also be upgraded before that post-processing pass through an app-level directive renderer registry. The markdown renderer still lowers generic directives to ordinary div or span elements with data-directive, but when the active app has registered a component for a directive name, that directive renders through the normal Svelte markdown component tree instead of waiting for a later DOM mutation pass. The same directive registry is used by both standalone reading-mode previews and live-preview markdown widgets, so directive-backed features do not need a separate post-processing implementation for live preview.

Bases Specialization

The bases plugin extends the view system for structured data:

  1. Opens .bases / .base files.
  2. QueryController executes PEaQL queries against BasesTable database context.
  3. TableView — TanStack Table grid with column sizing, sorting, filtering, cell editing.
  4. CardView — Grid layout with configurable card size, image handling, aspect ratio.

This is a layered application of the architecture:

  1. Runtime FileView class from @lapis-notes/api.
  2. Plugin-owned query engine and mode switching.
  3. Svelte UI mounted into the view container.

Media View

Registered by the markdown plugin for image extensions (.jpg, .jpeg, .png, .svg, .bmp, .gif). Scales content to fit. Separate from the text editing pipeline.

Canvas View

Registered by the canvas plugin for .canvas files.

  1. CanvasView is a FileView subclass rather than a TextFileView, because the canvas surface is a dedicated graphical editor rather than a CodeMirror-backed document view.
  2. The view reads the JSON Canvas file from the vault on file open and remounts a Svelte component into the leaf container.
  3. Initial SvelteFlow mount waits for JSON hydration, then the mounted flow waits for Xyflow node and viewport readiness and schedules a few delayed fitView() retries after tick() so restored page loads recenter once the pane geometry settles. That open-time auto-fit is clamped to maxZoom: 1, so small canvases do not start over-zoomed just because their content would otherwise fit at a larger scale.
  4. The mounted Svelte surface converts JSON Canvas documents into Svelte Flow nodes and edges, renders custom editable node components with transparent resize hit targets, inline markdown preview for text nodes through the shared EditableMarkdownPreview component, whole-file markdown file nodes through that same editable surface, and markdown subpath plus non-markdown file nodes through the markdown package’s read-only FileEmbed surface. Text nodes and editable whole-file markdown nodes use a two-step interaction model where the first click selects and a second click or toolbar action enters editing, blur-driven return from the embedded markdown editor back to preview mode, scrollable markdown previews that keep overflow contained to the node body on both axes and opt out of Svelte Flow wheel capture while hovered, with interactive markdown descendants also opting out of node drag capture and native browser dragging so slight pointer movement still triggers their own link or control action, and with the text-node body itself opting out of drag capture while the embedded markdown editor is active so text selection inside CodeMirror works normally, bezier-style connectors for a more curved path profile between nodes, solid arrowheads on directional connector ends, handle geometry that keeps connector endpoints flush with node borders even while the connection affordances stay visually hidden until hover, drag-time alignment guides that surface nearby left/center/right and top/center/bottom matches against neighboring nodes within a 4px threshold for more precise placement and add dot markers where those guides cross the dragged and matched node bounds, edge-specific hover connection affordances on hovered nodes, a selected-node action bar for remove, color, zoom, and future edit actions that clamps itself inside the visible canvas surface, plus group-node label focus and clear actions when relevant, a selected-edge action bar that exposes remove, color, zoom, and line-direction controls at the edge midpoint, a selected-edge interaction where clicking an already selected connector opens an inline label input with a connector-colored 3px border at the edge midpoint, edge labels that render only when they have non-empty text, node and edge action bars that counter-scale against the viewport zoom so they keep a stable on-screen size while the canvas itself zooms, top-right canvas controls that expose zoom in, reset zoom back to 1x, zoom out, fit view, and interactivity toggle actions, selected connectors that thicken to 4px and render a lighter glow behind the stroke, uniform node borders that thicken on selection while colored nodes keep their existing tint, JSON Canvas color controls, Obsidian-style tinted node surfaces, visible arrow markers on supported connector ends, basic file/link affordances whose open actions opt out of node drag and selection capture so slight pointer movement still triggers the intended target, lower-layer group styling, and resizer minimums that are smaller than the default node creation sizes so nodes can be shrunk below their initial 260x160 or 320x220 footprints, and serializes flow changes back into JSON Canvas.
  5. CanvasView debounces vault.modify(...) writes so drag, connect, and inline-edit operations persist without saving on every micro-change.
  6. The canvas surface clips node overflow to the pane body, so dragging nodes beyond the visible viewport does not let them paint over the leaf header or neighboring workspace chrome.
  7. Styling is injected by the plugin at load time so the bundled graph surface can ship its own Svelte Flow CSS, including the theme-aware dotted canvas overlay, without requiring separate workspace stylesheet imports.

Slides View

The slides plugin adds a dedicated file-backed presentation surface for Markdown notes.

  1. The active Markdown file can be reopened into SlidesViewType from the start of the Markdown pane menu, alongside the other view-mode actions, or via the slides:start-presentation command.
  2. The workspace opens the same file path in a different registered view type, so standard leaf history and layout persistence still apply.
  3. The slides view parses the Markdown document into horizontal and vertical slide sections using reveal.js separators and renders each section through the markdown package’s preview component rather than a separate renderer stack.
  4. Presentation rendering keeps the markdown preview pipeline lightweight by skipping code-block syntax highlighting inside slide sections, so opening and closing presentation mode does not leave normal markdown navigation competing with highlight work from the discarded deck.
  5. Presentation mode renders as a fixed overlay across the workspace shell, with a top-right close affordance and escape-key exit that return the current leaf to the Markdown editor’s live-preview surface through the normal file-open path.
  6. Speaker notes stay attached to the owning slide via inline notes: blocks or notes callout syntax.

Event System

The event system is the primary communication mechanism between runtime services, plugins, and UI components. Almost every class in the API package extends EventDispatcher.

EventDispatcher

EventDispatcher<EventTypes> (packages/api/src/lib/events.ts) wraps EventEmitter3:

Methods

MethodPurpose
on(eventName, listener, context?)Subscribe. Returns EventRef for manual unsubscription.
once(eventName, listener, context?)One-time subscription.
off(eventName, listener, context?, once?)Unsubscribe by function reference.
offref(ref: EventRef)Unsubscribe using ref.
trigger(eventName, ...args)Emit event.
emit(eventName, ...args)Alias for trigger.
dispatch(eventName, ...args)Alias for trigger.
tryTrigger(ref, ...args)Safe trigger that catches errors.

Type System

Events are fully typed via a generic map:

class MyService extends EventDispatcher<{
  "file-change": [file: TFile, event: string];
  "load": [];
}> {}

const ref = service.on("file-change", (file, event) => { ... });
service.offref(ref);  // Clean up

Lifecycle Integration

Component.registerEvent(ref) tracks event subscriptions and automatically unsubscribes them when the component is unloaded. This prevents memory leaks in views and plugins.

// In a Plugin or View:
this.registerEvent(
  app.vault.on("modify", (file) => { ... })
);
// Automatically cleaned up on unload

Event Catalog

Vault Events

EventArgumentsWhen
loadFile tree loaded from adapter
createfile: TAbstractFileFile or folder created
modifyfile: TAbstractFileFile content modified
deletefile: TAbstractFileFile or folder deleted
renamefile: TAbstractFile, oldPath: stringFile or folder renamed/moved
allevent: string, file, contextAny vault event (discriminated)

Workspace Events

EventArgumentsWhen
active-leaf-changeleaf: WorkspaceLeafActive leaf changed
file-changefile: TFile, event: stringExternal file modification detected
layout-changeevent: WorkspaceLayoutChangeEventA committed restorable layout mutation is persisted
layout-will-show-overlayevent: WorkspaceLayoutDropEventA renderer drop target is deciding whether to expose an overlay
layout-will-dropevent: WorkspaceLayoutDropEventA drag/drop layout mutation is about to commit; listeners may cancel
layout-did-dropevent: WorkspaceLayoutDropEventA drag/drop layout mutation committed successfully
layout-readyLayout fully loaded

Current workspace renderer coverage actively emits layout-will-show-overlay, layout-will-drop, layout-did-drop, layout-change, and layout-ready. Workspace also exposes typed layout-drag-start and layout-drag-end helpers for future drag backends, but the current HTML5 renderer path does not yet emit those start/end events consistently.

MetadataCache Events

EventArgumentsWhen
changedfile: TFile, data: string, cache: CachedMetadataFile indexed or re-indexed
deletedfile: TFile, prevCache: CachedMetadataFile removed from cache
loadedCache loaded from storage

Metadata cache events are intentionally low-level and per-file. Search indexing and other vault-wide derived-state owners may listen broadly, but view surfaces are expected to filter these events to the active file or directly affected reference neighborhood whenever they can do so safely.

PluginManager Events

EventArgumentsWhen
plugins-loadedAll plugins loaded from disk
plugin-loadedplugin: PluginSingle plugin loaded
plugin-enabledplugin: PluginPlugin enabled
plugin-disabledplugin: PluginPlugin disabled
plugin-errorerror: ErrorPlugin load/enable error
css-changePlugin CSS modified

CommandManager Events

EventArgumentsWhen
registercommand: CommandCommand registered
unregistercommand: CommandCommand unregistered

Configuration Events

EventArgumentsWhen
updated{key, value, prev}Configuration value changed

Plugin Events

EventArgumentsWhen
enablePlugin enabled
disablePlugin disabled

Editor Events

EventArgumentsWhen
changedata: stringDocument content modified

Event Flow Patterns

File Modification Flow

User edits in CodeMirror
  → Editor.save() (debounced 500ms)
    → vault.modify(file, data)
      → vault emits "modify"
        → watch-vault detects change
          → metadataCache processes file
            → metadataCache emits "changed"
              → searchManager.processChange() updates index
              → metadataTypeManager.processChange() updates types

Plugin Load Flow

PluginManager.loadPlugins()
  → reads /.obsidian/plugins/
    → for each: loadPlugin(path)
      → reads manifest.json
      → dynamicImport(main.js)
      → emits "plugin-loaded"
  → reads community-plugins.json
    → for each enabled: enablePlugin(id)
      → plugin.enable() → plugin.onload()
      → loads styles.css → appends <style>
      → emits "plugin-enabled"
  → emits "plugins-loaded"

Plugin System

The plugin runtime preserves Obsidian’s on-disk plugin format while hardening boot and failure handling. Community plugins continue to live under /.obsidian/plugins/<id>/ with manifest.json, main.js, and optional styles.css; robustness comes from manager policy, not from inventing a new bundle format.

Sources

All plugins are represented by PluginManager, regardless of source.

SourceRegistrationDefault activation
coreRegistered by the workspace shell before plugin discoveryActivated during boot unless listed in core-plugins.json; plugins registered with enabledByDefault: false stay off until explicitly enabled; required bundled plugins always activate
officialDiscovered from /.obsidian/plugins with verified official installed stateActivated if listed in community-plugins.json and optional-core Safe Mode is not active; grouped with core plugins in settings
communityDiscovered from /.obsidian/plugins without verified official provenanceActivated if listed in community-plugins.json and community-plugin Safe Mode is not active

The manager keeps runtime metadata alongside each plugin instance:

  • source (core, official, community, or system)
  • provenance (bundled, official, community, or manual)
  • base path
  • runtime state
  • last activation error

External plugins keep the Obsidian-style folder contract under /.obsidian/plugins/<id>/, but compatibility loadData() and saveData() now use the app configuration service as the canonical store. Opaque plugin payloads live under app.json pluginData, and the runtime mirrors that canonical state back into plugins/<id>/data.json for official/community plugins and /.obsidian/<id>.json for bundled/system plugins. When both copies exist, app.json wins; the legacy file is used only to backfill missing canonical data during migration.

Bundled plugin folders are therefore created lazily only when a bundled plugin ships non-CSS assets that are actually present. Missing bundled plugin folders must be treated as “no bundled plugin assets yet”, not as an enable failure.

This shared manager contract matters because bundled plugins must obey the same reversible lifecycle as community plugins.

The current runtime also exposes two narrow compatibility surfaces for plugin interop without adopting the full upstream internal plugin API: Plugin.registerBasesView() contributes external Bases views through a manager-owned registry that is cleaned up on plugin unload, and app.internalPlugins.getEnabledPluginById(id) resolves only enabled bundled plugins for compatibility checks.

CSS Ownership

Plugin-owned presentation should stay with the owning plugin package rather than leaking into workspace-global styles. The two-artifact first-party package contract and migration tracker are documented in Plugin CSS Contract.

  • community plugins may continue to ship optional styles.css beside main.js
  • bundled plugins may provide package-owned CSS during core-plugin registration; PluginManager injects that CSS through the same plugin-css-<id> lifecycle element it uses for community plugin styles
  • first-party packages that ship Tailwind-backed UI should expose workspace app.css and standalone styles.css artifacts; workspace core-plugin registration consumes app.css, while standalone/native plugin installs use styles.css
  • new plugin-specific selectors should use the plugin id as the namespace prefix, for example notebook-... for plugin-notebook
  • plugin-specific variables should use the plugin namespace prefix and default to core theme variables when possible
  • shared UI components may still use Tailwind internally, but plugin-specific wrapper classes should provide the stable override surface for plugin chrome
  • bundled plugin CSS must be reversible with plugin enable/disable; global workspace stylesheet imports must not be the only way first-party plugin chrome reaches the renderer

Lifecycle

A plugin moves through these runtime states:

  1. disabled — discovered or registered but inactive.
  2. enablingonload() is running.
  3. enabled — plugin registrations are live.
  4. failed — load or enable failed after rollback.

Activation and deactivation are transactional:

  • enable() awaits plugin startup before the plugin is considered enabled.
  • If onload() throws, registered disposers run immediately and the plugin enters failed.
  • disable() removes commands, views, editor extensions, post processors, event handlers, and DOM nodes registered through the plugin helper methods.

The runtime now traces plugin discovery, enable, disable, and failure reporting through app.telemetry, so plugin overhead and boot-time failures can be attributed per plugin ID, source, and provenance.

Boot Contract

Boot treats plugins as one runtime phase with internal substeps:

  1. The workspace registers bundled plugin constructors with PluginManager.
  2. The dependency bag is registered for Obsidian-compatible require() calls.
  3. PluginManager reads /.obsidian/installed-plugins.json and discovers external plugin manifests from disk.
  4. Manifest preflight runs for every plugin before evaluation.
  5. Activation is awaited for all required bundled plugins, all bundled plugins that resolve to enabled after applying core-plugins.json plus any default-disabled bundled-plugin overrides, configured official plugins whose installed record has official provenance, and configured community plugins.
  6. Only after activation completes does the workspace restore layout.

The current implementation records plugins.load_all, plugin.load, plugin.enable, and plugin.disable spans around these phases. Slow plugin spans inherit the same workspace snapshot enrichment as other slow app-owned spans.

The telemetry diagnostics surface itself is delivered by the external official @lapis-notes/telemetry package with manifest ID lapis-telemetry after install; the workspace shell still owns the base browser telemetry controller.

This preserves the ordering requirement from the boot sequence: views must exist before layout restoration.

Manifest Preflight

Before evaluation or activation, the manager validates:

  • manifest.json parses successfully.
  • Plugin IDs are unique across bundled and community plugins.
  • minAppVersion is compatible with the running app.
  • Required entry files exist, especially main.js or the selected Lapis runtime entry for external plugins.
  • Platform constraints such as isDesktopOnly are compatible with the host.

Preflight failures mark the plugin as failed, emit plugin-error, and keep boot moving.

Failure Containment

No single plugin failure should prevent the shell from rendering.

  • Bundled plugin failures degrade only that feature; startup continues.
  • Official and community plugin failures leave the plugin in failed state and surface diagnostics.
  • External plugin module-evaluation failures must preserve the original exception message in surfaced diagnostics rather than being rewritten into a generic invalid-plugin-class error.
  • plugins-loaded is emitted only after activation attempts finish.
  • Layout restore must tolerate missing view types by leaving the leaf unopened and surfacing a notice rather than aborting the boot process.

Runtime failure state is in-memory only. community-plugins.json remains the desired external enablement list, including official plugins for Obsidian-compatible persistence, not a cache of last-known-good runtime state.

The one exception is stale external plugin entries whose manifest.json is gone. Boot prunes those IDs from community-plugins.json, ignores leftover asset or data folders that no longer contain a manifest, and surfaces a user notice that the missing plugin was removed from the enabled list.

plugin-error emissions also generate telemetry events carrying the plugin ID and failure message so exporter-side dashboards can track failure rates without scraping console output.

Bundled plugin state is persisted separately in core-plugins.json. Default-enabled bundled plugins continue to use the legacy disabled-ID list shape. When a bundled plugin is registered with enabledByDefault: false, explicit user enablement is persisted as an enabled override in the same file so the core plugin settings UI can round-trip that choice across restarts. Missing or failed bundled plugins do not rewrite that file unless the user changes enablement from the settings UI. Installed official plugins appear in the same Core plugins settings group, but their enable/disable state remains in community-plugins.json; optional-core Safe Mode disables their activation while community-plugin Safe Mode applies only to source community.

Host Model

The runtime still loads Obsidian-style CommonJS plugin bundles so the same vault layout works in browser and native hosts. Community plugin evaluation is routed through a CommunityPluginExecutionHost; the default renderer CommonJS host still performs in-process CommonJS evaluation, but bare dependency resolution now goes through a PluginDependencyResolver instead of reaching directly into the workspace dependency bag. The workspace supplies a WorkspaceCommonJsDependencyResolver backed by generated host-module metadata and generated CommonJS provider values; deps.ts is limited to explicit legacy/private compatibility overrides and browser fallbacks that are not public host modules. When the active host provides a PluginAssetServer, PluginManager wraps the configured CommonJS or sidecar host with a hybrid renderer host that imports structured ESM entries from verified module URLs and delegates CommonJS and Electron sidecar entries to the existing host. Stronger isolation boundaries, such as separate renderer or process sandboxes, are host concerns layered on top of this contract; the browser runtime guarantees exception containment and rollback, not CPU preemption. The approved community-plugin host boundary for browser fallback and Electron sidecar execution is documented in Community Plugin Host Boundary.

Runtime entry selection is centralized in the API package. Lapis manifests may keep legacy lapis.runtime.workspace, desktop, and trustedDesktop string entries, and may also declare structured lapis.runtime.entries descriptors with path, format, optional fallbackPath, shared dependency declarations, and reload requirements. The selector prefers structured entries, then legacy Lapis runtime entries, then Obsidian-compatible main.js fallback when present. Diagnostics record the selected host, entry path, module format, fallback path, whether fallback was used, reload-on-update policy, declared shared dependencies, used shared dependencies, undeclared shared dependencies, missing shared dependencies, deprecated shared dependencies, private shared dependencies, and the host asset URL mode plus URL used for renderer ESM imports.

Community plugin authors should treat structured renderer ESM as the preferred Lapis v1 entry format while retaining main.js only when Obsidian compatibility or an explicit hybrid fallback is required:

{
  "id": "example-plugin",
  "main": "main.js",
  "lapis": {
    "runtime": {
      "entries": {
        "workspace": {
          "path": "main.mjs",
          "format": "esm",
          "fallbackPath": "main.js",
          "sharedDependencies": ["@lapis-notes/api", "obsidian"],
          "requiresReloadOnUpdate": false
        }
      }
    }
  }
}

sharedDependencies are host-module declarations, not a general package manager. They must list only public modules from the generated host-module catalogue that the app is allowed to provide at runtime. Normal npm dependencies, private Lapis internals, Svelte internals, local source files, and plugin-owned helper libraries should be bundled into the plugin artifact. The dependency scanner reports undeclared, missing, deprecated, and private host module usage so authors can remove unstable APIs before those warnings become hard failures.

The shared host-module catalogue is generated from scripts/plugin-host-modules.config.json. It produces renderer metadata, CommonJS provider metadata, generated CommonJS provider values, shared externals, registry-validation metadata, Electron sidecar host metadata, wrapper modules under packages/workspace/src/lib/plugin-host/, and an import map for public renderer host modules. The catalogue is an allowlist; package exports are expanded only for explicitly configured packages, and generated metadata carries platform, public/private, deprecated, replacement, and reason fields where applicable.

Electron sidecar CommonJS support deliberately uses a smaller catalogue slice than the renderer. Sidecar bundles may share only lapis and @lapis-notes/api; the Obsidian renderer compatibility facade, Svelte, DOM, CodeMirror view, and @lapis-notes/ui remain renderer-only. Sidecar v1 does not load local CommonJS module graphs from the vault. Relative, parent-relative, or absolute require() calls in sidecar code fail with a bundle-required diagnostic, so sidecar plugins must publish one CommonJS bundle for the selected entry until a future preloaded graph loader or Node ESM sidecar path is implemented.

Renderer host import maps are now injected at Vite HTML-transform time for the workspace dev shell, web/PWA host, and Electron renderer host. The import map is app-owned and maps public host specifiers such as obsidian, selected @lapis-notes/api/* exports, CodeMirror packages, Svelte, @dnd-kit/* packages used by shared table drag-and-drop, and selected utility packages to generated wrapper module URLs under /__lapis/host/ in dev and emitted host-wrapper assets in production. Plugin-defined import maps remain unsupported.

The API package also defines host-neutral plugin asset URL helpers and installed-asset verification primitives for the ESM module URL path. Those helpers cover the web route /__lapis/plugins/..., the Electron renderer lapis-plugin://... URL shape, path safety, supported module asset types, MIME types, size checks, URL hash checks, and SHA-256 verification against installed plugin metadata. The Web/PWA host mirrors verified installed plugin files into Cache Storage and the generated service worker serves only those version/hash /__lapis/plugins/... cache entries. The Electron host registers lapis-plugin as a secure fetch-capable custom protocol, accepts renderer IPC registration for active installed plugin asset contexts, and verifies plugin ID, version, URL hash, path, size, and SHA-256 before serving a file from the selected vault. The renderer ESM execution host prepares declared and scanned dependencies from the selected source file, imports the version/hash asset URL with bundler resolution disabled, and falls back to CommonJS only when the selected structured ESM descriptor explicitly declares fallbackPath; otherwise an ESM import failure is recorded as a plugin load failure.

Renderer ESM asset URLs include the installed plugin version and verified file hash. Web/PWA hosts therefore expose module entries under /__lapis/plugins/<vault-id>/<plugin-id>/<version>/<sha256>/<path>, while Electron renderer hosts use the equivalent lapis-plugin://<vault-id>/<plugin-id>/<version>/<sha256>/<path> custom-protocol URL. The hash segment is the SHA-256 recorded for the requested file in .obsidian/installed-plugins.json, and hosts reject mismatched URL hashes before serving the asset. The URL shape intentionally changes when installed bytes change, but browser module records can still live for the lifetime of the current document. Plugin authors should set requiresReloadOnUpdate when an update cannot be made safe by disable/enable alone and the app should tell the user a reload is required after the new version is installed.

Settings diagnostics expose this loader state in the plugin Features panel. Runtime badges summarize ESM, CommonJS compatibility, Hybrid, Sidecar, Fallback used, Missing dependency, Deprecated host API, and Reload required. The detail rows include selected host, module format, fallback entry, fallback-used status, reload policy, asset URL mode, version/hash URL, declared and scanned shared dependencies, undeclared dependencies, missing dependencies, deprecated public APIs, and private host APIs.

Official external Lapis plugins are ESM-only. Their release artifacts must include a structured workspace entry that points at main.mjs, must not declare fallbackPath, must not ship a stale main.js compatibility artifact, and must bundle UI subpaths, Svelte internals, and other normal npm dependencies instead of treating them as host modules. The shared official Vite build helper externalizes only generated public host modules and fails when unsupported bare imports remain.

Official distribution validates this runtime metadata before a verified install is staged. Signed release manifests mirror structured lapis.runtime.entries metadata from the packaged manifest.json; the installer rejects official releases when the signed metadata is missing or divergent, when the workspace entry is not ESM-only, when entry files are absent, when module formats do not match their file extensions, or when scanned bare imports are undeclared or unsupported by the generated host-module catalogue for the selected renderer or sidecar platform. Manual and community compatibility paths can still use CommonJS or explicit fallback metadata and can surface the same diagnostics as warnings instead of treating them as official-release failures.

The current platform direction distinguishes two plugin styles:

  • Obsidian-compatible plugins continue to use the trusted DOM compatibility path so existing plugins can keep working against the renderer-owned API surface
  • new Lapis extensions should prefer manifest-installed contributions, lazy activation, brokered capabilities, and isolated UI surfaces where the host can enforce clearer ownership

The compatibility path is therefore preserved, but new extension features should not assume arbitrary renderer DOM access by default.

Approved Direction

The approved target architecture is captured in ADR-001: Plugin Runtime Rework. That ADR does not change the current implementation described above; it defines the later host split that the codebase is expected to move toward.

The current-state summary for that rework now lives under Plugin System, and the forward-looking contract lives in Plugin System Design, including manifest classification, declarative contributions, scoped service providers, Electron host phases, permissions, install layout, extension state, and system-extension boundaries.

  • web/PWA keeps a renderer-hosted community-plugin path with capability gating and structured unsupported-API diagnostics
  • Electron desktop introduces a stronger host boundary for community plugins while preserving the same Obsidian-compatible disk layout
  • bundled plugins remain renderer-local in the first rollout of that rework

Any runtime or capability manifest declarations described in the ADR are planned extensions, not part of the current manifest contract documented on this page.

The Plugin base class now also exposes plugin-scoped telemetry helpers and automatically wraps plugin-owned view creators plus registered markdown post processors and code-block processors. This gives first-party and community plugins a consistent tracing surface without requiring each plugin to bootstrap its own telemetry client.

Plugin.registerObsidianProtocolHandler(action, handler) is backed by AppUrlService. Handlers receive the Obsidian-compatible decoded parameter map including action, are invoked for matching Lapis app URL actions, and are removed through the normal plugin unload lifecycle. Lapis does not claim the global obsidian:// OS scheme; the compatibility surface is for in-app dispatch and plugins that expect Obsidian-style protocol handler semantics.

The next plugin-runtime slices tracked in the backlog build on this foundation instead of replacing it: output channels for user-visible logging, quick pick and input prompt APIs owned by the renderer, Workspace Trust-aware capability gates, and broader host-kind support such as browser workers.

Plugin System

This page is the single current-state overview for the plugin-system work. The normative runtime contract still lives in Plugin Runtime and Community Plugin Host Boundary; this page summarizes what is implemented now and points to the consolidated design specification for the remaining model.

What Exists Today

Lapis still uses the Obsidian community plugin folder layout under /.obsidian/plugins/<id>/ with manifest.json, main.js, optional styles.css, and compatibility data.json. PluginManager in @lapis-notes/api remains the only lifecycle owner for community plugins: it discovers plugins, runs manifest preflight, chooses an execution host, evaluates code, manages activation and rollback, tracks diagnostics, and keeps the existing boot rule that layout restore happens only after plugin activation attempts finish.

The runtime now supports three plugin classes:

  • Obsidian-compatible plugins, which continue to use main.js.
  • Lapis extensions, which may be manifest-only or may load host-specific runtime entries.
  • Hybrid plugins, which keep main.js compatibility while also declaring Lapis metadata.

Before code runs, Lapis validates and indexes the optional lapis namespace. That index now supports declarative commands and configuration without code evaluation, activation-event driven lazy activation for code-backed Lapis extensions, scoped service registration for language-service providers, and app-owned generated extension state stored in AppDatabase metadata rather than under shipped plugin folders. App-shipped system extensions can now also be registered through PluginManager.registerSystemExtensions(...), which supports manifest-only registrations, renderer code-backed system plugins, declarative services bound to app-owned implementations, and explicit privilege metadata while keeping those extensions in the core-plugin settings surface instead of the community plugin list. Plugin settings diagnostics also now surface plugin class, selected host mode, activation mode, last activation trigger, selected runtime entry when known, requested and granted capabilities, requested and granted app permissions, explicit system-extension privileges, unsupported indexed contributions, and the last failure message.

Runtime Model

Community plugin evaluation goes through CommunityPluginExecutionHost.

  • The renderer host remains the compatibility fallback and still evaluates CommonJS bundles in process.
  • Electron desktop now has a brokered sidecar path for trusted desktop community plugins, with one trusted child process per plugin ID and vault/window context.
  • PluginManager selects lapis.runtime.workspace for renderer-hosted Lapis code and lapis.runtime.trustedDesktop or lapis.runtime.desktop for sidecar-hosted Lapis code.
  • Obsidian-compatible and hybrid plugins still resolve code through main.js.

Runtime-aware manifest fields such as supportedRuntimes, requiredCapabilities, and executionHints participate in preflight when declared. Lapis runtime-entry selection is also enforced in preflight: selected entries must stay inside the plugin root and currently must end in .js, .cjs, or .mjs.

Desktop Host State

Electron is the active desktop shell. The renderer runs with contextIsolation: true, nodeIntegration: false, and sandbox: false. Preload exposes typed native capability access. Electron main owns the plugin sidecar lifecycle and supports prepare, evaluate, activate, deactivate, and shutdown for trusted desktop community plugins, with restart budgets and cooldown failures isolated per hosted plugin.

This is a stronger boundary than renderer execution, but it is not a sandbox guarantee. The current trusted desktop host is still a normal Node process with brokered app capabilities layered on top, not a fully isolated per-plugin security boundary.

Permissions And Trust

Permissions currently map to brokered capabilities such as vault I/O, plugin data, commands, notices, settings, metadata queries, events, and logging. Permission and trust decisions happen during preflight and are shown in the workspace plugin settings UI.

Important constraint: app permissions only govern brokered APIs. They do not restrict arbitrary Node access once a trusted desktop extension is allowed to run in the sidecar. The docs and UI should keep describing trusted desktop execution as trusted, not sandboxed.

What Is Still Missing

Several planned pieces remain intentionally incomplete:

  • richer manifest-only contribution coverage beyond the currently installed command and configuration contributions
  • browser-worker runtime support
  • multi-host hybrid activation beyond the current main.js compatibility path
  • broader benchmark and regression coverage for the plugin/runtime matrix

The remaining plugin-runtime work is centered on richer manifest-only contribution models plus follow-on runtime expansion such as browserWorker and multi-host hybrid activation.

Current Phase Snapshot

The plugin-system phases are no longer mostly speculative.

  1. Done: manifest classification and contribution indexing.
  2. Done: manifest-only commands and configuration. Declarative settings widget coverage, VS Code parity gaps, and the plugin-test e2e-vault matrix are documented in Configuration and Settings.
  3. Done: scoped service registration, starting with language services.
  4. Done: Electron sidecar evaluation and lifecycle for trusted desktop community plugins.
  5. Done: permission and trust diagnostics in the settings UI.
  6. Done: bundled system extensions with manifest-only contributions, renderer code-backed registrations, service bindings, and explicit privilege diagnostics.
  7. Done for the current desktop host scope: per-plugin Electron sidecars, restart budgets, and trust/isolation diagnostics. Remaining runtime-model work is limited to the follow-on items listed above.

Design Specification

The forward-looking design now lives in one document instead of a cluster of planning notes: Plugin System Design.

Plugin System Design

This document defines the intended plugin-system design layered on top of the current Plugin Runtime and Community Plugin Host Boundary contracts. It is written as a spec for the target model, while noting where the current implementation already conforms and where later phases still remain.

Scope

The plugin system preserves the Obsidian-compatible community-plugin disk layout while adding Lapis-specific manifest metadata, contribution indexing, host selection, and capability-aware execution. PluginManager remains the lifecycle authority for discovery, preflight, activation, deactivation, rollback, diagnostics, and boot ordering.

The design covers:

  • manifest shape and classification
  • contribution indexing and activation
  • service-provider registration
  • runtime-host selection and runtime entries
  • permissions and trust language
  • install layout and extension state
  • system extensions and isolation boundaries

Design Principles

  • Preserve baseline Obsidian community-plugin compatibility.
  • Keep one lifecycle owner for discovery, preflight, diagnostics, and cleanup.
  • Prefer declarative contributions over eager code execution.
  • Treat host permissions as enforceable only where access is brokered or otherwise restricted.
  • Keep host and contribution models stable as desktop isolation improves.

The UI and host split is intentional:

  • existing Obsidian-compatible community plugins continue to run through the trusted DOM compatibility host so the on-disk plugin format and runtime model remain usable
  • new Lapis extensions should prefer manifest declarations, lazy activation, commands, context keys, brokered capabilities, and isolated UI or runtime hosts where possible instead of arbitrary renderer DOM mutation

This means compatibility APIs remain important, but they are not the design target for new extension surfaces.

Manifest Model

The baseline Obsidian manifest remains the compatibility contract. Lapis-specific metadata lives under an optional lapis namespace so upstream fields do not need to move or change meaning.

{
  "id": "example",
  "name": "Example",
  "author": "Author",
  "version": "1.0.0",
  "minAppVersion": "1.12.3",
  "description": "A Lapis extension",
  "lapis": {
    "manifestVersion": 1,
    "extensionKind": ["workspace", "trustedDesktop"],
    "contributes": {
      "commands": [],
      "configuration": [],
      "languages": [],
      "editorViews": [],
      "views": [],
      "services": []
    },
    "activationEvents": [],
    "runtime": {
      "workspace": "main.js",
      "desktop": "desktop.js"
    },
    "permissions": []
  }
}

The lapis namespace defines contribution declarations, activation events, runtime entries, and permission requests. Missing lapis metadata means the plugin is treated as an Obsidian-compatible community plugin.

Classification

Each discovered plugin is classified before runtime preflight decides which files are required.

ClassDefinitionRequired entryIntended behavior
Obsidian-compatibleBaseline manifest with no lapis namespacemain.jsPreserve current behavior
Lapis extensionManifest uses only Lapis contribution and runtime metadataSelected Lapis runtime entry, if anyAllow declarative and host-specific extensions
HybridBaseline Obsidian plugin plus lapis metadatamain.js plus any selected Lapis runtime entryAllow gradual adoption of the Lapis model

Classification rules are:

  1. If lapis is absent, classify as Obsidian-compatible.
  2. If lapis is present and main.js is still required for compatibility behavior, classify as hybrid.
  3. If lapis is present and the plugin can be expressed as declarative metadata plus optional Lapis runtime entries, classify as a Lapis extension.
  4. If classification is ambiguous, preflight fails instead of guessing.

Current implementation status: manifest classification is implemented.

Contribution Model

PluginManager builds a contribution index from manifests before evaluating plugin code. The index is the canonical source for diagnostics, settings visibility, manifest-only installation, and later lazy activation.

Indexed contributions may include:

  • commands
  • configuration schemas
  • status bar items
  • menus and keybindings
  • language IDs and file extensions
  • language-service providers
  • workspace and sidebar views
  • tree views and view-scoped badges
  • webviews and prompt surfaces
  • markdown processors and code-block processors
  • notebook renderers and kernels
  • service-provider declarations

Each indexed contribution record carries plugin ID, contribution type, stable contribution ID, manifest source path, runtime host requirement, permission requirements, activation requirement when code is needed, and validation diagnostics.

Built-in contribution kinds are now defined through contribution-point descriptors instead of being expanded inline in one hardcoded index builder. A descriptor is responsible for validating the contribution shape, producing indexed entries, declaring whether the contribution is manifest-installable, and supplying default activation metadata when the contribution kind supports lazy activation. Unknown contribution keys and malformed contribution records remain in diagnostics as invalid indexed entries so plugin settings can explain what failed without silently dropping manifest data.

Declarative contributions now also share an API-owned context-key service. App.contextKeys is the single source of truth for built-in state such as view.id, view.focused, editor.active, editor.language, editor.hasSelection, workspace.trusted, runtime.host, runtime.desktop, runtime.browser, runtime.nativeHost, plugin.state.<pluginId>, and plugin.enabled.<pluginId>. Plugins can register scoped custom keys under plugin.<pluginId>.*, and plugin unload resets those scoped keys automatically.

Supported when grammar for declarative contribution gating currently includes:

  • identifiers using dot or dash namespaces, such as editor.active or plugin.enabled.my-plugin
  • &&, ||, and unary !
  • == and !=
  • parentheses for grouping
  • boolean literals plus quoted string and number literals

The first declarative consumer is command visibility: manifest command contributions can declare when, and the command palette hides or shows those commands through the shared evaluator before callback-specific availability checks run.

Manifest contributions now also support statusBarItems, with records shaped as { id, text?, icon?, alignment?, priority?, tooltip?, command?, when? }. These items install without evaluating plugin code, render through the shared workspace shell on the declared alignment track, and execute referenced commands through the normal command registry so deferred command activation still works. The existing Plugin.addStatusBarItem() raw DOM API remains available as the compatibility escape hatch for Obsidian-style plugins.

Manifest contributions also support editorViews, with records shaped as { id, viewType?, label, description?, filenamePatterns, priority?, activationEvent? }. They install metadata into the workspace editor-view registry without evaluating plugin code, so settings can present selectable editor IDs and configured workspace.editorAssociations values can be preserved even when the backing runtime view is disabled. A code-backed plugin still registers the actual view constructor through Plugin.registerView(); manifest metadata only describes selectable file-backed editor ownership.

Current implementation status: contribution indexing is implemented. Manifest-only installation covers commands, configuration, status bar items, editor-view metadata, and bundled system-extension service bindings, starting with language services.

Follow-on contribution surfaces are intentionally staged behind the same model rather than being introduced as separate ad hoc registries. The next planned surfaces tracked in the backlog are:

  • codicon- and registry-icon label text rendering for declarative contribution labels (command palette today; status bar, trees, and quick pick next)
  • generalized contribution-point descriptors and validation
  • context keys and when clauses
  • declarative status bar items, menus, and keybindings
  • tree views, view badges, and view-scoped context keys
  • output channels, quick pick or input prompts, and richer declarative configuration controls
  • webviews and expanded host-kind support such as browserWorker

When adding a new contribution point, extend the built-in descriptor registry with:

  • shape validation for each contributed record
  • stable contribution IDs and required permission metadata
  • default activation-event metadata when the contribution can wake code lazily
  • manifest-installation metadata so renderer-owned surfaces can install without eager plugin code
  • diagnostics for malformed records and unsupported manifest use

Activation Model

Code execution is delayed until a contribution needs behavior that cannot be installed from manifest data alone. PluginManager remains the coordinator for all activation decisions.

Initial activation events are:

EventMeaning
onStartupFinishedActivate after workspace boot finishes
onCommand:<id>Activate before running a contributed command
onLanguage:<languageId>Activate when a matching document is opened
onView:<viewType>Activate before creating a contributed view
onFileSystem:<glob>Activate when matching vault files are present or opened
onService:<serviceId>Activate when a service provider is selected
workspaceContains:<glob>Activate after discovery finds a matching file

Default behavior is host- and class-dependent:

  • Obsidian-compatible plugins activate during the normal plugin boot phase.
  • Lapis extensions default to lazy activation unless they declare startup work or own contributions needed during boot.
  • Hybrid plugins preserve main.js compatibility and may still require eager activation.

The boot invariant remains unchanged: layout restoration must not race ahead of required plugin activation when code-backed views or services are needed for layout recovery.

Current implementation status: activation-event lazy activation is implemented for indexed Lapis extensions, including command-triggered activation, layout-safe view activation before restore, language/service activation hooks, and vault-path activation for workspaceContains and onFileSystem patterns.

Future declarative UI contributions must keep the same activation rule: the app should be able to index and, when practical, install metadata without forcing plugin code evaluation, then activate code only when the contribution actually needs behavior.

Service Model

Service extensions register as scoped providers owned by the target service rather than replacing App services. Runtime registration returns a disposer and is tied to plugin lifecycle cleanup.

A service-provider declaration includes:

  • provider ID
  • owning plugin ID
  • service ID
  • runtime host
  • required capabilities
  • priority
  • activation event
  • permission requirements

Service IDs remain namespaced. Duplicate provider IDs for the same service are rejected unless the owning service explicitly allows replacement.

Current implementation status: language-service providers follow this model today.

Runtime Hosts

Community plugin code runs through a runtime-neutral execution-host seam.

  • The renderer host is the compatibility fallback for browser/PWA and for community plugins that still run in process.
  • The Electron sidecar host is the trusted desktop host for brokered desktop execution.
  • PluginManager stays responsible for discovery, preflight, diagnostics, activation state, and renderer-installed contributions regardless of which host executes plugin code.

The MVP desktop topology is:

Workspace renderer
  -> PluginManager
    -> preload desktop_plugin_host_* IPC
      -> Electron main PluginSidecarManager
        -> forked Node child process
          -> extension code and broker requests

Electron main owns process creation, timeout handling, crash recovery, shutdown, and brokered native capability access. The renderer owns policy and UI-visible state.

Current implementation status: renderer execution and trusted desktop sidecar execution are both implemented.

Runtime Entry Selection

Lapis runtime entries are separate from main.js. The selected entry depends on plugin class, active runtime, and the narrowest host that can satisfy the contribution.

{
  "lapis": {
    "runtime": {
      "workspace": "main.js",
      "browserWorker": "worker.js",
      "desktop": "desktop.js"
    }
  }
}

Runtime-entry rules are:

  1. Obsidian-compatible plugins use main.js.
  2. Hybrid plugins keep main.js and may additionally declare Lapis runtime entries.
  3. Renderer-hosted Lapis code uses lapis.runtime.workspace.
  4. Sidecar-hosted desktop code uses lapis.runtime.trustedDesktop or lapis.runtime.desktop.
  5. Only the selected runtime entry is required for a given host decision.
  6. Selected host mode and runtime entry are recorded in diagnostics.

All manifest-declared runtime entries must be relative to the plugin folder, must not escape the plugin root, and currently must use .js, .cjs, or .mjs extensions.

Current implementation status: workspace and trusted desktop runtime-entry selection is implemented. browserWorker and multi-host hybrid activation remain future work.

Permissions And Trust

Permissions are manifest-declared requests and are enforceable only for APIs that go through a broker or another restricted host surface.

PermissionBrokered capability
vault.readvault:read
vault.writevault:write
plugin.dataplugin:data
commandscommands
noticesnotices
settings.readsettings
metadata.querymetadata:query
eventsevents
logginglogging

Preflight evaluates declared permissions against the selected host. Missing or unavailable required permissions fail before code evaluation. Granted permissions and host mode are surfaced in diagnostics.

Trusted desktop code is not sandboxed by these app permissions. Once arbitrary Node code is allowed to run in a trusted sidecar, the permission UI must describe those grants as app API access, not as an OS-level sandbox.

Current implementation status: requested and granted permissions plus trusted-host warnings are surfaced in the settings UI.

Install Layout

The plugin system preserves the vault-local community-plugin install layout.

.obsidian/
  community-plugins.json
  plugins/
    <plugin-id>/
      manifest.json
      main.js
      desktop.js
      worker.js
      styles.css
      data.json
      assets/

Only the files required by the plugin class and selected runtime need to exist. Manifest-only Lapis extensions may contain only manifest.json and static assets. Mutable plugin state stays separate from shipped code and assets.

Extension State

The design separates installed package files, user-authored configuration, and generated runtime state.

StateCurrent locationPurpose
Package files.obsidian/plugins/<id>/Manifest, entries, CSS, static assets
Community plugin dataapp.json pluginData (canonical), mirrored to .obsidian/plugins/<id>/data.jsonCanonical plugin compatibility persistence plus Obsidian layout compatibility
Bundled plugin settingsapp.json pluginData (canonical), mirrored to .obsidian/<id>.jsonCanonical bundled/system plugin compatibility persistence plus legacy file compatibility
Generated app stateApp database outside canonical notes when availableSearch, metadata, notebook outputs, derived records

Compatibility loadData() and saveData() behavior remains valid for community plugins. Rebuildable indexes, caches, and large derived artifacts should move to app-owned generated-state APIs rather than being written into shipped plugin folders.

Current implementation status: compatibility plugin data is canonicalized in app.json pluginData and mirrored back into the legacy Obsidian plugin files. Plugins also have generated-state helpers backed by AppDatabase metadata plus an idempotent migration helper for moving rebuildable subtrees out of compatibility settings payloads.

System Extensions

System extensions are app-shipped, app-versioned, trusted components that are not installed into a user’s vault as community plugins. They may use stronger privileges than community plugins, but those privileges remain explicit rather than implicit.

System extensions reuse the same contribution and service-registration model where possible so the application does not evolve two unrelated extension systems. Their settings surface remains distinct from the community plugin list.

Examples include native language-service providers, notebook kernels, file-watching providers, app-owned search backends, and trusted desktop integrations.

Current implementation status: bundled system extensions can now be registered at app boot as manifest-only extensions, renderer code-backed system plugins, or declarative services with app-owned implementations, all through explicit registration. Packaged discovery remains future work.

Security And Isolation

The desktop trusted host is powerful. A normal Electron child-process sidecar can access Node APIs unless the runtime deliberately restricts them. App-level permission checks do not make arbitrary Node code safe.

The design therefore distinguishes:

  • declarative and manifest-only extensions, which can be validated before code execution
  • renderer-hosted compatibility plugins, which preserve behavior but do not gain process isolation
  • trusted desktop extensions, which receive brokered app APIs but still require honest trust language

Isolation hardening may add Node permission flags, per-plugin sidecars, trust-level grouping, watchdogs, and packaged-app verification across supported platforms. Those later phases must not change the manifest, contribution, or host-selection model.

MVP Phases

  1. Manifest classification and contribution indexing.
  2. Manifest-only commands and configuration.
  3. Scoped service registration, starting with language services.
  4. Trusted desktop Electron sidecar lifecycle and brokered capability facades.
  5. Permission and trust diagnostics in the settings UI.
  6. Bundled system extensions with manifest-only contributions and service bindings.
  7. Isolation hardening and the remaining runtime-model work.

Current implementation status: phases 1 through 7 are complete for the current scoped host model. Follow-on runtime expansion such as browserWorker support, richer manifest-only coverage, and multi-host hybrid activation remains future work without changing the completed MVP boundary.

Community Plugin Host Boundary

This page defines the approved host boundary for community plugin execution across browser/PWA and Electron desktop runtimes. It expands the host-model direction in Plugin Runtime without changing the current Obsidian-compatible plugin folder format.

Scope

The boundary applies to community plugins discovered under /.obsidian/plugins/<id>/. Bundled first-party plugins remain renderer-local and continue to use the normal PluginManager lifecycle.

The current renderer implementation remains the compatibility fallback. PluginManager now routes community plugin evaluation through a runtime-neutral execution-host seam, with the renderer host as the default implementation. The Electron sidecar path is the approved desktop target, but it must stay capability-advertised as unavailable until the IPC protocol, sidecar process manager, and API facades are implemented together.

That fallback remains the trusted DOM compatibility host for existing Obsidian-style plugins. New Lapis-native extension surfaces should prefer brokered capabilities plus isolated hosts or UI containers so the app can make trust and ownership boundaries explicit.

Ownership Model

PluginManager remains the lifecycle authority in every runtime. It owns discovery, manifest preflight, enable/disable state, boot ordering, rollback, and user-facing failure reporting.

A selected community-plugin execution host owns only code execution and host-local diagnostics:

HostRuntimeExecution locationIsolation guarantee
Renderer hostbrowser/PWA and fallbackactive renderertransactional lifecycle and structured unsupported-API errors, but no preemption
Electron sidecar hostElectron desktopElectron main-owned JS child process poolrequest timeouts, crash containment, sidecar restart, and capability-brokered APIs

The renderer host and Electron sidecar host must expose the same lifecycle surface to PluginManager so boot remains discover -> preflight -> activate plugins -> restore layout.

Electron Sidecar Shape

Electron main owns the sidecar lifecycle. The renderer never receives Node.js primitives, child-process handles, raw file paths outside the selected vault, or the real Electron IPC surface.

The sidecar manager follows the proven notebook DuckDB sidecar pattern:

  • fork a JS child process from Electron main
  • use request/response messages with unique request IDs
  • enforce a default 30-second request timeout
  • reject all pending requests when the sidecar exits
  • restart after crashes or timeouts only when there are still hosted plugins to service
  • shut down during app quit and when the owning vault/window closes

The preload bridge exposes typed desktop_plugin_host_* IPC commands. The Electron host supports prepare, evaluate, activate, deactivate, and shutdown against Electron main-owned JS child processes, includes the initial parent-brokered capability request path, and advertises plugin-sidecar with status: "available". The current desktop hardening slice runs one trusted child process per plugin ID and vault/window context, so crash recovery and restart budgets are isolated per hosted plugin. The shared renderer still preserves the renderer host as the compatibility fallback and selects the sidecar only for desktop/sidecar-aware plugins or plugins that request brokered capabilities.

The Electron sidecar CommonJS v1 path is bundled-only. It evaluates the selected entry file as a single CommonJS bundle; synchronous local require("./chunk"), parent-relative, or absolute require() calls fail with a clear bundle-required diagnostic instead of attempting async vault reads inside CommonJS resolution. The sidecar hosted dependency surface is generated from the shared host-module catalogue and is intentionally limited to lapis and @lapis-notes/api. Renderer compatibility modules such as obsidian, Svelte, DOM and CodeMirror view packages, and @lapis-notes/ui are unavailable in the sidecar. Dependency failures include the host, selected module format, selected entry, and rejected specifier so PluginManager can preserve actionable diagnostics in the settings UI.

Capability Broker

Hosted community plugins must not receive the real renderer App graph. They receive a capability facade assembled by the selected execution host and brokered through PluginManager policy.

The initial background-safe capability set is:

CapabilityPurpose
vault:readRead vault-scoped text/binary files and metadata needed by the plugin
vault:writeWrite, create, rename, and remove vault-scoped files through existing path-safe adapters
plugin:dataLoad and save plugin-scoped data.json
commandsRegister and unregister serializable command contributions
noticesRequest renderer-owned notices without direct DOM access
settingsRead app/plugin settings and register declarative plugin settings surfaces
metadata:queryQuery indexed metadata snapshots without receiving live renderer cache internals
eventsSubscribe to approved serializable app/vault/plugin lifecycle events
loggingEmit structured plugin logs and diagnostics tied to plugin ID and host mode

The first hosted phase does not include raw DOM access, direct Svelte component mounting, live CodeMirror instances, arbitrary renderer mutation, Node.js built-ins, network access, or direct filesystem paths outside vault-scoped broker calls.

Community manifest extensions may declare supportedRuntimes, requiredCapabilities, and executionHints, while baseline Obsidian manifests remain valid when those fields are absent. Runtime-aware preflight rejects a plugin before evaluation when declared requirements cannot be satisfied by the selected host. The renderer host currently grants no brokered hosted capabilities, so plugins that explicitly require brokered capabilities wait for a capable host instead of running against an unsafe partial facade.

IPC Protocol Direction

The Electron host protocol uses renderer-to-main IPC for lifecycle requests and main-to-sidecar child-process messages for execution. All payloads must be structured-clone safe.

Planned renderer-visible commands:

CommandPurpose
desktop_plugin_host_prepareCreate or reuse the sidecar context for a vault/window and return status
desktop_plugin_host_evaluateEvaluate a community plugin bundle after manifest preflight, including the selected runtime entry metadata
desktop_plugin_host_activateRun hosted plugin onload() against a capability facade
desktop_plugin_host_deactivateRun hosted plugin onunload() and dispose registered contributions
desktop_plugin_host_shutdownStop the sidecar context for a vault/window

Capability calls from the sidecar back into the renderer-visible app must go through main-owned broker requests, not ad hoc require("obsidian") access to renderer objects. Contributions that affect renderer UI, such as commands and future settings views, are returned as serializable descriptors and installed by PluginManager in the renderer.

Failure And Recovery

Sidecar failures must preserve the current plugin-runtime containment rules:

  • a sidecar evaluation or activation error marks only that plugin as failed
  • a sidecar crash marks hosted active plugins as failed, rolls back renderer-installed contributions, and keeps workspace boot moving
  • timeout errors include the request type and plugin ID in diagnostics
  • restart attempts are bounded and visible through plugin diagnostics
  • plugins-loaded still fires only after all activation attempts settle

The community plugin settings UI surfaces host mode, requested capabilities, granted capabilities, requested and granted app permissions for indexed Lapis extensions, a clear trusted-desktop warning when that runtime is declared, last failure, and restart/disable actions for both registered plugins and preflight/evaluation failures that never produced a plugin instance.

Workspace Trust is the user-facing policy layer on top of this boundary. App now owns a vault-scoped WorkspaceTrustService backed by the active adapter vault id through a ScopedVaultStore("workspace-trust") record. Trust defaults to trusted for existing vaults until the user explicitly revokes it, and the service exposes the persisted trust state, grant(), revoke(), request(), plus changed and requested events for future prompt flows.

When a workspace is untrusted, community plugins cannot activate trusted desktop runtime entries, cannot receive brokered hosted capabilities, and cannot register compatibility settings tabs. Plugin diagnostics preserve the selected host mode, requested capabilities, and the trust-gated failure reason so the settings UI can explain why the plugin stayed blocked before evaluation. App also mirrors the current state into the workspace.trusted context key so when clauses can react to trust state.

The workspace Community Plugins settings section now shows a top-level Workspace Trust row with current status plus trust and revoke actions. Trust does not make trusted desktop code safe by itself; it only gives the app a clear place to gate powerful plugin features and explain the decision to users.

Implementation Phases

  1. Done: PluginManager community-plugin evaluation is behind a runtime-neutral execution-host interface while preserving the renderer host as the default.
  2. Done: Electron main/preload sidecar IPC skeleton and child-process manager exist for prepare/shutdown smoke coverage.
  3. Done: hosted-plugin capability facade types and the initial Electron broker path exist for vault I/O, plugin data, commands, notices, settings reads, metadata queries, approved event subscriptions, and logging.
  4. Done: runtime-aware manifest preflight and diagnostics UI exist for host mode, capability decisions, last failure, restart, and disable actions.
  5. Done: Electron sidecar evaluate/activate/deactivate runs trusted Lapis desktop entries through brokered capability facades while PluginManager remains the renderer lifecycle authority.
  6. Expand renderer-safe declarative contribution models for richer community plugin UI.

Each phase must preserve the current plugin boot invariant: layout restoration begins only after community plugin activation attempts have completed.

Plugin Registry Distribution

This page defines the external official plugin registry contract used by Full Registry V1. The registry repository is separate from the app monorepo, but this spec is the durable app-side contract for repository layout, signing policy, static hosting, and official plugin metadata.

The active official external set includes lapis-canvas, lapis-graph, lapis-markdown-lint, lapis-notebook, lapis-pdf, lapis-slides, lapis-telemetry, and lapis-docs. Verified installs record provenance: "official" in .obsidian/installed-plugins.json; the runtime uses that app-owned provenance to classify the plugin as source official, list it under Core plugins, and activate it through optional-core Safe Mode policy while still loading files from /.obsidian/plugins/<id>/ and preserving the existing external enablement file.

Repository Layout

The official registry repository uses reviewed source entries and generated static metadata:

lapis-plugin-registry/
  README.md
  package.json
  pnpm-lock.yaml
  entries/
    official/
      lapis-docs.jsonc
      lapis-markdown-lint.jsonc
    community/
      example-community-plugin.jsonc
  generated/
    v1/
      index.json
      index.sig
      plugins/
        lapis-docs.json
      trust/
        root.json
        root.sig
      revoked.json
      revoked.sig
  schemas/
    catalog-index.schema.json
    plugin-detail.schema.json
    plugin-release.schema.json
    signed-envelope.schema.json
    revoked.schema.json
  scripts/
    validate-registry.mjs
    generate-registry.mjs
    sign-registry.mjs
    verify-registry.mjs
  docs/
    publishing-official-plugins.md
    publishing-community-plugins.md
    signing-and-key-rotation.md

entries/** are human-maintained review inputs. generated/v1/** is generated by registry CI, committed for review, and published as static files. Pull requests normally edit entries/**, schemas/**, or docs/**; CI regenerates generated/v1/** and fails when generated output is stale.

Source Entry Contract

Each registry source entry is JSONC so maintainers can explain publication decisions in review. The generator strips comments and emits canonical JSON.

Official entries must include:

{
  "$schema": "../../schemas/catalog-entry.schema.json",
  "schemaVersion": 1,
  "id": "lapis-docs",
  "name": "Docs",
  "description": "Rich document and spreadsheet editing for Lapis.",
  "readmeUrl": "https://code.ju.ma/lapis-notes/lapis/raw/branch/main/packages/plugins/plugin-docs/README.md",
  "author": "Lapis Notes",
  "authorUrl": "https://app.lapis.md",
  "channel": "official",
  "status": "active",
  "categories": ["documents", "editor"],
  "platforms": ["web", "electron"],
  "latestVersion": "2026.6.6",
  "minAppVersion": "0.20.0",
  "badges": ["official", "verified"],
  "owner": {
    "name": "Lapis Notes",
    "verified": true,
  },
  "versions": {
    "2026.6.6": {
      "version": "2026.6.6",
      "minAppVersion": "0.20.0",
      "releasedAt": "2026-06-06T00:00:00.000Z",
      "platforms": ["web", "electron"],
      "bundle": {
        "url": "https://code.ju.ma/lapis-notes/lapis/releases/download/official-plugin-assets-lapis-docs-2026.6.6/lapis-docs-2026.6.6.lapis-plugin",
        "sha256": "<64 hex chars>",
        "size": 50022551,
      },
    },
  },
  "contributes": {
    "editorViews": [
      {
        "id": "lapis-doc",
        "filenamePatterns": ["*.lapisdoc", "*.lapissheet"],
        "extensions": ["lapisdoc", "lapissheet"],
      },
    ],
  },
}

The registry validator rejects unknown top-level fields unless the schema explicitly reserves them, duplicate plugin IDs, non-HTTPS bundle URLs outside local development, non-HTTPS readmeUrl values, official entries without a verified official release signature during remote validation, and community entries that use reserved Lapis IDs.

Generated V1 Metadata

The app fetches the locked official source at https://registry.lapis.md/v1/index.json. The index is signed inline over canonical JSON with the signatures field removed from the signed payload. Plugin detail and revocation files use the same inline signature shape. Matching .sig sidecars may be published for audit and generated-output stability, but the app verifies inline signatures.

Required generated files:

generated/v1/index.json
generated/v1/index.sig
generated/v1/plugins/<plugin-id>.json
generated/v1/trust/root.json
generated/v1/trust/root.sig
generated/v1/revoked.json
generated/v1/revoked.sig

Each official Forgejo release publishes exactly one <plugin-id>-<version>.lapis-plugin asset. A .lapis-plugin file is a deterministic ZIP-compatible archive containing root release.signed.json plus all installable plugin files. Archive entries may use ZIP method 0 (stored) or method 8 (DEFLATE). release.signed.json must be the first local entry and must be stored. The monorepo publisher writes plugin files in sorted path order with DEFLATE level 6, a fixed ZIP timestamp of 1980-01-01T00:00:00, no comments, and no extra metadata. Streaming ZIP data descriptors are allowed; verifiers still reject unsafe paths, duplicate paths, directory entries, encrypted entries, unsupported compression methods, missing signed files, and unsigned extra files. release.signed.json is a signed envelope:

{
  "signed": {
    "schemaVersion": 1,
    "type": "lapis.plugin.release",
    "pluginId": "lapis-docs",
    "version": "0.1.0",
    "channel": "official",
    "compatibility": {
      "minAppVersion": "0.20.0",
      "platforms": ["web", "electron"]
    },
    "runtime": {
      "entries": {
        "workspace": {
          "path": "main.mjs",
          "format": "esm",
          "sharedDependencies": ["@lapis-notes/api", "svelte", "clsx"],
          "requiresReloadOnUpdate": false
        }
      },
      "compatibilityOverrides": {
        "deprecatedHostModules": {
          "workspace": []
        }
      }
    },
    "files": [
      {
        "path": "manifest.json",
        "sha256": "<64 hex chars>",
        "size": 1234
      },
      {
        "path": "main.mjs",
        "sha256": "<64 hex chars>",
        "size": 6789
      },
      {
        "path": "styles.css",
        "sha256": "<64 hex chars>",
        "size": 890
      }
    ]
  },
  "signatures": [
    {
      "keyId": "lapis-plugin-release-2026-06",
      "alg": "ed25519",
      "sig": "<base64>"
    }
  ]
}

Catalog metadata points at one bundle per release. Install and update download that bundle, verify the catalog bundle SHA-256/size, extract release.signed.json, verify the release signature, validate every signed file path/hash/size against the embedded bytes, reject unsigned extra files, and then stage the verified files into .obsidian/plugins/<id>/. Official external Lapis plugins must publish ESM runtime entries only: release metadata must mirror a packaged lapis.runtime.entries.workspace descriptor with format: "esm" and must not include fallbackPath, CommonJS runtime entries, or stale main.js compatibility artifacts. CommonJS remains supported only for legacy Obsidian-compatible or community/hybrid plugin loading outside the official external release path. Web installs use the same verification path in a module worker when available so hashing, signature verification, and DEFLATE extraction do not block the renderer; explicit main-thread verification remains available for tests and fallback.

Install progress events preserve existing download byte fields and also include generic processed byte/file progress for bundle extraction, file verification, and staging writes. User-facing notifications must show determinate progress when processedBytes/totalBytes or downloadedBytes/totalBytes are available, including file index/count for staging so large plugins such as lapis-docs do not appear frozen while verified files are extracted or written. Direct registry installs and manually selected .lapis-plugin bundles enable the installed plugin by default when a plugin manager is available. Callers that are preserving an existing disabled update state must pass enable: false explicitly.

The registry detail file repeats the fields needed to audit one plugin and points to immutable official release artifacts:

{
  "schemaVersion": 1,
  "id": "lapis-docs",
  "name": "Docs",
  "description": "Rich document and spreadsheet editing for Lapis.",
  "readmeUrl": "https://code.ju.ma/lapis-notes/lapis/raw/branch/main/packages/plugins/plugin-docs/README.md",
  "channel": "official",
  "status": "active",
  "owner": {
    "name": "Lapis Notes",
    "verified": true
  },
  "latestVersion": "2026.6.6",
  "versions": {
    "2026.6.6": {
      "version": "2026.6.6",
      "minAppVersion": "0.20.0",
      "releasedAt": "2026-06-06T00:00:00.000Z",
      "platforms": ["web", "electron"],
      "bundle": {
        "url": "https://code.ju.ma/lapis-notes/lapis/releases/download/official-plugin-assets-lapis-docs-2026.6.6/lapis-docs-2026.6.6.lapis-plugin",
        "sha256": "<64 hex chars>",
        "size": 50022551
      }
    }
  },
  "signatures": [
    {
      "keyId": "lapis-registry-2026-06",
      "alg": "ed25519",
      "sig": "<base64>"
    }
  ]
}

Release assets are immutable by default and use deterministic per-plugin-version release tags of the form official-plugin-assets-<plugin-id>-<version>. A published version normally may not be changed in place; fixes publish a new version. registry-assets:publish -- --force-overwrite is reserved for exceptional repair work with explicit plugin selection and must be followed by registry sync, generation, signing, and validation because bundle hashes and signature metadata may change. README detail content is different: readmeUrl is a signed mutable documentation pointer, so changing markdown content at the same URL does not require a registry metadata republish.

official-plugin-assets-lapis-docs-2026.6.6/
  lapis-docs-2026.6.6.lapis-plugin

Signing Roles

Registry signing keys and plugin-release signing keys are separate operational roles.

RoleSignsApp trust usage
registryindex.json, plugin detail filesBrowse, detail, source provenance
official-plugin-releasesigned plugin release manifestsInstall/update official plugin files
revocationrevoked.jsonDisable, warn, or block revoked installed builds
roottrust-root and key rotation policyFuture key rotation; not a full TUF role in V1

Private keys are never committed. Registry CI may hold registry and revocation signing secrets, while local release signing uses operator-provided key files. The root private key stays offline except for key rotation. Public keys are published in generated/v1/trust/root.json and embedded into app releases when the key set changes.

generated/v1/trust/root.json records active and retired public keys with role, algorithm, activation time, and optional expiry. Full TUF timestamp/snapshot roles are out of scope for V1, but the shape must not prevent adding them later. The V1 app trust set only embeds the current owned registry signing key, lapis-registry-2026-06; the earlier bootstrap key is not trusted because its private credentials are not part of the current publishing setup.

Static Hosting

The published generated/v1 directory and release assets must be available over HTTPS with browser-compatible CORS:

Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET, HEAD
Access-Control-Allow-Headers: Content-Type

Cache policy:

  • index.json, plugin detail files, revoked.json, and trust-root files use a short cache lifetime or ETag validation so app refreshes can observe updates.
  • .lapis-plugin bundle assets are immutable and may use a long cache lifetime.
  • Bundle URLs must not be rewritten in place after publication.
  • Static JSON files must be served with application/json.
  • Release asset downloads must not require cookies, bearer tokens, or per-user authorization.

Registry Generation Commands

Registry repository CI must provide these commands:

pnpm registry:validate
pnpm registry:generate
pnpm registry:sign
pnpm registry:verify

Command responsibilities:

  • registry:validate reads entries/**/*.jsonc, validates schemas, enforces reserved ID rules, and checks immutable HTTPS bundle URLs without requiring Forgejo assets to exist locally. registry:validate:remote additionally fetches every non-pending .lapis-plugin bundle, verifies the bundle SHA-256/size, extracts stored or DEFLATE-compressed entries, verifies the embedded official release signature, checks signed file hashes and sizes, rejects unsigned bundled files, and confirms packaged manifest.json ID/version compatibility. For structured Lapis runtime metadata, remote validation also rejects official releases whose signed runtime metadata does not match manifest.json, whose official workspace entry is not ESM-only, whose entry files are missing, whose module format does not match the entry file extension, or whose declared/scanned bare dependencies are absent from the generated public host-module catalogue for the selected platform. Deprecated and private host-module usage is preserved in diagnostics for manual/community compatibility paths. Official release validation treats private host modules as unsupported shared dependencies, and treats deprecated host modules as release errors unless the signed runtime metadata and packaged manifest.json both include an explicit runtime.compatibilityOverrides.deprecatedHostModules.<host> entry naming the deprecated specifier. That override downgrades only the deprecated-module diagnostic; it does not allow private host modules or unsupported platforms.
  • registry:generate normalizes entries, sorts by plugin ID and version, writes unsigned deterministic generated/v1/** documents, and keeps contribution summaries small enough for startup or settings-screen fetches.
  • registry:sign canonicalizes generated JSON, signs index.json, plugin detail files, revoked.json, and trust-root metadata, and writes both inline signatures[] arrays and sidecar .sig files when the host wants detached signatures.
  • registry:verify verifies the committed generated registry exactly as the app would consume it.

The Lapis monorepo creates official release inputs with:

pnpm plugin-release:keygen
pnpm plugin-release:package -- --plugin-id lapis-docs --version 0.1.0 --input dist --out dist-registry
pnpm plugin-release:sign -- --input dist-registry/lapis-docs-0.1.0/release.json --out dist-registry/lapis-docs-0.1.0/release.signed.json --key-id lapis-plugin-release-2026-06 --private-key-file private.pem
node scripts/plugin-release.mjs bundle --plugin-id lapis-docs --version 0.1.0 --release-dir dist-registry/lapis-docs-0.1.0 --signed-release-path dist-registry/lapis-docs-0.1.0/release.signed.json

pnpm plugin-release:keygen writes the default local plugin-release signing key set to ~/.lapis/. The private PEM stays local or in Forgejo secrets, while the raw base64 public key is the value embedded into the app and registry trust metadata.

For remaining first-party official plugin assets, the Lapis monorepo owns the repeatable publication wrapper:

pnpm registry-assets:version
pnpm registry-assets:publish -- --dry-run
pnpm registry-assets:publish -- --verify
pnpm registry-assets:publish -- --publish

registry-assets:version updates official publishable plugin package and manifest versions to CalVer before publication. Its default plugin set matches registry-assets:publish rather than every bundled/core plugin. It defaults to today’s YYYY.M.D value, accepts --plugins <ids> for subsets, and accepts --patch/--patch-suffix for semver-compatible patch builds such as 2026.6.1-patch.1.

The publisher builds the selected plugin packages, stages normalized release inputs using each plugin package’s package.json version, rejects package and manifest version mismatches, signs and verifies each release manifest, packages release.signed.json plus all plugin files into one deterministic .lapis-plugin bundle with stored release.signed.json and DEFLATE-compressed plugin files, and optionally publishes that single asset to the app repo Forgejo release tag official-plugin-assets-<plugin-id>-<version>. It mirrors the same per-plugin release to github.com/lapis-notes/releases when LAPIS_RELEASES_GITHUB_TOKEN is configured. --verify checks package versions and Forgejo release assets before a real publish. Local TTY runs without --plugins prompt with checkboxes for plugins that still need a release, defaulting to all unpublished plugins selected; use --no-interactive to keep the previous publish-all-unpublished behavior. Availability checks call Forgejo’s release-by-tag API for each deterministic pluginId@version tag and only fetch release asset pages when the release response does not include the expected .lapis-plugin asset. When no --plugins selection is provided, already-published plugin/version assets are skipped so the default path remains “publish all unpublished official plugin versions”; explicit --plugins selections fail if that plugin/version already exists unless --resume is set for a partial-upload recovery run. --force-overwrite is valid only with explicit plugin selection and deletes then re-uploads existing same-name assets in the deterministic release. Local staging defaults to release-artifacts/official-plugin-assets/, with the current run’s staged Forgejo assets under forgejo-assets/ and ephemeral packaging work under tmp/, which is deleted after each run. The app repo owns official plugin asset builds and release signing; the registry repo owns entry review, generated metadata, registry metadata signing, and final active metadata publication.

Selected official plugin packages are built in one Turbo invocation with repeated --filter flags and --concurrency=1, while publish logs record the Turbo remote-cache environment variables before that build so CI output can show whether subsequent runs are expected to hit the shared cache.

The app monorepo also owns pnpm test:official-plugins. That gate builds the current local official plugin packages, stages a signed local registry fixture with test-only Ed25519 keys, routes Playwright registry and bundle requests to that fixture, installs every plugin from officialPluginAssetDefinitions through pluginDistribution.install(..., { requireOfficial: true }), and asserts official provenance, default enabled state, installed file metadata, clean ESM runtime diagnostics, reload persistence, install progress cadence, and one representative runtime behavior per plugin. Shared CI verification and the official plugin asset publish workflow run this gate before app handoff or asset upload.

All official external Lapis plugins use the shared ESM-only Vite build contract. Their builds clean stale dist output, emit a single main.mjs runtime chunk, externalize only generated public host modules, bundle UI subpaths plus Svelte internals, and fail when unsupported bare imports remain. Release staging traces the declared runtime entry graph plus required assets and prunes stale chunks, unused UMD/CommonJS builds, and duplicate format artifacts before signing. Official installs fail before staging when signed runtime metadata is missing, inconsistent, non-ESM, declares a fallback, or references unbundled private or unsupported host modules.

Registry and release documentation should describe the same loader diagnostics the app surfaces after install. Authors are expected to publish ESM-first manifests with bundled normal dependencies, declare only generated public host modules in sharedDependencies, reserve fallbackPath for non-official community compatibility plugins, and document whether updates require a reload. Plugin-defined import maps are not part of Full Registry V1; the app owns the renderer import map and release validators check only manifest-declared runtime entries plus scanned source dependencies. Verified bundled files for ESM entries are served through versioned/hash-bearing app asset URLs after install, while registry metadata keeps the bundle size and SHA-256 immutable for each published version. Any temporary official use of a deprecated public host module must be declared under lapis.runtime.compatibilityOverrides.deprecatedHostModules in the packaged plugin manifest and mirrored into the signed release manifest runtime metadata. Overrides are host-specific, require the exact deprecated bare specifier, and exist only to document reviewed migration windows for official plugins.

Registry CI runs registry:validate, registry:generate, a generated-output diff check, registry:sign when signing secrets are available, and registry:verify on every pull request. Publishing is a static-site deployment of generated/v1 after verification passes; official plugin bundles are published separately from the app repo as deterministic Forgejo release assets and mirrored to GitHub Releases on lapis-notes/releases for public distribution.

Entry Policy

Official entries:

  • must use a reserved Lapis plugin ID
  • must be reviewed by Lapis maintainers
  • must reference immutable-by-default .lapis-plugin bundle assets
  • must verify with a current official plugin-release key
  • must not rely on plugin-controlled manifest fields as proof of official status
  • must include source package and source commit metadata in the signed release manifest for release audits

Community entries:

  • must not use lapis-* IDs or visually confusing names
  • must be labeled community even when hosted in the same registry repo
  • may be deferred from app UI source management in V1
  • must declare platform, minimum app version, and privileged capability needs

For V1 the official registry source is locked in the app. Community entries may be reviewed in the same external repository for future compatibility, but the app does not expose arbitrary user-added source management during this milestone.

Docs Pilot Registry Shape

The first generated registry must include the Docs pilot entry before Docs is removed from bundled workspace bootstrap.

generated/v1/index.json includes:

{
  "schemaVersion": 1,
  "generatedAt": "2026-05-31T00:00:00.000Z",
  "registries": {
    "lapis-official": {
      "name": "Lapis Official Plugins",
      "trustTier": "official"
    }
  },
  "plugins": [
    {
      "id": "lapis-docs",
      "name": "Docs",
      "description": "Rich document and spreadsheet editing for Lapis.",
      "author": "Lapis Notes",
      "channel": "official",
      "latestVersion": "2026.6.6",
      "minAppVersion": "0.20.0",
      "platforms": ["web", "electron"],
      "categories": ["documents", "editor"],
      "badges": ["official", "verified"],
      "detail": "plugins/lapis-docs.json",
      "contributes": {
        "editorViews": [
          {
            "id": "lapis-doc",
            "filenamePatterns": ["*.lapisdoc", "*.lapissheet"],
            "extensions": ["lapisdoc", "lapissheet"]
          }
        ]
      }
    }
  ],
  "signatures": [
    {
      "keyId": "lapis-registry-2026-06",
      "alg": "ed25519",
      "sig": "<base64>"
    }
  ]
}

generated/v1/plugins/lapis-docs.json includes the latest release, mutable readmeUrl, and bundle URL/hash/size for the signed monorepo output. The detail file must keep id, latestVersion, and release versions consistent with the index.

The registry site build fetches each readmeUrl from generated registry metadata and publishes deterministic documentation artifacts under /v1/readmes/<plugin-id>/README.md and /v1/readmes/<plugin-id>/README.html. The app plugin registry UI fetches the registry-hosted markdown artifact derived from the plugin detail URL, resolves relative README links and image URLs against that artifact, renders the markdown through the app markdown preview component, and treats README failures as documentation failures only: install, enable, update, and uninstall actions stay independent of README loading. README content changes at the same source URL do not require metadata changes or release republishing, but they do require a registry site rebuild to refresh the published README artifacts.

The Docs pilot uses lapis-docs as the official installable ID. The standalone manifest reports lapis-docs, and the workspace startup path includes an explicit compatibility migration that copies legacy bundled docs plugin data to lapis-docs when the target has no data yet. The registry may not treat the plugin-controlled manifest ID as proof of official status.

When a user opens a file whose extension has no installed handler but may be covered by an official registry contribution, the workspace asks for confirmation before fetching the locked registry. If that metadata fetch fails, the app surfaces a notification and falls back to the normal missing-handler state instead of silently hiding the failure.

Revocation

generated/v1/revoked.json records revoked plugin versions:

{
  "schemaVersion": 1,
  "generatedAt": "2026-05-31T00:00:00.000Z",
  "revoked": [
    {
      "pluginId": "lapis-docs",
      "versions": ["0.1.0"],
      "reason": "security",
      "message": "Update to a fixed Docs release.",
      "revokedAt": "2026-05-31T00:00:00.000Z"
    }
  ],
  "signatures": [
    {
      "keyId": "lapis-registry-2026-06",
      "alg": "ed25519",
      "sig": "<base64>"
    }
  ]
}

Revocation handling is app-owned. Plugins cannot opt out of revocation policy through their own manifests. The app verifies revoked.json with trusted registry keys during catalog refresh, records matching official installed versions as revoked in .obsidian/installed-plugins.json, elevates those rows in the Plugins settings UI, and only permits recovery through disabling, uninstalling, or installing a compatible verified replacement release.

Style System

The application style system is a shared Obsidian-aligned theme layered over shadcn-svelte components and first-party plugin CSS. The goal is a note-app interface that feels close to Obsidian’s restrained dark product chrome while preserving the existing Svelte component structure, light-mode support, and plugin ownership boundaries.

Ownership

@lapis-notes/ui/theme.css is the canonical palette and Tailwind token bridge entrypoint. It imports grouped partials under src/lib/styles/theme/ for the light palette, dark overrides, semantic aliases, shared foundations, document/content roles, shared surfaces, and Tailwind bridge tokens. Together they define the shared shadcn/Tailwind variables (--background, --card, --popover, --primary, --border, --ring, etc.), the Obsidian-compatible aliases that runtime and plugin code consume (--background-primary, --background-primary-alt, --background-secondary, --background-modifier-border, --text-normal, --text-muted, --text-faint, --interactive-accent, --text-on-accent, etc.), and the shared compatibility helpers that still bridge older Obsidian-style CSS (--color-base-*, --color-accent-*, --mono-rgb-*, shared selection and shadow helpers, the blur/raised surface chain, and related form-field hover helpers).

The workspace workspace-shell.css file owns app-specific Obsidian shell variables and selectors for tabs, stacked tabs, ribbons, status bar, and layout behavior. It should consume shared theme tokens instead of redefining the global palette.

CodeMirror 6 autocomplete tooltip surfaces (.cm-tooltip-autocomplete, completion info) are owned by @lapis-notes/ui/codemirror-autocomplete.css (exported beside theme.css). CodeMirror lint chrome is workspace-owned in packages/workspace/src/lib/styles/codemirror-lint.css because the workspace app is the runtime that loads editor shell CSS; it covers lint ranges, gutter markers, the Lapis-owned hover tooltip shell, the lint panel, and the inline View Problem widget while consuming shared UI theme tokens.

First-party plugins own plugin-prefixed selectors and variables in their package app.css files. Plugin variables should default to shared core variables and only introduce plugin-specific roles for real semantic states such as task status, due dates, trace status, graph categories, warnings, errors, or success states.

Effective Tokens

Dark mode is the primary target:

RoleToken examplesEffective value
Canvas--background, --background-primary#1e1e1e
Surface--card, --background-primary-alt#242424
Raised/secondary surface--secondary, --muted, --sidebar, --background-secondary#262626
Hairline--border, --background-modifier-border, --sidebar-border#363636
Strong/input border--input, --sidebar-accent, --background-modifier-border-hover#3f3f3f
Neutral hover--background-modifier-hover, sidebar rows use --sidebar-accenttranslucent
Neutral selected--background-modifier-active-hoveraccent tint
Primary text--foreground, --text-normal#dadada
Muted text--muted-foreground, --text-muted#b3b3b3
Accent text/focus--text-accent, --ring#a78bfa
Filled interactive--interactive-accent, --primary bridgeconfigured accent
Pressed interactive--interactive-accent-presseddarker accent

Light mode remains supported as a restrained structural mirror. Dark mode should be treated as the visual source of truth when choosing new token mappings.

Theme compatibility must continue to work for both class families, dark/light and theme-dark/theme-light, whether they are applied on html or body. Shared theme partials own that selector compatibility; plugin CSS should not work around it with duplicate palette blocks.

Existing font imports remain in place. The current implementation does not adopt a no-web-font rule.

Component Language

Shared UI components should use semantic Tailwind tokens and compact Obsidian-like chrome:

  • Prefer hairline borders and surface changes over shadows.
  • Neutral controls use --background-modifier-hover for hover state and --background-modifier-active-hover for selected or toggled state. Sidebar row-style controls use --sidebar-accent for hover and active row surfaces.
  • Prefer 4-8px radii for cards, inputs, menus, dialogs, table containers, and repeated rows.
  • Reserve pill geometry for controls that are naturally pill-shaped, such as switches, chips, badges, and compact status markers.
  • Use violet sparingly for links, focus, selected state, and filled primary controls.
  • Runtime components and plugin CSS should consume semantic roles such as --interactive-accent, --interactive-accent-hover, --text-accent, and --text-on-accent; raw --accent-interactive tokens are theme implementation details except where the Tailwind bridge exposes utility aliases.
  • Use semantic state colors only for status meaning: destructive/error, warning/due, success/completed, graph/category, and telemetry trace state.

Workspace chrome follows the same language: tabs, sidebars, status bar, command palette, settings, startup panels, editor tooltips, and toasts should inherit shared variables and avoid introducing standalone palettes.

Sidebar chrome is placement-aware. A leaf or tab group moved into a left or right sidedock should inherit sidebar surface and boundary roles from workspace-shell.css even if the mounted view uses the main --background-primary canvas internally. Dock outer borders, internal pane separators, tab/header borders, grouped-panel borders, and collapsed-sidebar rails should remain visible against both sidebar and editor backgrounds.

Accent Usage

Obsidian treats the accent color as an attention cue for interactive elements, not as a general decoration color. In this codebase:

  • Use --interactive-accent for filled primary actions, selected checkboxes or switches, active resize handles, and other controls whose active state needs a strong user-action signal.
  • Pair any filled --interactive-accent background with --text-on-accent. Do not assume --primary-foreground or white remains readable after user theme changes.
  • Use --interactive-accent-hover and --interactive-accent-pressed only for hover/pressed states of controls that already use --interactive-accent.
  • Use --text-accent for inline links, linked file names, focused status affordances, and small text/icon cues that should read as navigable or actionable without becoming filled controls.
  • Use --ring or --background-modifier-border-focus for focus outlines. Do not replace keyboard focus indicators with a background-only accent.
  • Use --background-modifier-hover for neutral hover rows, toolbar buttons, menus, and list items. Hover alone is not enough reason to use violet.
  • Use --background-modifier-active-hover for selected or toggled neutral controls when a filled accent would overstate the state.
  • Use --sidebar-accent for sidebar row hover/active surfaces unless the row is a primary action or selected control that needs the stronger accent treatment.
  • Use low-opacity accent mixes, such as color-mix() or /10 utility forms, only for drop targets, search/tag highlights, table selection guides, and other temporary interaction hints.
  • Avoid raw --accent-interactive in component and plugin CSS. It is a theme implementation token; use the semantic aliases above.

Plugin Inheritance

Plugins should not depend on workspace-global selectors for plugin chrome. They should expose plugin-prefixed selectors and variables while inheriting core theme roles:

  • Surface roles default to --background-primary, --background-primary-alt, --card, or sidebar tokens for sidebar-hosted views.
  • Border roles default to --background-modifier-border, --border, or --input.
  • Text roles default to --text-normal, --text-muted, and --text-faint.
  • Accent roles default to --interactive-accent, --text-accent, and --text-on-accent.
  • Error, warning, and success roles default to --text-error, --text-warning, and --text-success when present.

Standalone plugin builds continue to include @lapis-notes/ui/theme.css through the plugin CSS contract so plugin-local testing and native-style installs see the same effective token system.

Plugins should consume shared compatibility tokens for common Obsidian-derived roles such as callout palette values, table selection, raised surfaces, and form-field surfaces. When a plugin needs its own semantic colors, it should derive local aliases from the shared state tokens instead of re-declaring theme-light and theme-dark palette ramps.

Change Rules

Style changes that alter the global palette, semantic variable meanings, component shape language, plugin inheritance rules, or dark/light behavior must update this page. Package-local styling behavior should also update the owning package page or package-local spec.md when it changes durable behavior.

Plugin CSS Contract

First-party packaged plugins need two CSS entry points so they can run inside the workspace without duplicating Tailwind output and still remain self-contained when installed or tested as native Obsidian-style plugins.

This contract applies to first-party packages under packages/plugins/ that ship UI. Community plugins continue to follow the Obsidian-compatible plugin folder contract documented in Plugin Runtime.

The shared palette, shape language, and plugin inheritance rules that plugin CSS must consume are documented in Style System. This page owns package artifact and selector rules.

Bundle Contract

Each first-party plugin that needs styling should emit these CSS artifacts:

ArtifactConsumerContents
dist/app.cssWorkspace app bundlePlugin-owned BEM/custom rules only. It must not include Tailwind preflight, Tailwind theme output, or generated utility rules.
dist/styles.cssStandalone/native plugin installs and plugin-local testingSelf-contained CSS with the reusable UI theme, full Tailwind output generated from the plugin source scan, and the plugin app.css rules.

The workspace is the only full Tailwind app entry. Workspace core-plugin registration provides bundled plugin app.css content to PluginManager as bundled lifecycle CSS, never plugin standalone styles.css files. In local workspace development, those inline CSS imports point at bundled plugin src/app.css entries so style edits reload from source; published package consumers continue to use the exported dist/app.css artifact. External official plugins are not scanned or imported by the default workspace bundle; when installed, they load packaged styles.css from /.obsidian/plugins/<id>/ through the normal plugin CSS lifecycle.

Standalone or native plugin installs use styles.css so the plugin can render outside the workspace without relying on host-provided Tailwind output. For first-party plugin development, @lapis-notes/ui/styles.css provides the full UI standalone CSS used to preview plugin-local UI, while @lapis-notes/ui/theme.css provides reusable core variables and Tailwind theme mappings.

Selector Contract

First-party plugin custom CSS uses additive BEM hooks. Tailwind utility classes may continue to express layout and spacing in Svelte components, but plugin-owned selectors are the stable override surface for host styles, tests, and downstream plugin-native installs.

Selector names must use the plugin namespace as the prefix:

  • plugin-markdown uses markdown-...
  • plugin-notebook uses notebook-...
  • plugin-search uses search-...
  • plugin-bases uses bases-...
  • plugin-graph uses graph-...
  • plugin-pdf uses pdf-...
  • plugin-canvas uses canvas-...
  • plugin-slides uses slides-...
  • plugin-telemetry uses telemetry-...

Plugin CSS should not rely on workspace-global selectors for plugin chrome. When a shared UI component needs styling, prefer the shared UI component API and semantic Tailwind tokens before adding plugin-local selectors.

Tailwind Versus App CSS

Use app.css for plugin-owned styling contracts. Use Tailwind classes in component markup for local layout and composition.

Plugin-owned production CSS must be externalized into the package stylesheet artifacts rather than kept in component-local Svelte <style> blocks. Runtime-dependent values may still be passed from markup or script through CSS custom properties or other JS-owned style properties when the external stylesheet consumes them, but the styling rules themselves belong in app.css and the derived standalone styles.css artifact.

Put styles in app.css when they are:

  • stable override hooks for hosts, tests, or plugin authors
  • plugin-specific visual semantics such as surfaces, borders, warning states, error states, selected states, or accents
  • repeated across multiple plugin components
  • based on plugin-prefixed CSS variables
  • replacing hard-coded color utilities in plugin-owned chrome
  • required in both the workspace build and standalone/native plugin build
  • clearer as a named semantic rule than as repeated or brittle utility strings

Keep Tailwind utilities in Svelte markup when they are:

  • spacing, sizing, and local geometry such as px-3, gap-2, h-8, or w-full
  • layout and flow such as flex, grid, items-center, min-w-0, or overflow-auto
  • local typography scale or weight that is not plugin-specific
  • simple one-off composition that does not need to be themed, reused, or overridden

A component may combine both. The BEM class owns the semantic styling and Tailwind owns the local layout:

<div class="notebook-cell__surface flex flex-col rounded-md border px-3 py-2">

In that example, notebook-cell__surface owns background, border color, text color, and state variants through plugin variables. The Tailwind utilities arrange the element and set non-semantic geometry.

Variable Contract

Plugin-specific CSS variables must use the plugin namespace as the prefix, for example --notebook-surface-background or --notebook-accent.

Plugins should prefer core theme variables before introducing plugin variables. When plugin variables are useful, they should default to core theme variables so Obsidian/native hosts and the workspace can override one theme surface consistently:

  • --background-primary
  • --background-primary-alt
  • --background-modifier-border
  • --background-modifier-hover
  • --text-normal
  • --text-muted
  • --text-faint
  • --interactive-accent
  • --text-on-accent

@lapis-notes/ui/theme.css is the canonical source for those core variables. It maps them to the Obsidian-aligned dark palette and keeps light-mode counterparts available, so plugin packages should not duplicate the global canvas/surface/text/accent palette in package CSS. Plugin-specific state colors, such as warnings, errors, success, due-date states, trace status, and graph/category colors, may wrap global semantic variables (--text-warning, --text-error, --text-success, --interactive-accent) when those states need to remain distinct.

Sidebar-hosted plugin surfaces should prefer sidebar theme variables before generic background and text variables:

  • --sidebar
  • --sidebar-foreground
  • --sidebar-border
  • --sidebar-accent
  • --sidebar-accent-foreground
  • --sidebar-primary
  • --sidebar-primary-foreground
  • --sidebar-ring

Plugin variables may wrap these core variables when a plugin has repeated semantic roles such as warning surfaces, cell borders, output backgrounds, or graph accents. Hard-coded color utilities should be avoided in plugin-owned chrome when a core variable or plugin-prefixed variable can express the same role.

Implemented Slices

plugin-notebook is the first implementation slice for this contract.

  • Notebook is external official in the default app bundle; installed lapis-notebook loads packaged styles.css from /.obsidian/plugins/lapis-notebook/.
  • @lapis-notes/notebook/app.css exports notebook BEM/custom rules without Tailwind output.
  • @lapis-notes/notebook/styles.css exports standalone notebook CSS built from UI theme/full Tailwind output plus notebook source scanning and notebook addon rules.
  • Notebook-owned wrapper classes cover the notebook view, execution banner, cells, outputs, dataframe viewer, inspector panel, dependencies panel, and packages panel.
  • Notebook plugin variables default to the core theme variables listed above.

The notebook package keeps Tailwind utilities for spacing and layout where they are still the clearest expression, while BEM classes provide stable override hooks for notebook-owned surfaces.

plugin-search is the second implementation slice for this contract.

  • Workspace core-plugin registration provides packages/plugins/plugin-search/src/app.css to PluginManager as bundled lifecycle CSS during local development.
  • @lapis-notes/search/app.css exports search BEM/custom rules without Tailwind output.
  • @lapis-notes/search/styles.css exports standalone search CSS built from UI theme/full Tailwind output plus search source scanning and search addon rules.
  • Search-owned wrapper classes cover the search view, query editor, settings panel, result groups, match rows, highlighted query segments, and semantic-search status popover trigger.
  • Search plugin variables use the --search-... prefix and default to sidebar theme variables first, then generic core theme variables, for surfaces, borders, text, accents, highlights, status tones, and warning states.

plugin-bases is the third implementation slice for this contract.

  • Workspace core-plugin registration provides packages/plugins/plugin-bases/src/app.css to PluginManager as bundled lifecycle CSS during local development.
  • @lapis-notes/bases/app.css exports bases BEM/custom rules without Tailwind output.
  • @lapis-notes/bases/styles.css exports standalone bases CSS built from UI theme/full Tailwind output plus bases source scanning and bases addon rules.
  • Bases-owned wrapper classes cover the bases view shell, toolbar, search panel, table scroller, table head, rows, cells, footer summaries, card/list surfaces, card placeholders, and unknown-view error state.
  • Bases plugin variables use the --bases-... prefix and default to core theme variables for surfaces, borders, text, accents, cards, table focus state, and error state.

plugin-graph is the fourth implementation slice for this contract.

  • Graph is external official in the default app bundle; installed lapis-graph loads packaged styles.css from /.obsidian/plugins/lapis-graph/.
  • @lapis-notes/graph/app.css exports graph view and graph controls BEM/custom rules without Tailwind output.
  • @lapis-notes/graph/styles.css exports standalone graph CSS built from UI theme/full Tailwind output plus graph source scanning and graph control rules.
  • Graph-owned wrapper classes cover the graph view shell, canvas surface, controls toolbar, settings panel, and graph settings inputs.
  • Graph plugin variables use the --graph-... prefix and default to core theme variables for surfaces, borders, controls, and input states.

plugin-canvas is the fifth implementation slice for this contract.

  • Canvas is external official in the default app bundle; installed lapis-canvas loads packaged styles.css from /.obsidian/plugins/lapis-canvas/.
  • @lapis-notes/canvas/app.css exports canvas BEM/custom rules without Tailwind output.
  • @lapis-notes/canvas/styles.css exports standalone canvas CSS built from UI theme/full Tailwind output plus XYFlow base CSS and canvas source scanning.
  • Canvas-owned wrapper classes cover the canvas view shell, alignment guides, nodes, edges, toolbars, label editors, and error state.
  • Canvas plugin variables use the --canvas-... prefix and default to core theme variables for surfaces, borders, node fills, accents, toolbars, and connector states.

plugin-slides is the sixth implementation slice for this contract.

  • Slides is external official in the default app bundle; installed lapis-slides loads packaged styles.css from /.obsidian/plugins/lapis-slides/.
  • @lapis-notes/slides/app.css exports slides-owned rules without Tailwind output, while Reveal base CSS remains a plugin addon dependency.
  • @lapis-notes/slides/styles.css exports standalone slides CSS built from UI theme/full Tailwind output plus Reveal base CSS and slides source scanning.
  • Slides-owned wrapper classes cover the fullscreen overlay, presentation viewport, close button, and rendered markdown slide surfaces.
  • Slides plugin variables use the --slides-... prefix for plugin-specific colors and map those values onto Reveal’s runtime theme variables.

plugin-telemetry is the seventh implementation slice for this contract.

  • Telemetry is external official in the default app bundle; installed lapis-telemetry loads packaged styles.css from /.obsidian/plugins/lapis-telemetry/.
  • @lapis-notes/telemetry/app.css exports telemetry BEM/custom rules without Tailwind output.
  • @lapis-notes/telemetry/styles.css exports standalone telemetry CSS built from UI theme/full Tailwind output plus telemetry source scanning.
  • Telemetry-owned wrapper classes cover the diagnostics root, summary cards, table shells, waterfall rows, trace header, span details panel, measurement chips, and settings tab wrapper.
  • Telemetry plugin variables use the --telemetry-... prefix and default to core theme variables for surfaces, borders, accent states, warning states, and settings cards.

plugin-markdown is the eighth implementation slice for this contract.

  • Workspace core-plugin registration provides packages/plugins/plugin-markdown/src/app.css to PluginManager as bundled lifecycle CSS during local development.
  • @lapis-notes/markdown/app.css exports markdown-owned rules without Tailwind app output, including markdown preview and sidebar view shell wrappers plus markdown-specific CodeMirror, rendered-markdown, and callout CSS split by area under src/styles/.
  • @lapis-notes/markdown/styles.css exports standalone markdown CSS built from UI theme/full Tailwind output plus markdown source scanning and markdown addon rules.
  • Markdown-owned wrapper classes cover the editor shell, reading view shell, rendered markdown document, media view, outline view, file properties view, and all properties view.
  • Markdown CSS separates shared surface/rendered-content rules from source-mode, live-preview, reading-mode, and embedded-mode overrides, with stable hooks for each mode so selector changes do not leak across markdown surfaces.
  • Markdown plugin variables use the --markdown-... prefix and default to core theme or sidebar theme variables for document surfaces, sidebar surfaces, borders, text, accent states, and editor-specific markdown tokens.

plugin-pdf is the ninth implementation slice for this contract.

  • PDF is external official in the default app bundle; installed lapis-pdf loads packaged styles.css from /.obsidian/plugins/lapis-pdf/.
  • @lapis-notes/pdf/app.css exports PDF view and embed BEM/custom rules without Tailwind output.
  • @lapis-notes/pdf/styles.css exports standalone PDF CSS built from UI theme/full Tailwind output plus PDF source scanning.
  • PDF-owned wrapper classes cover the file view shell, viewer host, runtime mount container, and markdown embed surface.
  • PDF plugin variables use the --pdf-... prefix and default to core theme variables for shell backgrounds, viewer surfaces, borders, text, muted text, and error states.
  • PDF stylesheet availability follows the same bundled plugin lifecycle path as the other first-party plugin styles, so clean workspace sessions no longer depend on a separate global workspace stylesheet import for PDF chrome.

plugin-docs is the tenth implementation slice for this contract.

  • Docs is no longer registered by the workspace core-plugin bootstrap. Installed Docs loads styles.css from /.obsidian/plugins/lapis-docs/ through the normal community plugin CSS lifecycle.
  • @lapis-notes/docs/app.css exports Docs-owned rules without Tailwind app output.
  • @lapis-notes/docs/styles.css exports standalone Docs CSS built from UI theme/full Tailwind output plus Docs source scanning and Univer preset CSS.
  • Docs-owned wrapper classes cover the document and sheet view shell, Univer canvas host, load/save/error messages, view action buttons, and status-bar fallback controls.
  • Docs plugin variables use the --docs-... prefix and default to core theme variables for editor surfaces, borders, text, accent, warning, and error states.

Migration Tracker

This tracker covers first-party packaged plugins under packages/plugins/. It is narrower than the older “plugin-owned styling” status: a plugin is complete here only after it follows the two-artifact CSS contract, exposes stable plugin-prefixed BEM hooks for owned UI surfaces, and uses plugin-prefixed variables for plugin-specific styling.

PluginStatusNotes
plugin-notebookImplemented first sliceEmits app.css and styles.css, exposes notebook BEM hooks, and defaults notebook variables to core theme variables.
plugin-searchImplementedEmits app.css and styles.css, exposes search BEM hooks, and defaults search variables to core theme variables.
plugin-basesImplementedEmits app.css and styles.css, exposes bases BEM hooks, and defaults bases variables to core theme variables.
plugin-graphImplementedEmits app.css and styles.css, exposes graph BEM hooks for the graph view and controls, and defaults graph variables to core theme variables.
plugin-canvasImplementedEmits app.css and styles.css, keeps XYFlow base CSS in standalone output, and defaults canvas variables to core theme variables.
plugin-slidesImplementedEmits app.css and styles.css, keeps Reveal base CSS in standalone output, and maps --slides-... variables onto Reveal theme variables.
plugin-telemetryImplementedEmits app.css and styles.css, exposes telemetry BEM hooks for diagnostics and settings surfaces, and defaults telemetry variables to core theme variables.
plugin-markdownImplementedEmits app.css and styles.css, exposes markdown-prefixed wrapper hooks for editor, preview, embedded, media, outline, and properties surfaces, and defaults markdown variables to core theme variables.
plugin-pdfImplementedEmits app.css and styles.css, exposes pdf-... wrapper hooks and --pdf-... variables, and now loads as the registry-installed lapis-pdf plugin instead of workspace bootstrap CSS.
plugin-docsImplementedEmits app.css and styles.css, exposes docs-... wrapper hooks and --docs-... variables, and now loads as the registry-installed lapis-docs plugin instead of workspace bootstrap CSS.
plugin-spellcheckerOut of scopeObsidian-targeted/community-style plugin package, not part of this first-party CSS tracker.

Update this table as each plugin completes the contract. Do not advance another first-party plugin migration without updating this page in the same change.

Command and Keymap System

Commands provide a uniform dispatch mechanism for user actions. The keymap system binds keyboard shortcuts to commands with scope-aware priority.

Command Interface

interface Command {
  id: string; // Unique ID, e.g. "editor:bold"
  name: string; // Human-readable label
  title?: string; // Unprefixed display title
  category?: string; // Optional grouping/category label
  icon?: string; // Icon identifier for UI
  sourcePlugin?: string; // Owning plugin ID
  activationEvent?: string; // Deferred activation trigger
  argumentSchema?: Record<string, unknown>; // Optional structured arguments
  when?: string; // Declarative context-key expression
  hotkeys?: Hotkey[]; // Default keyboard shortcuts
  callback?: () => void;
  checkCallback?: (checking: boolean) => boolean | void;
  editorCallback?: (editor: Editor, view: MarkdownView) => void;
  editorCheckCallback?: (
    checking: boolean,
    editor: Editor,
    view: MarkdownView,
  ) => boolean | void;
}

Callback Variants

VariantWhen Used
callbackAlways available. Runs unconditionally.
checkCallbackContext-dependent. Called with checking=true to test availability, checking=false to execute.
editorCallbackOnly when an editor is active. Receives the active Editor and MarkdownView.
editorCheckCallbackEditor-scoped + conditional. Combines both patterns.

CommandManager

CommandManager (packages/api/src/lib/command.svelte.ts) extends EventDispatcher and manages all registered commands.

State

PropertyTypeDescription
commandsMap<string, Command>All registered commands
editorCommandsMap<string, Command>Subset with editorCallback or editorCheckCallback
bindingsMap<string, Command[]>Effective hotkey lookup table
hotkeyOverridesMap<string, Hotkey[]>Vault-scoped custom hotkey overrides
open$state<boolean>Whether the command palette is open
openHostIdstringCommand host currently owning the open palette

Methods

MethodDescription
addCommand(command)Register a command. Emits register.
removeCommand(id)Un-register. Emits unregister.
executeCommand(command)Run a command against the currently focused command host.
executeCommandById(id)Look up and execute against the focused command host.
executeCommandForHost(id, hostId)Look up and execute against an explicit root or popup host.
findCommand(id)Retrieve a command by ID.
listCommands()Return all commands as an array.
isCommandAvailable(id, hostId?)Evaluate availability against the focused or explicit command host.
isOpenForHost(hostId)Report whether the palette is open for a given host.
getAvailableCommands(hostId?)Return commands executable for the focused or explicit host.
getCommandMetadata(id)Return normalized metadata for declarative consumers.
loadHotkeys() / saveHotkeys()Load and persist custom hotkeys in .obsidian/hotkeys.json.
getEffectiveHotkeys(id)Return override hotkeys when customized, otherwise command defaults.
setHotkeys() / addHotkey() / removeHotkey()Edit command-specific overrides and rebuild bindings.
resetHotkeys(id)Remove the override so the command uses defaults again.
getHotkeyAssignments() / getHotkeyConflicts()Return settings UI data and duplicate-hotkey diagnostics.

Custom hotkeys are stored in Obsidian-compatible .obsidian/hotkeys.json as { [commandId]: Hotkey[] }. A missing command key means “use command defaults”; an empty array means the command is explicitly unbound. Loading malformed hotkey files is non-fatal and falls back to defaults. Hotkey changes emit hotkeys-updated, rebuild the command binding table immediately, and keep conflicts visible without blocking the save.

Editor Command Execution

When command execution encounters an editorCallback or editorCheckCallback, it resolves the target leaf through the workspace command-host model first. The root shell uses WORKSPACE_ROOT_HOST_ID, while each popup WorkspaceWindow contributes its own host id. Workspace.getCommandHostLeaf(hostId) and focused-host tracking therefore let command availability and execution use the popup’s active editor instead of always falling back to the root window’s active leaf. If no compatible editor is active for that host, the command is skipped.

Hotkey Types

interface Hotkey {
  modifiers: Modifier[];
  key: string; // KeyboardEvent.key value
}

type Modifier = "Mod" | "Ctrl" | "Meta" | "Shift" | "Alt";

"Mod" is the platform-aware modifier: Meta on macOS, Ctrl on Windows/Linux.

Keymap

Keymap (packages/api/src/lib/scope.svelte.ts) manages a stack of Scope objects. The topmost scope gets first chance to handle a keyboard event.

Scope

A Scope represents a keyboard context (e.g., the editor, a modal, a suggest popup):

class Scope {
  keyBindings: KeyBinding[];
  parent?: Scope;

  register(
    modifiers: Modifier[],
    key: string | null,
    handler: KeymapEventHandler,
  ): KeyBinding;
  unregister(binding: KeyBinding): void;
  handleEvent(event: KeyboardEvent, info: KeymapInfo): boolean;
}

Scope.register() supports modal wildcard capture in addition to exact hotkeys. Passing key = null matches any non-modifier key, modifiers = null matches any modifier combination, and the resolver checks exact keybindings before falling back through modifier-scoped and fully wildcard handlers. This keeps modal input flows such as hint overlays or command-like mini modes on the existing keymap stack instead of attaching ad-hoc document listeners.

Keymap Stack

MethodDescription
pushScope(scope)Push a scope on top. Used when modals/suggests open.
popScope(scope)Remove a scope. Used when modals/suggests close.
handleEvent(event)Walk the stack top-to-bottom. First handler that returns false stops propagation.

Keyboard Event Routing

1. Browser fires keydown event
2. Keymap.handleEvent receives it
3. Walk scope stack from top to bottom:
   a. For each scope, iterate keyBindings
   b. Match modifiers + key against the event
   c. If handler returns false → stop (event consumed)
   d. If handler returns undefined → continue to next binding/scope
4. If no scope handler consumes the event, command lookup uses effective
   hotkeys from `CommandManager`
5. The first available command bound to the key executes through
   `executeCommand()`
6. If no scope or command handles it, event propagates normally

Modifier Normalization

Keymap normalizes modifiers for cross-platform use:

  • isModifier(key) — returns true for Shift, Control, Alt, Meta
  • isModEvent(event) — checks event.metaKey on macOS, event.ctrlKey elsewhere
  • Internal matching converts "Mod" to the platform-specific modifier before comparison
  • Command hotkey registration and lookup use the same normalization path, so a command registered with Mod matches Meta on macOS and Ctrl on other platforms instead of storing a literal Mod binding.

Document-level workspace keydown forwarding should let any pushed modal scopes on app.keymap run before the active view scope, then fall back to app.scope before allowing the browser or host default to run. This keeps app-global shortcuts such as the command palette available even when the active leaf owns its own scope, while still letting modal overlays consume input before editor widgets or browser defaults see it. Popup shells register the same forwarding against their own document and window so Mod+P and related shortcuts resolve against the focused popup host instead of the parent renderer document.

Command Palette Integration

The command palette (CommandPalette in packages/workspace) lists all commands via commandManager.listCommands(). Workspace-owned command and file dialogs rank visible entries with the shared @lapis-notes/ui Fuse.js helper while keeping bits-ui command filtering disabled, and selection still calls commandManager.executeCommand(). Displayed shortcut badges come from getEffectiveHotkeys() so custom hotkeys and explicit unbindings match runtime behavior.

Command labels accept VS Code-style label icon text. The shared parser in @lapis-notes/api splits plain text from $(token) segments. Unqualified tokens such as $(play) and $(sync~spin) render with the VS Code codicon font first; when no codicon glyph exists, the renderer falls back to a registered Iconify icon with the same id (play, lucide:play, custom:…). Qualified tokens such as $(lucide:file-text) or $(vscode-icons:file-type-json) render inline SVG from registered packs only. Unknown or malformed tokens stay visible as literal $(…) text. The ~spin modifier applies to both codicon and registry segments.

The command palette and future declarative surfaces should use isCommandAvailable(), getAvailableCommands(), host-aware palette open state, and command metadata queries instead of re-implementing checkCallback or editor gating inline. Deferred manifest commands remain registered as placeholders until execution triggers the matching onCommand:<id> activation event.

Workspace-owned palette surfaces mount per command host. The root renderer can keep its normal overlay behavior, but popup shells must render their palette surface inside the popup document rather than portal across documents. Host-local mounting keeps focus management, key handling, and command execution attached to the same document that opened the palette.

isCommandAvailable() now evaluates a command’s optional when expression through App.contextKeys before any callback-specific availability check runs. A falsy or malformed when expression hides the command from palette-style surfaces and makes executeCommand() reject it as unavailable.

The current command registry is already the lifecycle owner for imperative and manifest-indexed commands. The planned VS Code-style expansion tracked in the backlog is to treat it as the shared command surface for declarative menus, keybindings, status items, prompt flows, and lazy command activation rather than introducing separate command dispatch paths per feature.

Plugin Command Registration

Plugins register commands via Plugin.addCommand(), which delegates to app.commands.addCommand(). The command is automatically removed when the plugin is disabled.

class MyPlugin extends Plugin {
  onload() {
    this.addCommand({
      id: "my-plugin:do-thing",
      name: "Do Thing",
      hotkeys: [{ modifiers: ["Mod"], key: "d" }],
      callback: () => { ... },
    });
  }
}

Planned Declarative Layers

The next command-system slices tracked in the backlog build on the existing registry:

  • manifest keybinding contributions with platform overrides and when clauses
  • declarative menu and context-menu contributions that reference registered commands
  • richer command metadata and diagnostics for duplicate IDs or invalid command references

These additions should reuse the current command registry and keymap stack so command execution remains observable, disposable, and plugin-owned in one place.

Drag-and-Drop Patterns

The application uses drag-and-drop interactions across multiple packages and contexts. This page documents the current implementation landscape, completed migrations, pending work, and standardization guidance.

Overview

Drag-and-drop functionality spans:

  • Workspace layout — pane resizing and tab reordering (custom implementation)
  • Tasks plugin — Fizzy board card moves, column reordering (Bases integration)
  • Bases plugin — table column reordering, sort/group popover reordering (@dnd-kit/svelte)
  • CSV plugin — table preview row/column reordering (@dnd-kit/svelte)
  • File explorer — file/folder drag-and-drop tree operations
  • Canvas plugin — node/edge dragging in visual editor

The codebase now uses @dnd-kit/svelte for first-party generic reorder interactions, while workspace layout, file explorer, and canvas surfaces keep their package-specific drag systems.

Migration Status

Completed Migrations to @dnd-kit/svelte ✅

Tasks Plugin — Fizzy Board

  • Location: packages/plugins/plugin-tasks/src/bases/fizzy/
  • Components: board.svelte, card.svelte, column.svelte
  • Package: @dnd-kit/svelte: ^0.4.0
  • Capability: Drag cards across status columns; drop mutates note frontmatter
  • Status: Production-ready; E2E tested

Shared UI — Table Row/Column Reorder

  • Location: packages/ui/src/lib/components/ui/table-dnd/
  • Exports: @lapis-notes/ui/table-dnd, @lapis-notes/ui/table-dnd/sensors, @lapis-notes/ui/table-dnd/utils
  • Package: @dnd-kit/dom, @dnd-kit/svelte: ^0.4.0
  • Capability: tableReorderSensors (5px pointer distance), TableDragGrip (attachHandle), dropIndicatorClasses, parseTableDragData, resolveTableDragTargetIndex; callers pass a namespace prefix (csv, markdown) for isolated type / accept strings
  • Status: Production-ready; consumed by CSV and GFM markdown live-preview tables

CSV Plugin — Table Preview Row/Column Reorder

  • Location: packages/plugins/plugin-csv/src/lib/components/
  • Components: csv-data-table.svelte, csv-data-table-column-head.svelte, csv-data-table-data-cell.svelte
  • Shared helpers: @lapis-notes/ui/table-dnd
  • Package: @dnd-kit/svelte: ^0.4.0
  • Capability: Drag row/column grips to reorder CSV preview rows and columns; grip-only activation via attachHandle; accent drop-indicator borders during drag
  • Status: Production-ready in CSV table preview

Bases Plugin — Sort Popover

  • Location: packages/plugins/plugin-bases/src/bases-view/components/sort/
  • Components: sort-root.svelte, sort-row.svelte
  • Package: @dnd-kit/svelte: ^0.4.0
  • Capability: Drag-reorder sort clauses in the toolbar Sort menu
  • Status: Production-ready; used for groupBy/sort configuration
  • Helpers Used: arrayMove from @dnd-kit/helpers

Bases Plugin — Table Column Reordering

  • Location: packages/plugins/plugin-bases/src/bases-view/table-view/
  • Files: table-view.svelte, table-view/components/table.svelte, table-view/components/div-table.svelte, table-view/components/table-header.svelte, table-view-placeholder.svelte, table-view/components/table-placeholder.svelte
  • Package: @dnd-kit/svelte: ^0.4.0
  • Capability: Drag to reorder columns horizontally; persists column order in .base document
  • Status: Production-ready; package-checked after migrating off the legacy namespace

Markdown Plugin — Standard (GFM) Tables Row/Column Reorder

  • Location: packages/plugins/plugin-markdown/src/lib/codemirror-extensions/table/
  • Components: editor-table.svelte, markdown-table-column-head.svelte, markdown-table-row-gutter-cell.svelte, markdown-table-data-cell.svelte, markdown-table-dnd.ts
  • Shared helpers: @lapis-notes/ui/table-dnd (same dnd-kit wiring as CSV: tableReorderSensors, droppables on cell/head refs, onDragOver + dragOverIndex, grip attachHandle)
  • Package: @dnd-kit/svelte: ^0.4.0
  • Capability: Drag row/column grips in live-preview GFM pipe tables to reorder rows/columns and persist updated markdown source
  • Status: Focused workspace regression tested

Markdown Plugin — Grid Tables (follow-up)

  • Location: packages/plugins/plugin-markdown/src/lib/codemirror-extensions/grid-table/
  • Status: Still on package-local HTML5 / legacy drag; migrate to @lapis-notes/ui/table-dnd in a separate backlog item

Legacy/Custom Implementations

Workspace Layout

  • Location: packages/workspace/src/lib/
  • Components: tabs-drop.svelte, tabs-move.svelte, sidebar-*.svelte, pane-resizer
  • Package: Custom implementation + shared HTML5/pointer transport
  • Capability: Tab reordering, tab-to-edge pane splits, sidebar tab moves, grouped sidebar tab/panel moves, drag-out floating panes, floating-pane redock, pane resizing, file drag-to-split
  • Status: Mature and stable; not scheduled for migration (layout operations are workspace-specific)
  • Notes:
    • Workspace shell keeps visual drop indicators and animating marker lines
    • Drag geometry and hover visuals still live in renderer components, but committed layout mutations now go through shared Workspace drop helpers (moveWorkspaceChildToTabIndex, dropWorkspaceItemOnTabs, moveLeafToSidebarGroupIndex, moveLeafToSidebarGroup) instead of mutating the model directly in Svelte
    • DragState resolves workspace DnD transport through auto, pointer, and html5 modes: default desktop mouse drags keep the existing HTML5 path, while touch and pen drags use pointer events plus registered drop targets resolved through elementsFromPoint()
    • Pointer drags use a renderer ghost instead of DataTransfer, keep the same overlay/drop lifecycle events as HTML5, and route the final committed mutation through the same shared Workspace helpers
    • Touch drags use a long-press activation delay plus a pre-activation movement tolerance so quick flicks cancel cleanly while held drags can still reorder tabs, split panes, move sidebar tabs, and reorder grouped sidebar panels
    • When a top tab, stacked tab, sidebar tab, sidebar group, or grouped sidebar panel drag ends without any registered workspace drop target, the source-side drag handler now converts that gesture into Workspace.moveWorkspaceChildToFloating(...), creating an in-app floating pane instead of dropping the gesture on the floor
    • Floating panes render as shell-level WorkspaceWindow overlays above the docked root split, keep persisted bounds and z-order in workspace.json, and reuse the same drop targets for redocking back into the main or sidebar layout
    • Renderer drop targets fire layout-will-show-overlay before exposing a target, then use cancelable layout-will-drop and layout-did-drop events around committed mutations; successful drops emit one final layout-change tagged with source: "drag-drop"
    • Sidebar groups reuse the same custom drag state instead of introducing a second drag framework; whole groups reorder in the sidebar strip and child leaves reorder vertically within grouped panels
    • Dropping a leaf into a sidebar group moves the real WorkspaceLeaf under that WorkspaceSidebarGroup; dropping a grouped leaf back onto the sidebar strip detaches it from the group and restores it as a normal sidebar tab
    • Desktop (Electron) uses data-desktop-drag-region markers to avoid native window-drag conflicts

File Explorer

  • Location: packages/workspace/src/lib/sidebar/file-explorer
  • Package: Custom HTML5 drag-and-drop
  • Capability: Drag files between folders, validate drop targets
  • Status: Functional; not a candidate for dnd-kit (filesystem operations are workspace-specific)

Canvas Plugin

  • Location: packages/plugins/plugin-canvas/src/
  • Package: Svelte Flow (not dnd-kit)
  • Capability: Node and edge dragging in the visual editor
  • Status: Separate drag-drop system via Svelte Flow; not in scope for dnd-kit standardization

API Differences

@dnd-kit/svelte (New, v0.4.0)

import { DragDropProvider, createDraggable, createDroppable } from "@dnd-kit/svelte";
import { arrayMove } from "@dnd-kit/helpers";
import { restrictToHorizontalAxis } from "@dnd-kit/modifiers";

// Wrap interactive area
<DragDropProvider {config}>
  // Draggable item
  {#each items as item (item.id)}
    <div use:createDraggable={item.id}>
      <!-- draggable content -->
    </div>
  {/each}

  // Drop target
  <div use:createDroppable={targetId}>
    <!-- drop zone -->
  </div>
</DragDropProvider>

// Reorder handler
function handleDragEnd(event: DragEndEvent) {
  const { active, over } = event;
  if (over && active.id !== over.id) {
    items = arrayMove(items, active.id, over.id);
  }
}

@dnd-kit-svelte (Old, v0.0.11)

import {(Sortable, DndEvent, DndOptions)} from "@dnd-kit-svelte/sortable"; import
{restrictToHorizontalAxis} from "@dnd-kit-svelte/modifiers"; // Sortable wrapper
<Sortable items={columns} {modifiers} on:sort={(e) => handleSort(e.detail)}>
  {#each columns as col (col.id)}
    <div slot="item" let:item>
      <!-- content -->
    </div>
  {/each}
</Sortable>

Key Differences

AspectNew (@dnd-kit/svelte)Old (@dnd-kit-svelte)
API styleComposable hooksComponent wrapper
Modifiers@dnd-kit/modifiers@dnd-kit-svelte/modifiers
Utilities@dnd-kit/helpers@dnd-kit-svelte/utilities
Event handlingDragEndEvent typeEvent slot bindings
ReorderingarrayMove() helperManual array manipulation
ProviderDragDropProvider<Sortable> component

The new package’s hook-based approach is more flexible and aligns better with Svelte 5 semantics.

Dependency Status

Single dnd-kit Generation In Use

plugin-bases/package.json now declares only the current stack:

{
  "dependencies": {
    "@dnd-kit/dom": "^0.4.0",
    "@dnd-kit/helpers": "^0.4.0",
    "@dnd-kit/svelte": "^0.4.0"
  }
}

Other packages:

  • plugin-tasks: @dnd-kit/svelte
  • plugin-markdown: New package only
  • workspace: Custom (no dnd-kit)

Immediate

  1. Document shared helper boundaries

Keep it explicit which drag helpers stay package-local versus shared across Bases, markdown, and CSV table-like surfaces.

Medium-term (Optional)

  1. Audit markdown table against bases table patterns

    • Ensure consistent implementation
    • Consider if they should share a base component
  2. Document desktop-specific drag concerns

    • Electron window dragging vs. internal drag-drop
    • Update workspace shell README with -webkit-app-region guidance

Long-term (Infrastructure)

  1. Workspace shell refactor (future, low priority)
    • Eventually consider standardizing pane/tab reordering on dnd-kit
    • Not urgent; current custom impl is stable and mature
    • Would require significant rework of visual feedback (drop indicators, animations)

Known Issues & Workarounds

Electron Drag Region Conflicts

  • Issue: Electron frameless window dragging (-webkit-app-region) interferes with internal HTML5 drag-drop
  • Workaround: Use data-desktop-drag-region="false" to opt interactive elements out of native drag regions
  • Files: tabs-*.svelte, workspace-shell.css
  • Status: Resolved for tab/file drag-drop and pane resizing

sort-popover Popover Position

  • Issue: When sort popover is at the bottom of the viewport, dnd-kit drag events can escape the provider bounds
  • Status: Tested; no issues observed with current viewport constraints
  • Monitoring: Watch for issues if sort row count significantly increases

Configuration and Settings

The configuration system provides typed, validated, reactive settings with automatic persistence.

ConfigurationSchema

ConfigurationSchema (packages/api/src/lib/configuration.svelte.ts) is the central schema registry for app and manifest-declared settings. It extends EventDispatcher and emits updated whenever the registered schema surface changes.

Schema Types

Settings are registered with schema-backed descriptors that validate values at write time and materialize defaults into persisted configuration on first load:

TypeShapeDescription
Stringtype: "string"Free-text value
Numbertype: "number"Numeric value; minimum and maximum render as a slider/range control
Booleantype: "boolean"Toggle
Enumtype: "string" + enum[]Fixed set of choices rendered as a select
Enum Arraytype: "array" of enumMulti-select list rendered from a multiple-select dropdown
Object Grouptype: "object"Nested settings section with inherited group title and description
Record Objecttype: "object" with schema-valued additionalPropertiesDynamic key/value map rendered as an editable table
Customtype: "custom"Arbitrary plugin-owned settings surface

Object groups are flattened into individual persisted keys, but the schema keeps group metadata so the renderer can preserve subsection titles and descriptions.

Methods

MethodDescription
register(key, schema)Register a configuration key with its schema and default.
getConfiguration(key)Returns a WorkspaceConfiguration proxy for the key.
validateConfigurationOptionValidates and normalizes a value against the registered key.
materializeSchemaDefaults()Persists missing defaults and restores invalid stored values.
load() / save()Persist to/from the data adapter.

WorkspaceConfiguration

WorkspaceConfiguration acts as a typed accessor for a configuration namespace:

MethodDescription
has(key)Check if a key exists.
get(key)Retrieve the current value.
update(key, value)Write a new value (validates against schema).
entries()Iterate all key-value pairs.

Invalid enum choices, out-of-range numeric values, and invalid enum-array entries are rejected before persistence. The workspace settings UI surfaces those failures through the normal settings interaction flow instead of silently accepting malformed values.

Persistence

Configuration is stored as JSON files via the DataAdapter:

FileContents
app.jsonCore application settings plus canonical plugin compatibility data under pluginData
appearance.jsonAppearance-specific settings
hotkeys.jsonVault-scoped command hotkey overrides, keyed by command ID
core-plugins.jsonDisabled bundled plugin IDs plus optional bundled-plugin enable overrides
community-plugins.jsonEnabled external plugin IDs, including installed official plugins for compatibility
installed-plugins.jsonVerified external install records and provenance
plugins/<id>/data.jsonExternal plugin compatibility mirror of canonical app.json plugin data
<id>.jsonBundled core/system plugin compatibility mirror of canonical app.json plugin data

The external official lapis-telemetry plugin stores its browser telemetry configuration through external plugin data under plugins/lapis-telemetry/data.json while canonical values remain under app.json pluginData. Its settings include enablement, web-vitals capture, debug logging, IndexedDB diagnostics persistence, sample rate, slow-span threshold, and optional OTLP endpoint.

Compatibility Plugin.loadData() and Plugin.saveData() now treat app.json as the source of truth for opaque plugin-owned settings payloads. The configuration layer stores those values under the top-level pluginData object, rewrites the legacy Obsidian plugin file on save, prefers canonical pluginData when both copies exist, and only falls back to the legacy file to backfill missing canonical entries during migration. After boot, external edits to app.json are reloaded through the configuration service and mirrored back into registered plugin legacy files so the on-disk compatibility layout stays in sync.

When boot finds an enabled external plugin whose manifest.json is missing, it removes that stale ID from community-plugins.json and notifies the user instead of repeatedly trying to activate a deleted plugin.

Manifest-declared plugin configuration stays namespaced under the contributing plugin ID, or an explicit contribution ID when one is provided. That keeps plugin-owned keys isolated from unrelated app settings and safe to ignore if a plugin is later removed.

Settings UI

The settings UI is built from a component library in packages/workspace.

When a registered schema contributes a single top-level section, the workspace configuration UI preserves the schema’s declarative title for the visible section heading instead of autogenerating a label from the schema or contribution id. That includes flat single-property contributions (for example csv-lint with title CSV Lint) and schemas that expand to exactly one nested subgroup (for example files.links.* under Files and Links).

The settings sidebar uses an Obsidian-style flat navigation model rather than exposing the raw schema tree. The Options, Core plugins, and Community plugins group labels are stable, even when a plugin group has no settings entries yet. The Options group contains curated app surfaces (Workspace, Editor, Files and Links, Appearance, Hotkeys, Plugin registry, Core plugins, and Community plugins) with Lucide-backed icons. Core plugins and Community plugins then list enabled plugins that expose settings through either an imperative SettingTab, manifest-declared configuration, or both. Selecting a nav item renders only that surface in the content pane; app schemas remain the implementation detail used to build the selected form.

The settings sidebar uses the shared sidebar provider’s resize state with an initial width of 14rem. Its resize rail updates the provider-owned --sidebar-width value directly, so dragging the handle changes the navigation width without hiding the selected settings surface or covering the content pane. Unlike the workspace dock rails, the settings panel keeps its visible resize separator at 2px on hover/focus so the modal chrome stays visually quiet. On small viewports, settings content exposes an icon-only top-left trigger that opens the shared sidebar provider’s mobile navigation sheet without changing the selected settings surface.

Schema-driven surfaces render each category as an unframed heading followed by a rounded settings panel. The panel owns the subtle background and row dividers; the category heading itself has no top border or sticky setting-row styling. Setting rows use their own setting title only because the surrounding category already provides the path context. This keeps declarative settings visually aligned with Obsidian’s settings model without changing the global Setting API used by imperative plugin tabs.

Imperative plugin tabs that call Setting.setHeading() emit the same section shape through API-owned hooks:

  • setting-section — section wrapper
  • setting-section-heading setting-control-heading — heading block outside the panel
  • setting-section-heading-title / setting-section-heading-description — title and optional description
  • setting-section-body — rounded panel that owns row dividers and receives subsequent new Setting(containerEl) rows until the next heading

The plugin tab root (.workspace-shell__settings-plugin-tab) is a form wrapper only; panel chrome lives on section bodies, matching .workspace-shell__settings-schema-body styling in the workspace shell.

The settings sidebar includes structured search above that navigation. Non-empty queries use the same shared Fuse.js helper as the command palette to rank schema-backed settings, app settings tabs, plugin rows, plugin manifest metadata, diagnostics/features, and declarative configuration metadata. The normal sidebar navigation stays visible while grouped result cards render in the main settings pane. Results highlight matching text and navigate to the canonical surface for the matched schema setting, settings tab, or plugin row. Imperative plugin settings tabs are not mounted off-screen for indexing; only structured metadata already owned by the settings shell is searched.

AppSettings Builder

AppSettings provides a declarative builder API for constructing settings panels:

const settings = new AppSettings(app).addGroup("Appearance", (group) => {
  group
    .addSetting("Theme", (setting) => {
      setting
        .setName("Color theme")
        .setDesc("Choose light or dark")
        .addDropdown((dropdown) => {
          dropdown
            .addOptions({ light: "Light", dark: "Dark", system: "System" })
            .setValue(config.get("theme"))
            .onChange((value) => config.update("theme", value));
        });
    })
    .addSetting("Font size", (setting) => {
      setting.setName("Font size").addSlider((slider) => {
        slider.setLimits(12, 24, 1).setValue(fontSize).onChange(updateFont);
      });
    });
});

Setting Components

ComponentPurpose
SettingGroupNamed group with collapsible header
SettingSingle setting row with name, description, and control
addToggleBoolean on/off switch
addTextText input
addTextAreaMulti-line text input
addDropdownSelect from options or enum-array multi-select
addSliderNumeric range
addColorPickerColor value
addButtonAction button
addExtraButtonSecondary action
addMomentFormatDate/time format string
addSearchSearchable selection
addObjectMapDynamic record-object editor with add/remove rows

Unsupported declarative shapes still stay visible in the schema-driven form, but they fall back to an Edit in app.json button that opens the vault’s app.json file in a centered in-app floating editor pane instead of rendering a misleading partial control.

Plugin Settings Sections

The workspace exposes two plugin-management sections in Settings:

SectionPurpose
Core pluginsLists bundled plugins plus installed official plugins, allows toggling non-required entries, and links their setting surfaces
Community pluginsLists installed community/manual plugins, allows toggling them, and links their setting surfaces

Required bundled plugins remain visible in the core list but cannot be disabled. Installed official plugins load from /.obsidian/plugins/<id>/, keep enablement in community-plugins.json for compatibility, and still appear under Core plugins when installed-plugins.json records provenance: "official".

Plugin rows that expose inline panel content now use an inline disclosure model. The row shows a trailing chevron affordance, starts collapsed, and expands the existing shared Details | Features panel in place when the non-control part of the row is activated. Rows without details metadata or feature sections stay non-expandable and keep their visible summary metadata. Settings-search navigation that targets a plugin row reveals a collapsed row before scrolling and highlighting it.

The Community Plugins section also owns the workspace-trust controls for hosted community plugins. It shows the current trust state for the active vault, offers explicit Trust and Revoke actions, and keeps trust policy visible next to plugin diagnostics and enablement controls instead of burying it in an unrelated app settings section.

The compatibility path still allows plugin-owned settings tabs backed by direct DOM construction. For new Lapis-native extensions, the preferred direction is declarative schemas plus app-owned controls for routine settings surfaces. Plugin-list settings affordances open the plugin’s canonical settings surface. If a plugin has both an imperative SettingTab and owned declarative configuration sections, the settings panel renders the imperative tab first and then the declarative form sections under the same plugin sidebar item.

When Settings is already open, plugin-list affordances and other settings-target navigation update the selected surface in place. They must not close and reopen the settings panel just to force a tab remount, because that creates a visible full-panel flash and breaks the Obsidian-style in-panel navigation model.

SettingTab

Plugins can add their own settings tabs:

class MySettingTab extends SettingTab {
  display(containerEl: HTMLElement): void {
    // Build settings UI using the builder API
  }
}

// In plugin onload:
this.addSettingTab(new MySettingTab(this.app, this));

Plugin setting surfaces are grouped under the settings section that matches the runtime source: bundled and official plugins under Core plugins, community/manual plugins under Community plugins. Core plugin manifest diagnostics can index the same plugin ID as the loaded runtime plugin; settings navigation and search de-duplicate those records so the loaded plugin surface appears once. Synthetic webview records created for compatibility by registerWebView() stay registered, but the settings sidebar filters those categoryId: "extensions" schema records out of the app-owned section model so .addSettingTab() tabs do not also appear as schema-derived sidebar groups.

The sidebar only renders plugin surfaces that have an imperative tab or declarative settings to show. The top-level plugin management entries under Options remain visible independently.

The Options section also includes the app-owned Hotkeys tab. It renders the command registry, filters by command text or pressed-hotkey tags, supports an assigned-only filter, and edits the CommandManager hotkey override API. The search input owns the pressed-hotkey capture affordance: clicking its keyboard button replaces that icon with Press hotkey, consumes keydown events until an idle gap commits the captured shortcut tags, and exits without adding a tag on Escape. Typed search text shows an in-input clear button. Rows can add multiple hotkeys, remove individual bindings, reset customized commands to defaults, and show conflict diagnostics without blocking the save. Changes persist to .obsidian/hotkeys.json and update command routing immediately.

Modal provides a dialog window with its own Scope for keyboard handling. Settings and confirmations commonly use modals.

Workspace Configuration Schemas

The workspace (packages/workspace) registers three main configuration groups.

Appearance

KeyTypeDefaultDescription
theme"light" | "dark" | "system""system"Color mode
accentColorstring""Custom accent hue in oklch
fontSizenumber16Base font size in pixels
fontFamilystring""Custom font family
lineWidthnumber700Max editor line width in pixels

Editor

KeyTypeDefaultDescription
lineNumbersbooleanfalseShow line numbers
indentSizenumber4Spaces per indent
vimModebooleanfalseEnable Vim keybindings
spellcheckbooleantrueBrowser spellcheck
foldHeadingsbooleantrueEnable heading folding
foldIndentbooleantrueEnable indent-based folding

The workspace also wires editor.behaviour.indentUsingTabs and editor.behaviour.indentVisualWidth into the shared CodeMirror setup: pressing Tab indents the current line or selection, Shift-Tab removes one indent level, and disabling tabs switches that indentation to spaces using the configured visual width.

editor.behaviour.indentVisualWidth defaults to 4 and drives both editor behavior and indentation layout. The shared editor uses it for EditorState.tabSize and space-based Tab insertion, while the workspace shell also projects the same value onto the root --indent-size CSS variable and the derived --list-indent value.

Files

KeyTypeDefaultDescription
files.links.newLinkFormat"shortest" | "relative" | "absolute""shortest"Default target-path style for generated internal links
files.links.useWikilinksbooleantruePrefer Obsidian-style wikilink syntax for generated internal links
files.links.omitMarkdownExtensionbooleantrueOmit .md and .markdown when formatting internal links where possible
files.links.useShortestUniqueSuffixbooleanfalseIn shortest mode, prefer the shortest unique path suffix instead of the full vault path

These settings are consumed by the shared API link formatter, so they affect completion-inserted note links, file-manager generated links, and rename-time link rewrites routed through FileManager.renameFile().

Workspace

KeyTypeDefaultDescription
workspace.mobile.mode"auto" | "always" | "never""auto"Choose whether the workspace shell follows the mobile breakpoint or forces a specific display mode
workspace.mobile.defaultPage"editor" | "tabs""editor"Choose which primary page the mobile shell opens first
workspace.mobile.showBottomNavbooleantrueShow the mobile shell’s bottom navigation bar
workspace.mobile.includeSidebarsInTabsbooleantrueInclude left and right sidebar leaves in the mobile tabs page
workspace.mobile.includeFloatingInTabsbooleantrueInclude floating and popout leaves in the mobile tabs page
workspace.mobile.breakpointPxnumber768Viewport width below which workspace.mobile.mode = auto uses mobile mode
workspace.editorAssociationsRecord<string, string>{}Glob-pattern to editor-view ID overrides for file-backed view routing
workspace.fileExplorer.autoRevealCurrentFilebooleanfalseAutomatically expand and scroll the file explorer to reveal the active file

The settings UI renders workspace.editorAssociations as a key/value table. Keys are VS Code-style glob patterns and values use a dropdown resolved through App.configurationOptionSources from the registered workspace.editorViews source. Unknown saved values are preserved and shown as custom options when allowUnknownOptions is true, so disabling or uninstalling a plugin does not destroy user associations.

The file explorer toolbar toggle and the Workspace settings toggle both read and write workspace.fileExplorer.autoRevealCurrentFile.

The mobile workspace settings are normal schema-backed workspace settings stored in app.json. Enum settings (workspace.mobile.mode, workspace.mobile.defaultPage) render through the standard select control, the booleans render as toggles, and workspace.mobile.breakpointPx uses the normal number-range control with the schema’s minimum and maximum bounds.

Declarative manifest configuration

Manifest-only and hybrid Lapis extensions can declare settings through lapis.contributes.configuration in manifest.json. The runtime path is:

  1. ContributionLapisConfigurationContribution is parsed from the manifest and installed by PluginManager.installManifestConfiguration.
  2. SchemaConfigurationSchema.register flattens nested object groups into namespaced leaf keys, validates writes with zod-backed SchemaType descriptors, and materializes defaults into app.json (or the plugin namespace).
  3. Control mappinggetSettingControlKind in packages/workspace/src/lib/components/configuration/configuration.ts maps each leaf schema to a SettingControlKind.
  4. RenderingcreatePropertySetting mounts Obsidian-compat Setting.add* helpers from packages/api/src/lib/settings.svelte.ts, which wrap @lapis-notes/ui primitives via mountComponent.

The programmatic AppSettings builder (addButton, addSearch, addMomentFormat, and similar) is not manifest-declarative; it remains the Obsidian-compat path for code-built setting tabs.

Supported schema shapes (declarative)

JSON schema shapeSettingControlKindSetting API@lapis-notes/ui
booleantoggleaddToggleSwitch
string (plain)textaddTextInput
string + enumselectaddDropdownbits-ui Select (api wrapper)
string + optionsSource (closed set)selectaddDropdownbits-ui Select
string + optionsSource + allowUnknownOptions: truecomboboxaddOptionsComboboxsearchable Input + suggestion list
string + editPresentation: "multilineText" or format: "textarea"textareaaddTextAreaTextarea
string + format: "color"coloraddColorPickerInput type="color"
string + format: "icon"iconaddIconPickerapi icon list (Popover + Button)
string + format: "date" / "time"dateaddDatePickerDateTimePickerDialog + trigger Button
string + format: "email" / "uri" / "ipv4"text (typed Input)addTextInput (email / url / text)
number or integer + minimum + maximumrangeaddSliderSlider
number or integer (no min/max)numberaddText (type="number")Input
array + enum itemsmultiselectaddDropdown (multiple)Select
array + string items + optionsSource (closed set)multiselectaddDropdown (multiple)Select
array + string items + optionsSource + allowUnknownOptions: truelistaddListSelect / Input rows with suggestions
array + primitive items (string / number / integer / boolean)listaddListInput / Switch / Button per row
array + flat object items (fixed primitive/enum/optionsSource columns)object-arrayaddObjectArrayTable + row grip reorder + add/remove + cell editors
flat primitive object value (leaf grid)object-gridaddObjectGridTable + cell editors
record object value with schema-valued additionalPropertiesobject-mapaddObjectMapTable + key editor and value control
nested object (manifest grouping)(section only)group headerslayout chrome
type: "custom"customSettingTab mountworkspace shell
anything elseunsupportedaddButton (“Edit in app.json”)ghost Button

CSS hooks

Schema-driven rows from createPropertySetting expose stable CSS classes derived from SettingControlKind via getSettingControlClassName:

  • Leaf controls: setting-control-{kind} on the root .setting-item (for example setting-control-toggle, setting-control-object-map).
  • Table-style leaf controls (object-grid, object-array): full-width column layout — title and description span the panel width with the table control underneath (not the two-column label/control row used by scalar settings).
  • Group section headers: setting-control-heading on the header row (declarative schema headings and imperative Setting.setHeading() sections).
  • Imperative section layout: setting-section, setting-section-heading, setting-section-heading-title, setting-section-heading-description, setting-section-heading-actions, setting-section-body.
  • Plugin list tabs (Core plugins / Community plugins): use Setting.setHeading() like other imperative plugin tabs; plugin rows render inside setting-section-body and span the panel via grid-column: 1 / -1. Heading actions such as reload buttons chain through Setting.setHeading().addButton(...). Heading descriptions use Setting.setDesc() before or after setHeading(), matching other setting rows.
  • Custom plugin tabs (type: "custom"): setting-control-custom on the external wrapper alongside setting-item-external.

These hooks are for workspace styling only; plugin-owned SettingTab surfaces built through AppSettings do not receive them automatically.

Dynamic option sources (optionsSource)

String schemas may declare optionsSource with an optional allowUnknownOptions flag. The settings UI resolves option lists through App.configurationOptionSources (ConfigurationOptionSourceRegistry in packages/api/src/lib/configuration-option-source-registry.ts):

  • Core sources — registered during app bootstrap (for example workspace.editorViews, wired to EditorViewRegistry invalidation).
  • Plugin sources — registered at runtime via Plugin.registerConfigurationOptionSource(id, provider). Relative IDs are prefixed with the plugin manifest id (ruleIdsmarkdown-lint.ruleIds). Registration is cleaned up on plugin unload.
  • Schema references — use fully qualified source ids in manifest JSON ("optionsSource": "markdown-lint.ruleIds").
  • optionsSourceParams — optional Record<string, unknown> on string schemas for parameterized providers (for example metadata.fieldValues with { "field": "status" }).
  • Searchable combobox — top-level string settings with allowUnknownOptions: true render as a combobox that passes query and limit to the resolver. The same combobox control is used for optionsSource columns in object-map and object-array table cells.
  • Session cache — providers may set cache: "session" to reuse resolved option lists (including queried results keyed by query and limit) until invalidation.

Dynamic sources are UI hints only; validation remains schema-local (static enum, etc.). When a source is unavailable, static enum options still render, current values are preserved, and the setting description includes a quiet hint such as Options source "plugin-id.source" is not currently available.. Mounted object-map, object-array, select, multiselect, combobox, and option-backed list controls refresh when a source invalidates.

Restore-default affordance: ghost Button + rotate-ccw on each supported row (existing behavior).

Metadata: implemented vs planned

FieldSchemaSettings UI
title, description, markdownDescriptionyesyes
defaultyesyes (+ restore default)
enumDescriptions, enumMarkdownDescriptionsyesdropdown labels
enumItemLabelsyesdropdown labels (short label; value unchanged)
orderyessort within category / group
minimum, maximum, stepyesslider / validation
minItems, maxItemsyesvalidation + object-array add/remove disable
deprecationMessage, markdownDeprecationMessageyeshide unless non-default; warning chrome when visible
format (color, icon, textarea, date, time, email, uri, ipv4)yesmapped controls above
editPresentationpartial (multilineText)textarea
optionsSource, allowUnknownOptions, optionsSourceParamsyesdynamic option lists for string, object-map, object-array, select, multiselect, and option-backed lists
pattern, minLength, maxLengthplanned
VS Code scope, tags, keywordsN/AN/A

VS Code comparison

See VS Code configuration contribution points. Summary:

CapabilityLapis declarativeVS Code
Boolean, string, enum, multilineyesyes
enumItemLabelsyesyes
number / integeryesyes
Slider (min + max)yesyes
format: color / iconyes (Lapis extension)not in public VS Code schema
Primitive arrays (editable list)yesyes
Flat primitive object gridyesyes
Array of flat object rows (table editor)yespartial / extension-specific
Dynamic record-object mapyesyes
Complex arrays/objects (nested cells, free-form rows)JSON fallbackJSON fallback
order in UIyesyes
Deprecation UXyesyes
Standard format strings (date, email, uri, …)yesyes
JSON Schema pattern / lengthplanned (TASK-PLUGIN-053)documented

Gaps and Task IDs

Prior open work is tracked by task id:

task_idScope
TASK-PLUGIN-047Spec matrix + VS Code gap documentation (this section)
TASK-PLUGIN-048order, enumItemLabels, integer + e2e-vault Phase A
TASK-PLUGIN-049Primitive array list control + e2e-vault Phase B
TASK-PLUGIN-050Leaf object-grid + e2e-vault Phase C
TASK-PLUGIN-051String format + deprecation UX + e2e-vault Phase D
TASK-PLUGIN-052e2e-vault INDEX.md + fixture checklist alignment
TASK-PLUGIN-066Array-of-flat-object object-array control + e2e Phase E
TASK-PLUGIN-067Object-array row reorder via shared @lapis-notes/ui/table-dnd
TASK-PLUGIN-073Extensible optionsSource registry for declarative settings UI
TASK-PLUGIN-074Top-level/optionsSourceParams follow-up for declarative settings UI
TASK-PLUGIN-075Searchable combobox and metadata.fieldValues option source
TASK-PLUGIN-076optionsSource combobox in table cells, query cache, developer docs

Unsupported fallback policy

Arrays whose items are objects with nested object/array columns, free-form additionalProperties, specialized string formats inside cells (color, date, textarea, and similar), or other shapes that do not map to a supported SettingControlKind render only an Edit in app.json ghost button that opens the vault configuration file in a centered in-app floating editor pane. The UI does not render partial or misleading controls for those keys.

Supported object-array rows require fixed items.properties where every column passes isTableCellSchema (string plain/enum/optionsSource, boolean, number, integer). Recommend additionalProperties: false in plugin schemas.

UI implementation policy

Declarative controls must not import shadcn-svelte directly from packages/workspace. Use Setting.add* in packages/api/src/lib/settings.svelte.ts, which mounts Svelte trees from @lapis-notes/ui. New shared editors belong under packages/api/src/lib/components/configuration/ unless promoted into packages/ui for reuse outside settings.

E2E vault contract (plugin-test)

The tracked vault e2e-vault/ includes manifest-only community plugin plugin-test (.obsidian/plugins/plugin-test/manifest.json). It is the manual regression surface for declarative settings widgets (not only commands or status bar).

PhaseManifest keys (examples)UI under test
Adisplay.mode (enumItemLabels, order), behavior.retryCount (integer)dropdown labels, sort order, integer/slider
Bdisplay.tags, behavior.flagsprimitive string/boolean lists
Cbehavior.limits (flat object)object grid
Ddisplay.contactEmail, display.docsUrl, display.scheduledAt, display.legacyModetyped formats; deprecation
Ebehavior.rules (object array), behavior.unsupportedComplexRowsobject-array table; JSON fallback

Persisted defaults live in e2e-vault/.obsidian/app.json under flattened plugin-test.* keys. The fixture note e2e-vault/plugin-test/Declarative Plugin Fixture.md lists controls and manual verification steps. Run pnpm e2e-vault:prepare before manual QA; do not edit generated e2e-vault-temp.

Reactivity

Configuration values are Svelte 5 $state under the hood. Components that read configuration via getConfiguration() automatically re-render when values change. The updated event provides an imperative hook for non-Svelte consumers.

Nested object-group metadata is preserved when schemas are flattened into persisted keys, so the settings UI can keep meaningful subsection headings and descriptions even though persisted configuration remains key-value based.

Current State

The test story is uneven across the monorepo.

The mobile workspace focused E2E suite exercises the touch-first shell contract: mobile mode switching, mounted sidebars, right-edge-aligned sidebar reveal, pointer-driven pan gestures with live drag progress and snap settlement, main-panel tap-to-close behavior, center-panel anchored floating controls, scroll-hidden mobile chrome, left-sidebar search routing, the Vaul-backed more-actions drawer, mobile menu drawer rendering, mobile tab actions drawer, mobile settings dialog/sidebar mounting, and startup error tracking for compatibility status-bar plugins.

What exists today

  • the root package defines shared build, test, lint, and formatting commands
  • the root package now defines pnpm test:smoke, the app smoke gate documented in AGENTS.md; it runs the workspace dev-server boot suite and the Electron desktop boot suite when a change can affect the running app; local runs use the desktop dev renderer while CI keeps the full desktop production build
  • the root package now defines pnpm test:daily-use, which runs the workspace Chromium daily-use journey after app smoke in CI and pnpm docker:ci-check; the journey covers note creation, rename persistence, search indexing, notebook execution, reload, and restored notebook outputs
  • the root package now defines pnpm test:official-plugins, which builds the local official external plugin packages, creates a signed local registry fixture with test-only keys, runs the workspace Playwright official-plugin matrix, installs every plugin from the release tooling’s official definition list through the real registry installer with official provenance required, asserts default enablement, install progress cadence, ESM-only runtime diagnostics, reload persistence, and one runtime smoke assertion each for Canvas, Docs, Graph, Markdown Lint, Notebook, PDF, Slides, and Telemetry; the shared CI verify action, Forgejo browser lanes, and official plugin asset publish workflow bootstrap scripts/ensure-xvfb-run-deps.sh before running this command under xvfb-run; when the local registry builder is asked to --skip-build, it now still materializes missing official plugin dist/ outputs from Turbo cache in the current job before packaging fixtures
  • after any code change, the expected validation path is to run the narrowest relevant pnpm check:all command (package-local or repo-root) on modified packages so formatting, source-resolution guards, script tests, package-local static checks, ESLint, spec markdown lint, Svelte warning regressions, and TypeScript regressions are exercised before finishing the change; also run pnpm --filter <package> test (or turbo run test --filter=…) on each modified package that defines tests; run pnpm test:smoke only when the change can plausibly affect workspace or desktop boot, runtime, UI, plugin loading, or related cross-package integration
  • .forgejo/workflows/checks.yml runs a warmed cache-build job first (pnpm ci:build:checks) and then fans out independent CI lanes for repo-checks (static checks), unit-tests, app-smoke, daily-use-e2e, and official-plugin-e2e with strategy.fail-fast: false so one lane failure does not cancel the others; each browser lane uploads Playwright artifacts with actions/upload-artifact@v3; and Turborepo remote cache runs against https://turbo-cache.app.ju.ma while every lane uses the warmed code.ju.ma/lapis-notes/lapis-ci:latest container image
  • the root package defines check:all, which runs check:source-resolution, plugin-host:check, test:scripts, turbo run check:all, check:format:root, lint:root, and spec:lint as the default post-change validation entrypoint; see Monorepo Scripts
  • the root check:all flow runs check:source-resolution before Turbo package checks so first-party Vite development graphs fail fast when any local @lapis-notes/* import resolves to a package dist/ artifact instead of the dependency’s src/ tree
  • individual packages expose package-local check:all and targeted check:* scripts so work can be checked narrowly without always running the root workflow
  • Turbo orchestrates package check:* tasks in parallel with local caching under .turbo/ and optional hosted remote cache in CI; package-local tools stay quiet on success through scripts/run-quiet-check.mjs
  • the root package defines check:types, which runs package-local check:types scripts across the workspace through Turbo so cross-package TypeScript regressions fail in one place instead of surfacing piecemeal during plugin development
  • the root package defines check:format, which runs turbo run check:format across workspace packages and then check:format:root for repo-root paths outside packages (scripts/, spec/, agent docs, CI workflows, and similar)
  • package check:all runs static checks only through scripts/run-package-check-all.mjs; tests run separately through pnpm test or package-specific test scripts
  • the root package defines pnpm e2e-vault:prepare, which stages and verifies a full copy of the tracked e2e-vault fixture (including root INDEX.md) before atomically swapping it into a generated writable vault copy at e2e-vault-temp by default, or a caller-provided path for future e2e harnesses. pnpm media:site consumes that prepared copy for public-site screenshots, restores the tracked default workspace layout for the landing hero still, and uses Workspace: Focus Leaf before capturing the full workspace shell for view-specific stills. The script renames an existing target aside instead of deleting it in place, so --force replace is reliable when the vault is closed; if the swap fails, the error message points at a retained staging directory for recovery. Generated targets are replaced on every run; unmarked existing custom targets require --force. Success is indicated by .lapis-e2e-vault-copy plus root INDEX.md in the prepared target.
  • the root package defines pnpm test:scripts, which runs Node tests for repo scripts such as scripts/prepare-e2e-vault.mjs, and the root check:all flow includes it before Turbo package checks.
  • newly added packages should define package-local check:all, check:format, and check:types scripts, plus check:svelte when the package owns a Svelte UI surface, and that Svelte check should fail on warnings so accessibility and reactive-state regressions stop the normal package check path instead of relying on a second repo-wide warning pass
  • the root package defines spec:lint and spec:lint:fix for markdownlint-based spec checks; root check:all already runs spec:lint, and the root/spec Makefiles still expose spec-lint, spec-lint-fix, lint, and lint-fix aliases for narrow spec-only passes
  • packages/api has the strongest explicit test surface, including Vitest tests and parity fixtures under test/parity
  • packages/api browser-storage tests now cover portable browser-vault transfer behavior in both directions, including import overwrite semantics and export into a user-picked directory handle
  • packages/api now also has focused workspace-model coverage for grouped sidebar layout compatibility, grouped layout round-trips, traversal through grouped leaves, active-leaf restore, sidebar placement defaults, hidden panel persistence, and leaf/group conversion helpers
  • packages/api workspace-model tests now also cover layout drag/drop event ordering and cancellation plus floating-pane and supported/unsupported popout host capability paths, so drag/drop helper regressions fail without needing a real secondary window
  • packages/api now validates SQLite app-database bind compatibility in tests with a sqlite-wasm-like mock that rejects malformed or unused named parameters, so worker-only query regressions such as invalid bind() names fail in CI instead of only in browser sessions
  • packages/api now also covers plugin-manager boot order, failure rollback, manifest preflight, runtime-aware community plugin preflight, failed plugin restart diagnostics, and vault-scoped workspace-trust gating for trusted desktop runtime entries plus brokered capabilities with targeted integration tests
  • packages/api now also covers frontmatter mutation and metadata-type regressions, including first-block creation, last-property block removal, no-op suppression, concurrent frontmatter write retry, nested property membership cleanup, nested rename collision reporting, and falsey-value-preserving metadata renames
  • packages/api now also covers Bases custom-view registration persistence across plugin startup order so plugin-contributed .base renderers survive enable-order differences
  • packages/plugins/plugin-bases includes a test script and now has a focused regression check around virtualized Bases table search state so empty filtered result sets do not reuse stale virtual rows while scrolling
  • packages/plugins/plugin-bases now also preserves custom view types when .base documents are normalized back from source mode so plugin-registered task-specific Bases views do not collapse to the fallback renderer
  • packages/plugins/plugin-canvas now includes focused Vitest coverage for the JSON Canvas serializer and Svelte Flow bridge, and packages/workspace now carries Playwright canvas smoke coverage that opens .canvas files, verifies the canvas renders without the old top banner, confirms the built-in controls stay anchored in the top-right corner, checks colored nodes and arrow-ended connectors render in the workspace shell, checks the loaded graph is fit into view, ensures dragged nodes stay clipped to the pane body, and seeds text, file, link, and group nodes together so runtime page errors such as Svelte effect loops fail the smoke path
  • packages/plugins/plugin-graph now includes focused Vitest coverage for graph assembly, orphan filtering, and local-graph depth derivation from metadata cache fixtures
  • packages/plugins/plugin-telemetry now includes focused Vitest coverage for the trace-tree helpers that drive the local telemetry waterfall and span-search behavior
  • packages/notebook now includes focused Vitest coverage for notebook parsing, dependency graph construction, DuckDB app-table registration, dedicated worker-client transport behavior, and cross-cell execution/value propagation
  • packages/notebook now also includes focused Vitest coverage that Safe Mode blocks active notebook execution commands before they hand work to the runtime
  • packages/workspace now has a Playwright browser regression suite for markdown rendering, exercising live-preview and source-mode heading gutter alignment, wrapped plain-indent guides, nested list rendering, live-preview blockquote widgets, source-mode markdown table reformatting on Tab and Enter, source-mode cm-table line styling, table widgets, and reading-view embeds against the real workspace shell
  • packages/workspace/e2e/pwa-title-bar.spec.ts now clones the production Window Controls Overlay media rules, injects simulated native-control blocker rectangles, and verifies pane-owned top-titlebar geometry stays in flow while interactive controls avoid those blocked regions on macOS and Windows, alongside sidebar split-inset checks, painted-icon visibility, blocker-based non-overlap for sidebar tab buttons, WCO border coverage (including a :root::before 1px divider probe at --workspace-safe-area-top, an outer top-tab border-bottom guard against active-tab notch leakage, and a [data-sidebar="rail"] extends-into-safe-area-strip assertion), an elementsFromPoint probe asserting the sidebar dock radio is the topmost paint at its own centre under WCO (regression guard against root pseudo-elements masking the icons), and reference screenshots clipped to the macOS top-left/top-right and Windows top-right corners under e2e/__screenshots__/pwa-title-bar.spec.ts/
  • packages/workspace/e2e/markdown-showcase.spec.ts opens the tracked e2e-vault/plugin-markdown/CodeMirror Layout Showcase.md content and checks source mode, live preview, and reading mode against the same fixture for tables, grid tables, quoted lists, indentation, wrapping, embeds, links, tags, math, and code rendering.
  • packages/workspace/e2e/csv-editor.spec.ts covers CSV table preview/source sync, 1-based row numbers, header edits, preview undo, column move menu, selection grips, and source-mode lint diagnostics against tracked e2e-vault/plugin-csv fixtures.
  • packages/plugins/plugin-markdown now also has focused widget regressions that exercise text-widget commit behavior on blur and Enter plus checkbox writes on change, which protects the shared frontmatter editor against focus-handoff and remount data loss
  • packages/workspace now also covers frontmatter editing regressions through the real shell, including live-preview property rename, File Properties checkbox commits against vault-backed note contents, and frontmatter-to-search propagation without a manual rebuild
  • packages/workspace now also includes focused Playwright coverage for open-note file lifecycle behavior, proving an active note retargets on rename and closes cleanly on delete instead of keeping stale leaf state
  • packages/workspace/e2e/markdown-hashtag-lint.spec.ts collapses both workspace sidebars and captures Chromium screenshot baselines for source-mode hashtag pills under synthetic cm-lintRange geometries (full-line wrap, prefix split, trailing lint after the tag name, mid-line tags, path tags, and dual-tag lines) via __LAPIS_SYNTHETIC_LINT__ without depending on markdownlint rule IDs
  • packages/workspace/e2e/markdown-lint-tooltip.spec.ts collapses both workspace sidebars before capturing Chromium screenshot baselines for markdownlint MD041 hover tooltips (lapis-lint-tooltip.svelte inside the Lapis-owned .cm-lapis-tooltip), verifies users can move from the lint range into the tooltip, checks MD041 marks the full source line and keeps both the lint gutter row and visible marker centered against the line-number gutter, verifies lint gutter markers also open that custom tooltip, and covers the inline problem widget opened through View Problem, including clipping and below-line placement regressions
  • packages/workspace now also exercises shared menu regressions through the real markdown workspace surface, including right-click table context-menu submenus plus pane-header dropdown menus, and asserts those paths no longer throw the previous Object.entries requires that input parameter not be null or undefined runtime error in Chromium
  • packages/workspace now runs its app Playwright suite against Chromium, matching the Electron renderer engine, and the workspace shell owns a canonical Chromium visual-regression fixture that seeds the file explorer plus a multi-tab, multi-split layout
  • packages/workspace now also includes focused Playwright coverage for grouped sidebar views, including real grouped panel rendering, group-menu and visible-panel basics, vertical grouped-panel resizing, serialized panel-size state, and expanded panel content filling the available pane body
  • packages/workspace/e2e/tab-drag-drop.spec.ts now includes focused deterministic drag-policy assertions alongside the browser flows, exercising explicit strategy overrides, auto mouse/touch/pen routing, activation thresholds, touch jitter tolerance, flick cancellation, and the held-drag initiation delay alongside the existing Playwright pointer regressions
  • packages/workspace now also includes focused Playwright coverage for Safe Mode recovery, covering a failing community plugin path that enters Safe Mode with community plugins disabled plus the visible targeted disable action, and a broken saved-layout path that proves workspace.loadLayout() rejects malformed layout JSON before the shell restarts with layout restore skipped
  • packages/workspace now also includes notebook Playwright coverage for notebook opt-in commands, starter cell insertion, SQL and table output rendering, chart output rendering, downstream markdown interpolation, loading-progress UI during initial notebook execution and stale reruns after notebook source changes, cursor-scoped notebook execution, notebook runtime package install and uninstall flows through the Packages side view, notebook runtime-status and output-restoration messaging in the notebook-owned status banner, and notebook-relative CSV loading in the legacy browser vault
  • packages/workspace now also includes a Chromium-scoped daily-use journey Playwright path that boots an OPFS vault, creates and renames notes, verifies search indexing, executes a notebook, reloads the shell, and confirms the renamed note plus restored notebook outputs survive restart on the supported browser shell; e2e/daily-use-journey.spec.ts plus pnpm test:e2e:daily-use run that journey through playwright.daily-use.config.ts, and CI plus pnpm docker:ci-check invoke it through root pnpm test:daily-use after the Electron smoke phase unless --skip-daily-use is passed
  • packages/workspace now also includes focused Playwright coverage for the search sidebar query input, proving shell clicks focus the CodeMirror editor with a visible caret and ArrowUp / ArrowDown recall persisted recent searches from the start of the query
  • packages/workspace now also includes Playwright coverage for the bundled Tasks plugin, exercising markdown task-note creation, completion toggling through mapped frontmatter writes, deletion, note-opening actions, custom task-folder plus field-role mappings, inline task conversion from the current line, selection, and note-wide batches for supported prefixed task candidates, reading-mode plus source-mode and live-preview convert buttons for supported prefixed Markdown task candidates, task-note summary widgets in reading mode, source mode, and live preview, compact task-link overlays in reading mode, source mode, and live preview, partial metadata extraction for hashtags plus explicit ISO due-date tokens, opening a generated Task List .base file through the custom task Bases renderer against the real workspace shell, and a Fizzy-board regression that verifies tasks land in the expected Maybe/Open/Done lanes without surfacing the previous Svelte effect-depth page error
  • packages/plugins/plugin-tasks now also carries adapter-backed TaskNotes validation coverage for a claimed core-lite bridge against tasknotes-spec 0.1.0-draft, checking canonical mapped writes plus strict completed-date, due-date, and temporal-order rules, alongside representative meta.*, date.*, field.*, config.*, validation.*, create_compat.create, op.*, and delete.remove operations plus explicit known-deviation metadata
  • packages/plugins/plugin-tasks now also carries focused package tests for inline-task line parsing and conversion, and packages/workspace/e2e/tasks.spec.ts covers the initial Create inline task Markdown-editor command flow alongside the existing Tasks list regressions
  • packages/workspace now also covers browser SQLite coordination with Playwright two-tab regressions that boot an OPFS vault, verify owner and proxy session modes, edit a markdown note from the proxy tab, assert delegated app-database search state updates without invalid named-bind runtime errors, and verify a proxy tab promotes itself after the owner tab closes
  • packages/workspace now includes e2e/app-boot-smoke.spec.ts plus pnpm test:e2e:smoke, which runs turbo run build --filter @lapis-notes/workspace, clears stored vault profiles, boots through the normal dev-server path with a fresh Browser (OPFS) vault, waits for visible shell readiness, asserts .workspace and core app services, and fails on startup page errors plus targeted renderer console diagnostics such as derived_inert warnings and Vite Failed to load source map messages; the smoke Playwright timeout now stays above the helper’s internal boot timeout so CI failures surface the startup checklist/message snapshot instead of a raw runner timeout, cold CI workers get extra first-boot headroom beyond local runs, timeout snapshots now report chooser-loading state/message as well as in-shell startup state, and the chooser submit path falls back to the Create vault button when Enter does not dismiss the dialog; the older dev-server-startup.spec.ts path still covers additional dev-server regressions
  • packages/workspace now includes e2e/app-boot-smoke.spec.ts plus pnpm test:e2e:smoke, which runs turbo run build --filter @lapis-notes/workspace, clears stored vault profiles once per Playwright browser context, boots through the normal dev-server path with a fresh Browser (OPFS) vault, waits for visible shell readiness, asserts .workspace and core app services, and fails on startup page errors plus targeted renderer console diagnostics such as derived_inert warnings and Vite Failed to load source map messages; the smoke Playwright timeout now stays above the helper’s internal boot timeout so CI failures surface the startup checklist/message snapshot instead of a raw runner timeout, cold CI workers get extra first-boot headroom beyond local runs, timeout snapshots now report chooser state plus chooser-loading state/message as well as in-shell startup state, and the chooser submit path falls back to the Create vault button when Enter does not dismiss the dialog; the older dev-server-startup.spec.ts path still covers additional dev-server regressions
  • packages/workspace/e2e/mobile-workspace-stage-gesture.spec.ts now covers stepped mid-screen touch pointer paths that must fully open left/right mounted sidebars (track translateX settles to 0 / -2 * sidebarWidth) and short swipes that snap back to center without opening a sidebar
  • packages/workspace/e2e/mobile-workspace-mode.spec.ts now covers the mobile workspace shell in Chromium, proving the shared renderer switches between auto, forced-desktop, and forced-mobile display modes through the registered workspace commands, and exercising the compact always-visible floating dock, hidden full view header plus visible editable mobile inline title inside the active view content, title scroll-out with the editor page, scroll-hidden top chrome, single-page mobile leaf viewport without the desktop root tab strip, floating left toggle, pointer-driven mounted-sidebar pan gestures with live pre-release transform updates, snap-back, touch-style reveal, and DevTools-style mouse close, body-only sidebar content with bottom name selectors, capped main-tab-only live mobile tab preview tiles, fixed open-tab count and Done controls, the mobile tab actions drawer, mobile app-owned menus rendered through drawers, left-sidebar search entry point without opening the command palette, settings sidebar recovery, and Vaul-backed bottom action drawer without runtime page errors; mobile-workspace-navigation.test.ts, mobile-inline-title.test.ts, mobile-workspace-stage-helpers.test.ts, mobile-sidebar-panel.test.ts, and mobile-scroll-chrome.test.ts also cover the mobile sidebar editor-stage state transitions, inline title fallback, deterministic reveal offsets, swipe input/direction decisions, pan snap-point decisions, sidebar selector labels, and scroll chrome state directly
  • packages/desktop-electron now includes e2e/app-boot-smoke.spec.ts plus pnpm test:e2e:smoke, which compiles main/preload and boots through the Vite dev server locally (or runs the full production build in CI), launches Electron with isolated user data and an Electron-safe environment that strips ELECTRON_RUN_AS_NODE, asserts the vault chooser and workspace mount, and fails on renderer or main-process startup errors plus the same targeted renderer console diagnostics before the broader startup-shell integration suite runs. Focused desktop E2E also covers the shared Plugin registry settings tab so registry UI changes cannot ship browser-only.
  • packages/desktop-electron now includes package-local Playwright Electron coverage that launches the built app with isolated user data, auto-selects ~/Documents/vault-copy, seeds and opens Untitled.md, asserts the visible note header, verifies native file-watching by writing a file outside the renderer, smoke-tests the available plugin-sidecar lifecycle plus initial brokered capabilities, exercises the pane-header Split right action, covers grouped sidebar rendering/menu/resizing behavior against the real packaged shell, verifies vault-backed transformers-js semantic indexing with an explicitly selected embedding model, confirms the packaged desktop app returns vector hits for that indexed vault data, settles the footer semantic-search status item back to its ready/completed state, and exercises native Markdown language-service diagnostics through desktop_ls_capabilities and desktop_ls_diagnostics without main-process sidecar failures
  • packages/workspace now also includes a Playwright History regression that seeds tracked file revisions, opens the History sidebar, launches the fullscreen compare overlay, asserts the vendored two-pane diff surface mounts through the real workspace shell without runtime import failures, verifies syntax-highlight overlay tokens render, exercises saving edits back through the editable current-file pane, and restores a selected revision through the overlay
  • e2e-vault is now the canonical tracked sample vault for broad manual and future automated app coverage. Its plugin-* folders provide documentation-like content for markdown, bases, canvas, csv, graph, search, history, notebook, pdf, slides, telemetry, notifications, markdown-lint, Harper, Tasks, and a vault-local manifest-only plugin-test community plugin. The plugin-csv folder documents CSV table preview, source rainbow highlighting, lint fixtures, and settings verification; see e2e-vault/plugin-csv/CSV Feature Tour.md. The plugin-test fixture also exercises declarative settings widgets (object groups, enums, lists, object grid, typed string formats, deprecation, and unsupported JSON fallback) through Settings → Community plugins → Plugin Test Fixture; see e2e-vault/plugin-test/Declarative Plugin Fixture.md and Configuration and Settings. The tracked source vault should remain clean; use pnpm e2e-vault:prepare before running workflows that mutate vault content.
  • most other packages lean more heavily on build, prepack, and svelte-check than on dedicated automated tests
  • manual desktop release paths run the same verification gates before building or uploading release assets unless explicitly skipped; see Release Management

What this means architecturally

  • the runtime package is the most obvious candidate for regression protection
  • view-layer and workspace-shell behaviors still depend heavily on manual validation outside the covered markdown, shell-visual, notebook, tasks, canvas, history, and startup slices
  • the workspace shell now has a small but concrete browser-level regression surface for markdown view rendering, which is the right place for geometry-sensitive bugs that unit tests miss
  • the desktop host now has a packaged Electron smoke path, so shared-renderer startup regressions can fail in automation instead of relying only on manual local host checks
  • plugin lifecycle now has targeted manager coverage, but layout persistence and workspace-shell plugin flows still need broader integration coverage
  • the spec now has a markdown style check that can be run independently or paired with mdbook build spec through Makefile targets

Documentation gap this spec addresses

Several package READMEs are still template defaults. That makes code inspection the main source of truth for architecture today. This book is intended to reduce that gap and give future tests and ADRs a stable narrative reference.

High-value future coverage

  1. broader workspace-shell integration for community plugin loading, dependency injection, and failed-view fallback beyond the current plugin-manager and Safe Mode slices
  2. metadata-cache to chunk and vector search propagation beyond the current frontmatter-driven search update coverage, including embedding invalidation
  3. multi-leaf and non-markdown rename/delete lifecycle behavior beyond the current active-note coverage
  4. markdown and bases view mode transitions beyond the current markdown rendering regression slice

Complete Daily-Use App

This page defines the release-readiness target for Lapis Notes as a complete, daily-usable notes app. It exists to keep the next implementation phase focused on the core loop instead of expanding platform or feature surface area.

Release Goal

Lapis Notes is ready for a complete daily-use release when a user can rely on the app for local-first note taking across the approved browser/PWA and Electron hosts without losing notes, layout, settings, or the ability to recover from common failures.

Core User Journey

The release target is the end-to-end loop below.

  1. Open an existing vault or create a new vault.
  2. Create, edit, rename, and delete Markdown notes.
  3. Preserve or edit frontmatter safely.
  4. Link notes together and navigate those links.
  5. Search note content and basic metadata.
  6. Create a notebook and run simple explicit cells.
  7. Enable or disable first-party and compatible plugins.
  8. Quit or reload the app and reopen without unexpected data or layout loss.
  9. Recover from common startup, plugin, layout, metadata, search, or notebook failures.

In Scope

Must Have

  • Browser or PWA local vault usage.
  • Electron folder-backed vault usage.
  • Markdown note creation, editing, rename, and deletion.
  • Safe frontmatter preservation or editing.
  • Note linking and link navigation.
  • Safe link behavior during note rename.
  • Basic metadata indexing.
  • Basic full-text search.
  • Notebook creation with explicit cell execution.
  • Clear notebook output persistence or output lifecycle semantics.
  • Plugin enable or disable controls.
  • Safe Mode or equivalent recovery path for bad plugins or layout state.
  • Browser-local export or backup flow.
  • Restart or reload without losing notes, layout, settings, or required generated state semantics.

Should Have

  • Visible diagnostics when startup recovery or rebuild flows are used.
  • Journey-based automation that covers the complete daily-use path.
  • Documentation that distinguishes canonical vault data from generated app state.
  • Clear host-specific validation instructions for browser/PWA and Electron.

Explicitly Deferred

  • Advanced graph optimization.
  • Map or calendar views.
  • Complex TaskNotes parity beyond the current daily-use milestone.
  • LLM or reranker integrations.
  • Full VS Code extension parity.
  • Advanced plugin isolation beyond the current trusted and fallback tiers.
  • Advanced collaborative sync.

Reliability Checks

  • Startup succeeds for the browser/PWA host and the Electron host.
  • Vault contents remain canonical when metadata, search, or notebook generated state is missing or rebuilt.
  • A failed plugin or broken layout does not trap the user outside recovery.
  • Rename, delete, search, and notebook flows remain understandable after restart or reload.
  • Export, import, or reveal-vault flows expose where the user’s canonical data lives.

Required Test Coverage

  • Root pnpm check:all remains the default post-change validation path for every code change.
  • Root pnpm test:smoke remains the pre-commit gate for changes that can affect the running workspace or desktop app; unrelated script, docs-site, roadmap, and spec-only work should stop at the relevant check:all path.
  • At least one journey-based automated test covers the complete user loop from vault open or creation through restart.
  • Focused tests cover plugin failure recovery, layout-restore failure recovery, and generated-state rebuild paths when those behaviors change.

Required Documentation

  • The spec documents the complete daily-use release target on this page.
  • Recovery flows document what is canonical user data and what is rebuildable generated state.
  • This checklist links to File System, Metadata, and Search, Workspace App, and Notebook Plugin for the browser-local import/export, desktop reveal-folder, generated-state rebuild, and notebook output-clearing flows that support this milestone.
  • Testing docs point at the journey coverage and smoke gate.
  • Roadmap and agent guidance point back to this milestone before expanding scope.

Required Recovery Flows

  • Start with community plugins disabled.
  • Start with layout restore disabled.
  • Rebuild metadata generated state without deleting notes.
  • Rebuild search generated state without deleting notes.
  • Clear notebook generated outputs without deleting notebook source.
  • Disable notebook execution temporarily for recovery.
  • Import, reveal, or export canonical vault data from supported hosts.
  • Surface the last startup failure reason when practical.

Known Blockers and Tracked Work

The current release-critical implementation sequence is:

  1. Issue 77: Define a complete daily-use app release milestone and acceptance checklist
  2. Issue 82: Clean up milestones and agent workflow guardrails
  3. Issue 78: Add Safe Mode and recovery flows for startup, plugin, layout, metadata, and search failures
  4. Issue 80: Harden data export, import, backup, and generated-state rebuild flows
  5. Issue 79: Polish Notebook v0 into a reliable, understandable feature
  6. Issue 81: Add journey-based e2e and smoke tests for the complete daily-use path
  7. Issue 90: Add explorer copy path, native file actions, and Lapis app URLs
  8. Issue 91: Replace plugin list diagnostics with VS Code-style Features panel
  9. Issue 95: Restore metadata cache from database or portable backup

New release-critical work should either fit this milestone directly or become a deferred follow-up issue instead of widening the current release.

Release Gate

Do not tag a complete daily-use release until all items below are true.

  • The core user journey works on the approved browser/PWA and Electron hosts.
  • Recovery flows exist for startup, plugin, layout, metadata, search, and notebook failure cases within the scoped release target.
  • Data-safety flows explain and protect canonical vault files.
  • Notebook execution behavior is explicit, understandable, and survives restart with clear semantics.
  • Journey automation and smoke coverage pass on the supported validation path.
  • Deferred work remains outside the milestone unless its acceptance criteria are promoted explicitly.

Scope Control Rule

Stop adding platform surface area until the daily-use loop is reliable, recoverable, and test-covered.

Full Registry V1

This page defines the milestone for signed plugin registry distribution in Lapis Notes. It is intentionally separate from the Complete Daily-Use App release milestone so plugin distribution work can move without widening that release target.

Release Goal

Lapis Notes supports installable plugins distributed through a signed official registry while preserving the existing Obsidian-compatible plugin runtime.

The app can fetch verified registry metadata, install verified plugin files into the existing .obsidian/plugins/<plugin-id>/ layout, record provenance outside plugin-controlled manifests, manage installed plugins from Settings, and pilot the flow with the first official installable Docs plugin.

In Scope

  • Locked built-in official registry source.
  • Signed catalog, plugin detail, and release metadata.
  • Browser-compatible hash and signature verification.
  • Verified staging installer, update, and uninstall flows.
  • Installed provenance state in .obsidian/installed-plugins.json.
  • PluginDistributionManager exposed through App beside PluginManager.
  • Settings UI for Installed, Browse, Updates, and Sources.
  • On-demand install prompt for official Docs file handlers.
  • Monorepo release tooling for official plugin packages.
  • Docs plugin pilot from package artifact to registry install.

Non-goals For V1

  • Replacing PluginManager.
  • Trusting plugin manifests as proof of official, verified, or core status.
  • Arbitrary user-added third-party registry sources.
  • Full TUF timestamp, snapshot, or delegated target metadata.
  • Chunked or multi-entry plugin bundles beyond the first pilot needs.
  • Moving Bases or other baseline/core plugins out of the bundled bootstrap.
  • Silent installation or enablement of privileged desktop plugins.

Policy

Core is a bundling and runtime ownership concept. Official is a provenance and signature concept.

Downloaded official plugins remain community-style installed plugins at runtime. They are official only when they come from a verified official registry entry and a verified official release signature. True bundled core behavior remains reserved for code registered by the app bundle.

The official registry source is locked for v1. Community or manual plugins may still load through the existing plugin runtime, but the UI must label them as community, manual, or unverified unless provenance is recorded by the distribution layer.

The external registry repository layout, generated metadata contract, signing roles, static hosting requirements, and first Docs pilot registry shape are defined in Plugin Registry Distribution.

Ordered Backlog

The Full Registry V1 implementation sequence is:

  1. ROADMAP-PLUGIN-REGISTRY-001: Define Full Registry V1 milestone and spec contract.
  2. TASK-PLUGIN-REGISTRY-001: Add plugin registry metadata schemas and verification primitives.
  3. TASK-PLUGIN-REGISTRY-002: Implement official registry client and cache.
  4. TASK-PLUGIN-REGISTRY-003: Implement installed plugin state and verified installer.
  5. TASK-PLUGIN-REGISTRY-004: Expose PluginDistributionManager through App services.
  6. TASK-PLUGIN-REGISTRY-005: Add Plugins settings registry UI.
  7. TASK-PLUGIN-REGISTRY-006: Add monorepo packaging and signing tooling for official plugins.
  8. TASK-PLUGIN-REGISTRY-007: Create official registry repository bootstrap plan and first generated registry.
  9. TASK-PLUGIN-REGISTRY-008: Package Docs as the first official installable plugin.
  10. TASK-PLUGIN-REGISTRY-009: Add on-demand install for Docs file handlers.
  11. TASK-PLUGIN-REGISTRY-010: Move Docs out of bundled workspace bootstrap after registry install works.
  12. TASK-PLUGIN-REGISTRY-011: Add update and revocation handling for installed official plugins.
  13. TASK-PLUGIN-REGISTRY-PUBLISH-REMAINING-OFFICIAL: Publish and activate remaining official plugin registry releases.
  14. TASK-PLUGIN-REGISTRY-UNBUNDLE-REMAINING-OFFICIAL: Remove migrated official plugins from bundled workspace bootstrap.
  15. TASK-PLUGIN-REGISTRY-MARKDOWN-LINT-OFFICIAL: Move Markdown Lint into the official plugin registry.

TASK-PLUGIN-REGISTRY-PUBLISH-REMAINING-OFFICIAL includes the app-repo local asset publisher and Forgejo workflow for building, signing, staging, and publishing immutable official plugin assets to Forgejo release downloads under the app repo. Registry metadata activation remains owned by the registry repo after those assets exist.

Do not remove a first-party plugin from the bundled workspace bootstrap until the verified install path works in both browser/PWA and Electron.

Docs Pilot

Docs is the first official installable plugin pilot.

The pilot is successful when:

  • Docs has a decided official installable ID and documented migration policy from the current bundled docs ID if that ID changes.
  • The package emits manifest.json, main.js, and styles.css suitable for standalone installation.
  • The official registry advertises Docs with .lapisdoc and .lapissheet contribution summaries.
  • The app can install, enable, restart, open Docs files, disable, and uninstall Docs in browser/PWA and Electron.
  • Docs is removed from bundled workspace bootstrap only after that flow passes.

Remaining First-Party Migration

Canvas, Graph, Markdown Lint, Notebook, PDF, Slides, Telemetry, and Docs now use official installable lapis-* IDs and are external to the bundled workspace bootstrap. Installed official records load from /.obsidian/plugins/<id>/, remain compatible with community-plugins.json enablement, and appear under Core plugins when their installed provenance is official. First-party packages remain in the monorepo for official artifact builds and publish flows.

Bases remains bundled for this phase. Its registry entry, if present, must stay pending/non-installable until a separate issue changes that ownership decision.

Markdown itself remains bundled; Markdown Lint is an external official plugin with manifest id lapis-markdown-lint while preserving the markdown-lint.disabledRules configuration namespace.

Release Gate

Do not consider Full Registry V1 complete until all of these are true:

  • The app verifies official registry, detail, and release metadata.
  • .lapis-plugin bundles and every signed bundled file are hash-checked before final install.
  • Installed provenance is stored outside plugin-controlled manifests.
  • Official, community, manual, and bundled provenance are visually distinct.
  • Desktop-only or privileged plugins are blocked or gated by Workspace Trust.
  • Install, update, uninstall, and failure states are recoverable.
  • The Docs pilot works end to end in browser/PWA and Electron.
  • Existing manually installed community plugin loading remains compatible.

Validation

Use the narrowest package checks for each issue:

  • API distribution work: pnpm --filter @lapis-notes/api check:all.
  • Workspace UI or app integration: pnpm --filter @lapis-notes/workspace check:all.
  • Script or cross-package work: pnpm check:all.
  • App-runtime or bootstrap behavior: pnpm test:smoke.
  • Spec changes: make spec-lint and mdbook build spec when practical.

Every implementation commit that touches code should keep the owning package or contract spec current.

Spec Maintenance

The spec is both human-facing architecture documentation and agent-facing navigation. Keep it organized by ownership so a reader can start from the source package and follow links only when behavior crosses package boundaries.

Where New Docs Belong

Change areaDocumentation home
Single package behaviorspec/src/20-packages/<package>/
Single plugin behaviorspec/src/20-packages/plugins/<plugin>/
Package-local current functionalitypackages/<package>/spec.md or packages/plugins/<plugin>/spec.md
Shared runtime contractsspec/src/30-cross-package-contracts/
System shape, boot flow, host targetsspec/src/10-architecture/
Public site legal or policy pagespackages/lapis.md/src/pages/ plus the lapis.md package spec
Test strategy and workflowsspec/src/40-testing/
Release readiness and milestone scopespec/src/50-roadmap/
Accepted architectural decisionsspec/src/90-decisions/
Durable unimplemented tasksowning package, contract, roadmap, or ADR page
Temporary implementation plansspec/working/active-plans/

If a change touches multiple packages, document each package-owned responsibility on the package page and put the shared rule in a cross-package contract. Avoid duplicating the same contract in several package pages.

Package-local spec.md files are concise current-functionality mirrors for people and agents working inside a package directory. Keep them aligned with the rendered package page, but keep detailed cross-package contracts and ADR context in spec/src/.

Required Navigation Updates

Every rendered spec page must be linked from SUMMARY.md. When adding, moving, or deleting pages, update that file in the same change and run mdbook build spec when practical.

Markdown Linting

Run make spec-lint from the repo root, or make lint from inside spec/, after every update under spec/. Use make spec-lint-fix from the root, or make lint-fix from inside spec/, to apply markdownlint fixes automatically, then rerun the lint target.

markdownlint-cli2 reads the root .markdownlint-cli2.jsonc file. That config lints spec/**/*.md and packages/**/spec.md, excludes the generated mdBook output, and excludes temporary active-plan markdown files under spec/working/active-plans/.

Durable State Versus Temporary State

Durable implementation truth belongs in spec/src/. Temporary checklists, blockers, handoff notes, and in-progress implementation state belong in spec/working/active-plans/ and should be deleted when the task is complete.

Durable Follow-Up Tracking

When a spec page mentions behavior that is planned, pending, missing, or not implemented, keep the detailed context in the package, contract, ADR, roadmap, or parity page that owns the behavior. Use stable task IDs only when they still help connect related pages.

Use spec/working/active-plans/ only for short-lived execution state while a task is active. When the implementation lands, update the owning spec page and remove the temporary active plan.

ADRs

Use an ADR when the codebase accepts a direction that changes package boundaries, host strategy, data ownership, plugin contracts, persistence shape, or long-term testing expectations. Keep implementation pages accurate about current code and link to ADRs for accepted future direction.

Monorepo Scripts

The repo uses pnpm for dependency management and Turborepo for task orchestration and caching across workspace packages.

Naming rules

Root scripts use <verb>:<target> grouping:

CategoryExamples
Devdev:workspace, dev:desktop, dev:web, dev:site
Buildbuild:all, build:desktop, build:web, build:site
Previewpreview:web, preview:desktop, preview:site
Dist / releasedist:desktop:mac, dist:desktop:linux, release:desktop:local
Plugin releaseplugin-release:package, plugin-release:sign, registry-assets:*
Killkill:workspace, kill:desktop
Checkcheck:all, check:format, check:types, check:source-resolution
CI / Dockerdocker:ci-check, ci:image:build, ci:image:push
Testtest, test:watch, test:smoke, test:daily-use, test:scripts
Format / lintfmt, lint, lint:fix
Site mediamedia:site

Package scripts keep short names (dev, build, preview, test) plus check subtasks:

  1. check:format, check:svelte, check:types, package-specific check:*
  2. check:all — runs the applicable static check:* subtasks and lint for that package through scripts/run-package-check-all.mjs
  3. fmt, lint, and lint:fix when the package owns lintable source
  4. test when the package has unit tests (never invoked from check:all)

Turbo

turbo.json defines the task graph for build, check:*, fmt, lint, test, dev, and preview. Root scripts call Turbo for cross-package work:

pnpm build:all
pnpm check:all
pnpm check:format
pnpm check:types
pnpm test
pnpm fmt
pnpm lint
turbo run check:all --filter=@lapis-notes/workspace...

Turbo runs package tasks in parallel, respects workspace dependency order, and caches unchanged package results under .turbo/. Forgejo/GitHub CI and optional local pnpm docker:ci-check --remote-cache runs also use the hosted cache at https://turbo-cache.app.ju.ma; see Release Management.

build:web runs pnpm --filter @lapis-notes/web build directly instead of Turbo ^build, matching deploy (nixpacks.toml). The web host bundles first-party plugins from source through its Vite aliases, so it does not need published plugin dist/ artifacts first. The @lapis-notes/web#build Turbo override also sets dependsOn: [] for callers that invoke Turbo directly. Use build:all or turbo run build --filter=@lapis-notes/workspace... when you need the full plugin packaging graph.

Validation ladder

  1. Package-local static checks: pnpm --filter <package> check:all
  2. Package-local tests: pnpm --filter <package> test on each modified package that defines a test script
  3. Affected packages: turbo run check:all --filter=<package>... and turbo run test --filter=<package>...
  4. Full repo: pnpm check:all and pnpm test
  5. App smoke (when runtime may change): pnpm test:smoke

What check:all runs

Root check:all:

  1. check:source-resolution — first-party @lapis-notes/* source-resolution guards (Vite dev resolution, tsconfig import resolution, Vitest test resolution, tsconfig path sync)
  2. plugin-host:check — generated plugin-host module drift guard
  3. test:scripts — repo script unit tests
  4. turbo run check:all — each package’s static checks (check:format, check:svelte, check:types, package-specific check:*, and lint) through scripts/run-package-check-all.mjs
  5. check:format:root — Prettier for repo-root paths outside workspace packages (scripts/, spec/, agent docs, CI workflows, and similar)
  6. lint:root — ESLint for repo scripts under scripts/
  7. spec:lint — markdownlint for spec/ and package-local spec.md files

Root check:format runs turbo run check:format across workspace packages, then check:format:root for the same non-package paths.

Root fmt runs turbo run fmt across workspace packages, then fmt:root for the same non-package paths.

Root lint runs turbo run lint across workspace packages, then lint:root for repo scripts. Package ESLint is already included in root check:all through Turbo; use standalone pnpm lint when you only want lint fixes or a lint-only pass. Use pnpm lint:fix for autofix across packages plus scripts/.

Root test runs turbo run test across packages that define a test script. Use pnpm --filter <package> test or turbo run test --filter=<package> for narrower runs. test:watch delegates to Turbo with Vitest watch mode per package task.

@lapis-notes/api Vitest aliases plugin-host-cjs-provider-values.generated to a lightweight stub under packages/api/test/stubs/ so plugin-manager.test.ts avoids compiling the full generated CommonJS provider graph in CI. Legacy workspace/deps still loads lazily in that suite for install-bundle evaluation. When any install-bundle dist/main.js is missing, plugin-manager.test.ts builds the affected plugin packages through Turbo before reading their artifacts. Turbo wires @lapis-notes/api#test to build the first-party install-bundle plugin packages (canvas, docs, graph, markdown-lint, pdf, slides, telemetry, and notebook) so plugin-manager.test.ts can read their gitignored dist/ artifacts on fresh CI checkouts. Signed official-registry metadata for official-registry-fixture.test.ts lives under packages/api/test/fixtures/official-registry/v1/.

Package check:all runs static checks only through scripts/run-package-check-all.mjs. It skips any check:* or lint script the package does not define (for example check:astro on non-Astro packages). Tests run only through pnpm test or package-specific test scripts such as check:unit, never through check:all.

Dev-only Svelte derived_inert tracing

Workspace and desktop Electron Vite dev servers can register scripts/vite-plugin-svelte-derived-inert-trace.mjs (first in the plugins array) when LAPIS_SVELTE_DERIVED_INERT_TRACE=1 (or another truthy value such as true, yes, or on) is set. The plugin still runs only during vite serve; production build paths are unaffected.

Use the dedicated root scripts when you want tracing enabled:

pnpm dev:workspace:derived-inert
pnpm dev:desktop:derived-inert

Workspace and desktop Vite configs keep svelte excluded from dep optimization in both normal and tracing mode so the traced runtime patch can be enabled without changing the underlying Svelte module shape between modes.

When Svelte emits a derived_inert warning, the patched runtime logs:

  • Browser console — collapsed group [svelte] derived_inert trace with URL, filtered creation/read stacks (raw stacks as fallback), derived.fn, and parent effect metadata
  • Vite terminal[svelte] derived_inert summary via the svelte:derived-inert HMR WebSocket event

Restart dev with a clean dependency cache after enabling the plugin or upgrading Svelte so deriveds.js is not served from a stale prebundle:

pnpm --filter @lapis-notes/workspace dev -- --force
pnpm --filter @lapis-notes/desktop-electron dev -- --force

If Svelte internals change and the string patch no longer matches, Vite logs a plugin warning and dev still starts; remove or update the plugin when leaks are fixed. Use Created stack first, then Read stack, then derived.fn to locate stale $derived references.

Common entry points

pnpm install
pnpm dev:workspace
pnpm dev:desktop
pnpm dev:web
pnpm dev:site

pnpm check:all
pnpm --filter @lapis-notes/api check:all
turbo run check:all --filter=@lapis-notes/workspace...

pnpm build:all
pnpm test
pnpm fmt
pnpm lint
pnpm test:smoke
pnpm spec:lint

Official Plugin Release Tooling

scripts/plugin-release.mjs owns the monorepo-side artifact flow for official installable plugins:

pnpm plugin-release:package -- --plugin-id lapis-docs --version 0.1.0 --input dist --out dist-registry
pnpm plugin-release:sign -- --input dist-registry/lapis-docs-0.1.0/release.json --out dist-registry/lapis-docs-0.1.0/release.signed.json --key-id KEY --private-key-file private.pem
pnpm plugin-release:keygen

The package command validates manifest.json and main.js, checks manifest ID/version against the requested release, copies plugin files into deterministic path order, and writes file SHA-256/size metadata to release.json. The sign command signs canonical JSON with an operator-provided Ed25519 private key, and the bundle subcommand creates the deterministic ZIP-compatible <plugin-id>-<version>.lapis-plugin archive containing release.signed.json plus all plugin files. The archive stores release.signed.json first and compresses plugin files with deterministic DEFLATE settings: sorted paths, fixed ZIP timestamp, no comments or extra metadata, and compression level 6. Keys are local release inputs and must not be committed.

pnpm plugin-release:keygen creates a local Ed25519 release signing key set under ~/.lapis/ by default. It writes the private PEM, public PEM, raw base64 public key, and lapis-plugin-release-key.json metadata file, and refuses to replace existing files unless --force is provided. The raw base64 public key is the app-compatible value used by trust-list updates.

scripts/publish-official-plugin-assets.mjs wraps that low-level package/sign flow for Full Registry V1 official assets:

pnpm registry-assets:version
pnpm registry-assets:publish -- --dry-run
pnpm registry-assets:publish -- --verify
pnpm registry-assets:publish -- --publish

Local TTY runs without --plugins scan Forgejo and present checkboxes for official plugins that still need a release, with all unpublished plugins selected by default. Use --no-interactive to keep the previous publish-all-unpublished behavior, or --interactive to force the prompt in non-TTY contexts.

registry-assets:version updates official publishable plugin package.json and manifest.json versions before publication. Its default plugin set matches registry-assets:publish rather than every bundled/core plugin. It defaults to today’s CalVer (YYYY.M.D, for example 2026.6.1), accepts --plugins <ids> for a subset, accepts --version <calver> for an explicit date, and accepts --patch or --patch-suffix <number> for semver-compatible patch builds such as 2026.6.1-patch.1.

Dry runs use an ephemeral signing key and stage a Forgejo-release-ready forgejo-assets/ tree under release-artifacts/official-plugin-assets/ with one .lapis-plugin bundle per selected plugin/version. Release versions are read from each selected plugin package’s package.json, and publishing fails unless that package version matches the package-local manifest.json version. The publisher does not stamp one batch version across all assets. Non-dry-run signing requires LAPIS_PLUGIN_RELEASE_KEY_ID plus LAPIS_PLUGIN_RELEASE_PRIVATE_KEY_PEM or --private-key-file, or the default ~/.lapis/ key set generated by pnpm plugin-release:keygen. Verify and publish mode also require FORGEJO_TOKEN; they check existing official plugin asset releases on Forgejo before building. Availability checks use deterministic release tags of the form official-plugin-assets-<plugin-id>-<version> and call Forgejo’s release-by-tag API for each requested pluginId@version; release asset pages are fetched only when the tag response does not include the target .lapis-plugin bundle asset. Default publish-all runs skip already-published plugin/version assets, while explicit --plugins selections fail if the requested plugin/version already exists unless --resume is set. --resume keeps a partially uploaded deterministic release and uploads only missing assets. --force-overwrite is valid only with explicit --plugins; it deletes and re-uploads same-name assets in the deterministic release and must be followed by registry sync, generation, signing, and validation. Publish mode uploads each plugin/version bundle to its own app repo Forgejo release tag and mirrors the same per-plugin release to github.com/lapis-notes/releases when LAPIS_RELEASES_GITHUB_TOKEN is set. Uploads run in small batches (--upload-batch-size, default 20), and ephemeral packaging work stays under release-artifacts/official-plugin-assets/tmp/ so each run can clean that temp subtree while restaging a fresh local asset set. Selected official plugin packages are built once up front with a single turbo run build --concurrency=1 invocation plus repeated --filter <package> flags; the publisher logs whether TURBO_API, TURBO_TEAM, TURBO_TOKEN, TURBO_REMOTE_CACHE_SIGNATURE_KEY, TURBO_CONCURRENCY, and NODE_OPTIONS are set before the Turbo run so CI output shows whether the hosted remote cache is configured for that job. Low-level upload scripts are scripts/forgejo-release.mjs and scripts/github-release.mjs; both are orchestrated through scripts/publish-release-assets.mjs.

CI

.forgejo/workflows/checks.yml runs pnpm check:all, pnpm test, pnpm test:smoke, and pnpm test:daily-use on every push and pull request through the shared composite actions .github/actions/lapis-ci-job-env and .github/actions/lapis-ci-verify. Root check:all already includes package ESLint, repo-script ESLint, and markdown spec lint. The job env action sets TURBO_CONCURRENCY=1, NODE_OPTIONS=--max-old-space-size=8192, and LAPIS_SMOKE_DESKTOP_FULL_BUILD=1 for the same resource profile as pnpm docker:ci-check. Forgejo publish-official-plugin-assets.yml calls the same action with profile: publish, which keeps TURBO_CONCURRENCY=1 but lowers Node heap to --max-old-space-size=4096 so cold notebook Vite production builds are less likely to be OOM-killed on the standard runner. The workflow also exposes manual-dispatch overrides for GitHub mirroring and upload batch size, and uses the Forgejo-compatible artifact upload action version when archiving staged assets from the container job. Release verification and other warmed-image Forgejo jobs that run Turbo tasks should call lapis-ci-job-env after dependency sync; release-desktop verify also calls lapis-ci-verify. Local pnpm test:smoke uses the desktop dev renderer; CI and pnpm release:desktop:local keep the full desktop production build (LAPIS_SMOKE_DESKTOP_FULL_BUILD=1).

Repo-root .env

Local scripts that read developer-facing environment variables bootstrap repo-root .env through scripts/load-repo-env.mjs (applyRepoEnv). Copy .env.example to .env and fill in values; never commit .env. Shell exports take precedence over file values.

Supported variables, consuming commands, and defaults are documented in .env.example. Scripts that load .env include pnpm release:desktop:local, pnpm registry-assets:publish, pnpm ci:image:push, pnpm docker:ci-check, pnpm test:smoke, pnpm dev:desktop, pnpm media:site, and macOS packaging notarization hooks. CI-only variables such as GITHUB_OUTPUT and build-time commit injection (GITHUB_SHA, LAPIS_BUILD_COMMIT) are not loaded from .env.

Reproduce CI locally in Docker

When checks pass on the host but fail in Forgejo, run the same workflow inside the lapis-ci image:

pnpm docker:ci-check --help
pnpm docker:ci-check --build --skip-smoke
pnpm docker:ci-check --pull
pnpm docker:ci-check --shell

docker:ci-check bind-mounts the repo at /__w/lapis/lapis, keeps Linux root and workspace package node_modules in Docker named volumes (so macOS binaries are not reused), and defaults to --platform linux/amd64 for CI parity. Use --native-platform on Apple Silicon when you only need Linux container behavior without amd64 emulation.

Optional Turborepo remote cache is enabled with --remote-cache, or automatically when repo-root .env contains the turbo cache keys documented in .env.example. The script passes TURBO_API / TURBO_TEAM / TURBO_TOKEN / TURBO_REMOTE_CACHE_SIGNATURE_KEY into the CI container against the hosted cache server. Use --no-remote-cache to force local-only .turbo/ caching even when .env is complete.

Inside the container the script sets CI=true and LAPIS_SMOKE_DESKTOP_FULL_BUILD=1 so desktop smoke matches Forgejo checks and release verification (full production renderer build, not the local dev server).

--build and --pull forward the resolved platform (linux/amd64 by default, or the value passed to --platform) by invoking scripts/build-lapis-ci-image.mjs and docker pull --platform directly. With --native-platform, build and pull use Docker’s host-default platform and docker run omits --platform.

Use --skip-checks to omit pnpm check:all, --skip-tests to omit pnpm test, --skip-smoke to omit xvfb-run pnpm test:smoke, and --skip-daily-use to omit xvfb-run pnpm test:daily-use. Dependency sync and smoke-vault preparation still run unless you use --shell for an interactive container.

Recommended validation before closing CI-parity work:

pnpm docker:ci-check --help
pnpm docker:ci-check --build --skip-smoke --skip-daily-use
pnpm docker:ci-check --pull
pnpm check:all
pnpm test:smoke
pnpm test:daily-use

Turbo concurrency defaults to 1 inside the container and Node heap is raised (NODE_OPTIONS=--max-old-space-size=8192) so large packages such as workspace can finish svelte-check under Docker Desktop memory limits. If tasks fail with SIGKILL or FatalProcessOutOfMemory, allocate 8 GB+ to Docker before raising --concurrency.

Related image scripts: ci:image:build, ci:image:build:amd64, and ci:image:push.

Adding a package

Every workspace package under packages/** must define:

  • build
  • check:format, check:types, and check:svelte when the package owns Svelte UI
  • check:all — static validation entry point for package checks (format, svelte, types, package-specific check:*, lint)
  • fmt, lint, and lint:fix when the package owns lintable source under src/ (plus package-specific paths such as desktop Electron host code)
  • test when the package has unit tests

Keep script ordering: devbuildpreviewcheck:*check:allfmtlintlint:fixtest → package-specific scripts.

Picking the Next Task

Use this checklist when choosing the next implementation task for Lapis Notes.

Default Rule

Prefer the earliest incomplete release-critical item linked from Complete Daily-Use App.

Do not widen the release by default. If a task does not clearly support that milestone, treat it as deferred or document it separately as follow-up work.

Selection Order

  1. Finish the earliest incomplete item in the release-critical sequence.
  2. If that item is blocked, choose the next unblocked item that is explicitly listed as depending only on already-complete work.
  3. If no release-critical issue is ready, pick the smallest enabling task that removes a documented blocker for the current sequence.
  4. Only pick speculative or deferred work when the release-critical path is intentionally paused in the roadmap.

Required Inputs Before Starting

  • Read SUMMARY.md.
  • Read the owning package page and shared contract page for the area you will change.
  • Read Complete Daily-Use App if the item is release-critical.

Scope-Control Rules

  • Start from the roadmap item and spec page, not from an unrelated nearby idea.
  • If implementation uncovers adjacent work that is not required by the current acceptance criteria, document separate follow-up work instead of silently expanding scope.
  • Prefer fixes that strengthen the current milestone over work that adds new platform or feature surface area.
  • Update tests for user-visible behavior changes.
  • Update the spec when behavior, ownership, boot flow, or validation expectations change.

Completion Rules

  • Do not stop at planning if the selected item has implementable acceptance criteria.
  • Run the narrowest relevant pnpm check:all path for the touched packages before committing.
  • Run pnpm test:smoke before committing only when the change can affect the running workspace or desktop app (see AGENTS.mdValidation Before Commit).
  • Record what changed, what did not change, and any follow-up work in the commit message or handoff notes when appropriate.

Agent Task State

Agents may keep temporary implementation state under spec/working/active-plans/. These files are outside mdBook navigation and are meant to be deleted after the task finishes.

Location

Use one markdown file per active task:

spec/working/active-plans/YYYY-MM-DD-task-slug.md

The active-plan files are ignored by default so stale plans do not enter history. If a plan needs to become durable documentation, move the durable parts into spec/src/ and link them from SUMMARY.md.

If the plan corresponds to a durable unimplemented item, keep the durable context in the owning package, contract, roadmap, or ADR page before treating the active plan as the source of truth.

Required Sections

Each active plan should include:

  • Goal
  • Scope
  • Current state
  • Decisions
  • Checklist
  • Blockers
  • Validation
  • Cleanup notes

Keep the file current while working. Remove completed checklist noise before promoting anything to stable docs.

Cleanup

Delete the active plan when the task is complete. The final durable state should be represented by code, tests, and stable spec pages, not a lingering task note.

Release Management

Desktop releases are published from Forgejo releases and mirrored to the public GitHub distribution repo https://github.com/lapis-notes/releases. Homebrew installs still read Forgejo release URLs from the tap at https://code.ju.ma/lapis-notes/homebrew-tap.git.

Version Contract

Desktop release versions are read from packages/desktop-electron/package.json. Release workflows and local release scripts require the input version without a leading v and fail if it does not match that package version. Remote tags use v<version>.

Artifact Names

Electron Builder writes stable hyphenated artifact names so Homebrew scripts and release URLs do not depend on the product name with spaces:

  • Lapis-Notes-<version>-mac-arm64.dmg
  • Lapis-Notes-<version>-mac-x64.dmg
  • Lapis-Notes-<version>-mac-arm64.zip
  • Lapis-Notes-<version>-mac-x64.zip
  • Lapis-Notes-<version>-linux-x64.tar.gz
  • Lapis-Notes-<version>-linux-x64.AppImage

The macOS DMGs are the Homebrew cask inputs. The Linux tarball is the Homebrew formula input. ZIP and AppImage files are direct release assets.

Desktop package scripts pass --publish never to Electron Builder and set CI=1 plus ELECTRON_BUILDER_DISABLE_UPDATE_CHECK=true. Forgejo release creation, tag creation, checksum staging, and asset upload are owned by the release helper scripts instead of Electron Builder’s publishing integration.

Local Release Path

Run local desktop releases from the repo root. Set FORGEJO_TOKEN in repo-root .env (see .env.example) or export it for the command. Set LAPIS_RELEASES_GITHUB_TOKEN when the GitHub mirror should upload as well:

pnpm release:desktop:local -- --version <version-without-v> --targets all

On macOS, --targets all builds macOS arm64/x64 DMGs and ZIPs plus Linux x64 tarball/AppImage assets. On non-macOS machines, --targets current builds Linux and macOS targets are rejected because DMGs require macOS. The script stages assets under release-artifacts/desktop-<version>/, writes SHA256SUMS.txt, creates or updates the remote Forgejo tag/release (v<version>), uploads the staged assets, and mirrors the same files to GitHub Releases (app-v<version> on lapis-notes/releases) when LAPIS_RELEASES_GITHUB_TOKEN is set.

The local script refuses to release from a dirty JJ working copy by default. The default tag target is @-, matching the normal JJ shape where the working-copy commit @ is empty after a completed commit. Use --target-rev or --target-commit for explicit targeting. --skip-upload performs a local dry-run build/stage without touching Forgejo or GitHub. --skip-github-upload uploads to Forgejo only. Release notes include the Forgejo source commit SHA for traceability on the GitHub mirror.

GitHub releases mirror

  • Distribution repo: lapis-notes/releases on github.com
  • Desktop tag on GitHub: app-v<version> (Forgejo remains v<version>)
  • Upload script: scripts/github-release.mjs
  • Orchestrator: scripts/publish-release-assets.mjs
  • Required secret/token: LAPIS_RELEASES_GITHUB_TOKEN with contents: write on the releases repo
  • Optional repo override: LAPIS_RELEASES_GITHUB_REPOSITORY (default lapis-notes/releases)
  • CI: .forgejo/workflows/release-desktop.yml and .forgejo/workflows/publish-official-plugin-assets.yml pass secrets.LAPIS_RELEASES_GITHUB_TOKEN when configured; the step skips cleanly when the secret is unset locally or in CI

Forgejo Workflows

Ubuntu CI jobs run in the shared Forgejo container image code.ju.ma/lapis-notes/lapis-ci:latest. The image is defined in docker/lapis-ci/Dockerfile, warmed with the pnpm store, workspace dependencies, Playwright Chromium, Electron Linux runtime libraries such as libgtk-3-0, and xvfb plus xauth (required by xvfb-run), and published as a multi-arch linux/amd64 + linux/arm64 manifest through manual workflow dispatch in .forgejo/workflows/publish-lapis-ci.yml on the docker-node22 runner label. .forgejo/workflows/checks.yml and .forgejo/workflows/release-desktop.yml also call scripts/ensure-electron-linux-deps.sh before app smoke tests so older pulled images still install GTK and related Electron launch dependencies when needed. Local build and push entry points are pnpm ci:image:build and pnpm ci:image:push; the push script expects LAPIS_CI_REGISTRY_USER and LAPIS_CI_REGISTRY_TOKEN. The Forgejo publish workflow and GitHub mirror workflows authenticate to the registry with the same LAPIS_CI_REGISTRY_USER and LAPIS_CI_REGISTRY_TOKEN repository secrets. GITHUB_TOKEN remains sufficient for CI jobs that only pull the image.

Turborepo remote cache

Turbo-enabled Forgejo and GitHub CI jobs use the hosted cache server at https://turbo-cache.app.ju.ma. The server stores Turborepo artifacts in Cloudflare R2; CI runners only need Turborepo client credentials.

Required repository secrets (Forgejo and GitHub mirrors):

  • TURBO_TOKEN (shared bearer token for the cache server and Turborepo client)
  • TURBO_REMOTE_CACHE_SIGNATURE_KEY (HMAC signing key shared with the hosted cache server; must match remoteCache.signature in turbo.json)

Jobs set TURBO_API=https://turbo-cache.app.ju.ma, TURBO_TEAM=lapis-notes, TURBO_TOKEN, and TURBO_REMOTE_CACHE_SIGNATURE_KEY so Turborepo can read and write signed remote cache entries. Workflows covered today:

  • .forgejo/workflows/checks.yml
  • .forgejo/workflows/release-desktop.yml (prepare, verify, build-linux, build-macos, publish-release)
  • .forgejo/workflows/publish-official-plugin-assets.yml
  • .forgejo/workflows/publish-lapis-md.yml
  • .github/workflows/lapis-md-site.yml
  • .github/workflows/lapis-md-site-build.yml

Recommend an R2 lifecycle rule (for example 30-day expiration) on the cache bucket so storage stays bounded. The hosted cache URL and client env keys are centralized in scripts/turbo-cache-config.mjs.

.forgejo/workflows/checks.yml runs on every push and pull request. It first runs pnpm ci:build:checks to warm Turbo artifacts for workspace, desktop, and official plugin packages, then runs independent lanes for repo checks, unit tests, app smoke, daily-use e2e, and official plugin install e2e with fail-fast disabled and xvfb-run on GUI lanes. Playwright artifacts are collected with actions/upload-artifact@v3 for GHES compatibility.

.forgejo/workflows/release-desktop.yml is manually triggered. Dispatch inputs default all step toggles to enabled and default an empty version to packages/desktop-electron/package.json. Its optional prepare job resolves the release version, verify runs the same shared serial Lapis CI verification path as checks.yml through .github/actions/lapis-ci-job-env and .github/actions/lapis-ci-verify (repo checks, unit tests, app smoke, daily-use e2e, and official-plugin e2e under xvfb), build-linux builds Linux desktop artifacts on ubuntu-latest, build-macos builds macOS arm64/x64 desktop artifacts on macos-arm64, and both build jobs apply .github/actions/lapis-ci-job-env after dependency installation so Turbo and Node resource settings match the other cache-backed CI lanes. publish-release creates a combined SHA256SUMS.txt, then creates or updates v<version> and uploads all macOS plus Linux assets in one Forgejo release, then mirrors the same asset set to GitHub Releases tag app-v<version> when LAPIS_RELEASES_GITHUB_TOKEN is configured. Forgejo does not support upload-artifact@v4 / download-artifact@v4, so this workflow uses the v3 artifact actions to pass build outputs between jobs. Electron Builder’s afterAllArtifactBuild hook renames Linux AppImage output only when an AppImage is present so macOS-only builds do not fail. Each job can be run in isolation by disabling the other step toggles; publish-release still requires build artifacts from enabled build jobs in the same run.

.forgejo/workflows/publish-official-plugin-assets.yml is manually triggered. Dispatch inputs include per-official-plugin boolean toggles (default enabled) that map to registry-assets:publish --plugins, an advanced single-plugin release_tag override, a publish toggle for dry-run staging versus real Forgejo upload, a publish_github toggle for the GitHub releases mirror, upload_batch_size for smaller release-asset batches when troubleshooting CI memory or API behavior, and force_overwrite for exceptional explicit-plugin repair publishes. Normal runs use deterministic release tags of the form official-plugin-assets-<plugin-id>-<version>; the manual release_tag override fails unless exactly one plugin is selected. When every official plugin toggle stays enabled, the workflow omits --plugins so already-published plugin versions are skipped automatically; partial selections pass an explicit plugin list. When force_overwrite is enabled, the workflow always passes the selected plugin list explicitly, including the case where every official plugin toggle remains enabled, then logs that the registry must be re-synced, generated, signed, and validated after the replacement. The workflow always passes --no-interactive because CI has no TTY prompt, detects whether any staged assets were produced before attempting artifact upload, and uses actions/upload-artifact@v3 because Forgejo does not support the v4 artifact actions in container jobs.

.forgejo/workflows/publish-homebrew.yml is manually triggered after release assets exist. It verifies the requested macOS DMG and Linux tarball URLs, clones the tap through HOMEBREW_TAP_SSH_KEY, runs the tap updater scripts, validates what the Ubuntu runner can validate, and pushes the tap update.

.forgejo/workflows/publish-lapis-md.yml publishes https://lapis.md to Cloudflare Pages. It runs on pushes to main that touch the public site package, and on manual dispatch. The workflow checks @lapis-notes/lapis.md, runs pnpm build:site, and deploys the prebuilt packages/lapis.md/dist output with Wrangler using packages/lapis.md/wrangler.toml (name = "lapis-md"). Required Forgejo secrets are CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID.

.forgejo/workflows/publish-spec.yml publishes https://spec.lapis.md to Cloudflare Pages. It runs on pushes to main that touch spec/**, and on manual dispatch. The workflow runs make spec-lint, installs mdBook when needed, builds the rendered book with mdbook build spec, and deploys the prebuilt spec/book output with Wrangler using spec/wrangler.toml (name = "spec-lapis-md"). Required Forgejo secrets are CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID.

.forgejo/workflows/publish-web.yml publishes the Web/PWA container image lapisnotes/web to Docker Hub. It runs on pushes to main when packages/web/package.json or docker/web/Dockerfile changes, and on manual dispatch from main. The workflow still treats the manifest as the web release gate for version tags: it reads the web package CalVer from packages/web/package.json, logs in to Docker Hardened Images (dhi.io) and Docker Hub with DOCKERHUB_USERNAME / DOCKERHUB_TOKEN, builds docker/web/Dockerfile as a multi-arch linux/amd64 + linux/arm64 image, passes GITHUB_SHA as LAPIS_BUILD_COMMIT, labels the image with OCI app metadata, and pushes lapisnotes/web:<12-character-git-sha>, lapisnotes/web:<web-package-version>, lapisnotes/web:v<web-package-version>, and lapisnotes/web:latest. The runtime image serves the built PWA on port 8080 directly from Docker Hardened Images Caddy (dhi.io/caddy:2) and exposes /healthz for health probes; deployments should terminate TLS before the container. Other source changes still require either a manifest bump or manual workflow dispatch to publish a new image.

Homebrew Tap

The tap contains:

  • Casks/lapis-notes.rb for macOS cask installs from the arm64/x64 DMGs.
  • Formula/lapis-notes.rb for Linux Homebrew installs from the x64 tarball.
  • scripts/update-cask.sh and scripts/update-formula.sh to fetch release assets, compute SHA256 values, and rewrite tap metadata.
  • scripts/validate-tap.sh to validate cask and formula metadata where the current OS supports the install path.

macOS cask installation validation remains separate from the Ubuntu Homebrew publication workflow; the desktop release workflow now builds and publishes the macOS DMG inputs used by the cask.

ADR Index

This section is reserved for architecture decision records.

Purpose

Use ADRs to capture decisions that change or constrain the architecture described in the rest of this book.

Suggested process

  1. copy ADR-000-template.md
  2. rename it to the next available number and short slug
  3. add the new page to SUMMARY.md
  4. link back to the affected architecture and implementation pages

Current ADRs

Good early candidates

  • long-term compatibility target for the @lapis-notes/api surface
  • search index persistence strategy
  • storage adapter strategy beyond Lightning FS

ADR-001: Plugin Runtime Rework

  • Status: Accepted (desktop runtime direction superseded in part by ADR-003)
  • Date: 2026-04-19
  • Owners: Runtime and shell maintainers

Context

Lapis Notes currently executes community plugins entirely inside the active renderer/runtime process. PluginManager reads manifest.json, evaluates main.js through new Function(...), injects dependencies through a renderer-local require() map, and drives plugin lifecycle hooks directly. The recent plugin hardening work improved rollback, runtime states, and failure reporting, but it did not create a runtime-neutral execution boundary. A plugin that blocks the renderer still blocks the app, and browser-hosted execution cannot offer CPU preemption.

At the same time, the repository still carries stale desktop assumptions from an older Electron-oriented packaging experiment. The checked-in packages/desktop tree is not a clear source-level shell, but some platform and spec language still imply an Electron desktop target. That is no longer the intended direction.

The accepted product targets are now:

  • web-pwa — a browser-hosted and installable web application
  • tauri-desktop — a native desktop distribution built around the same renderer/runtime packages

The plugin system must support both targets without abandoning the existing Obsidian-compatible plugin disk layout. Community plugins should continue to live under /.obsidian/plugins/<id>/ with manifest.json, main.js, and optional assets such as styles.css.

The documentation also has a truthfulness constraint: implementation pages under 30-implementation/ and current API/component pages should remain accurate about what the code does today. The target rework therefore needs to be captured as a decision record first, with implementation pages linking to this ADR rather than pretending the new host architecture already exists.

Decision

Lapis Notes will adopt a cross-runtime plugin host architecture with one community-plugin contract and runtime-specific host implementations.

1. Runtime targets

The only supported host targets for this rework are:

  • web-pwa
  • tauri-desktop

Electron is out of scope. Existing Electron-era artifacts in the repository are treated as legacy implementation residue, not as the architectural direction.

Note (2026-05-17): The choice of Tauri as the first-party native desktop shell was superseded by ADR-003. electron-desktop replaces tauri-desktop as the approved native desktop runtime target. The plugin-host architecture described in sections 3–9 remains valid and should be re-read with electron-desktop substituted for tauri-desktop.

2. Plugin format compatibility

Community plugins keep the Obsidian-compatible on-disk format:

  • manifest.json
  • main.js
  • optional plugin assets such as styles.css and data.json

The rework changes where and how plugin code executes. It does not introduce a new packaging format as the default path.

3. One lifecycle authority, multiple execution hosts

PluginManager remains the lifecycle authority for community plugins. It continues to own:

  • manifest discovery
  • preflight validation
  • runtime state transitions
  • activation and deactivation ordering
  • failure reporting
  • boot-order guarantees

Actual plugin execution moves behind a runtime-neutral host abstraction selected by the active runtime. This host abstraction is responsible for:

  • module evaluation
  • dependency and capability binding
  • host diagnostics and structured errors
  • timeout and heartbeat integration where supported

4. Runtime-specific host implementations

Two host implementations are approved.

Web/PWA host

The web/PWA path remains renderer-hosted. It may use workers for serializable background work, but it does not promise a true process boundary. Its guarantees are:

  • manifest preflight before evaluation
  • transactional lifecycle and rollback
  • capability-gated access to runtime services
  • explicit diagnostics for unsupported APIs
  • non-fatal plugin failure handling

It does not guarantee:

  • CPU preemption
  • hard process isolation
  • transparent support for raw DOM or live editor-object tunneling

Tauri desktop host

The Tauri desktop path introduces the first stronger host boundary for community plugins. Community plugins run in a JS-capable sidecar-style host managed by the Tauri shell rather than directly inside the renderer. The desktop host owns:

  • sidecar lifecycle
  • IPC transport
  • native capability brokering
  • crash recovery and restart controls
  • policy enforcement for hosted community plugins

The Tauri Rust shell is not expected to evaluate arbitrary community-plugin CommonJS bundles directly. The approved direction assumes a dedicated JS-capable host process for that responsibility.

5. Renderer-local bundled plugins in the first rollout

Bundled and first-party plugins remain local to the renderer/runtime in the first rollout of this rework. The host abstraction initially applies only to community plugins. This keeps the migration surface smaller and preserves current boot behavior for core application features.

6. Capability-gated community-plugin API

Community plugins will no longer receive the real App graph directly as the primary boundary. Instead, they receive capability-backed access to approved services. The initial capability set is intentionally smaller than the full current renderer API and starts with background-safe operations:

  • vault I/O
  • plugin settings storage
  • command registration and unregistration
  • notices and logging
  • configuration reads
  • metadata queries
  • limited event subscription

The following are not guaranteed in the first hosted phase:

  • raw DOM access across the host boundary
  • direct view-constructor registration across the host boundary
  • live CodeMirror objects across the host boundary
  • arbitrary renderer mutation by community plugins

Richer UI and editor features are expected to move toward declarative contributions and renderer-companion patterns rather than direct object tunneling.

7. Manifest and preflight policy

The current manifest remains the documented baseline contract. The approved direction allows Lapis-specific manifest extensions, added only when implementation is ready, for example:

  • supportedRuntimes
  • requiredCapabilities
  • executionHints

The existing isDesktopOnly field remains meaningful and maps to tauri-desktop compatibility during preflight.

Preflight becomes runtime-aware. A plugin may be rejected before evaluation if:

  • the active runtime is unsupported
  • required capabilities are unavailable
  • the plugin requires a desktop-only boundary while running in web/PWA
  • required entry files are missing

8. Boot-order invariants

The runtime rework must preserve the existing invariant that plugin activation completes before layout restoration. The approved boot order is:

  1. resolve vault and runtime environment
  2. construct the renderer App
  3. register bundled plugins and runtime dependencies
  4. construct the host registry for the active runtime
  5. discover and activate community plugins through the selected host
  6. restore layout only after activation attempts complete

This preserves the current plugins-before-layout guarantee while allowing host-specific setup before community-plugin activation.

Consequences

  • Tauri desktop gains a meaningful isolation story for third-party community plugins, including stronger crash containment and recovery controls.
  • Web/PWA remains first-class, but its plugin host is still constrained by the browser execution model and cannot promise hard isolation.
  • PluginManager becomes more important, not less. It remains the central owner of lifecycle, state, and boot invariants even after host extraction.
  • The current direct require('obsidian') boundary becomes an internal implementation detail that must be replaced or wrapped by capability-backed facades for community plugins.
  • Community-plugin compatibility expands in stages rather than all at once. UI-heavy integrations will need new contribution models.
  • Manifest preflight and diagnostics become more complex because they now depend on runtime and capability availability, not just version checks and file existence.
  • The repository needs a real Tauri host package or equivalent source representation. Legacy Electron packaging artifacts no longer define the desktop architecture.
  • Documentation must stay split by truth level: implementation pages describe the current loader, and this ADR defines the approved target state for later work.

Alternatives Considered

1. Keep renderer-only execution for all runtimes

Rejected. It preserves the simplest implementation, but it leaves desktop without a strong containment story and does not address the requirement that a community plugin should not be able to block the entire app.

2. Continue designing around Electron

Rejected at the time of this ADR. See ADR-003 for the subsequent reversal of this decision.

3. Introduce a new non-Obsidian plugin packaging format

Rejected. Preserving the existing disk layout and CommonJS plugin entry keeps vault compatibility, avoids unnecessary migration cost, and preserves the current community-plugin loading expectations.

4. Move bundled plugins and community plugins behind the remote host immediately

Rejected. Bundled plugins are part of the core application feature set and already share the renderer/runtime lifecycle. Moving them out of process in the first rollout would expand the migration surface significantly and slow delivery of the community-plugin safety improvements that motivated this ADR.

5. Expose the real renderer App graph over IPC to hosted plugins

Rejected. That would preserve too much of the current trust model, make host boundaries leaky, and undermine the point of capability-based policy and runtime-aware preflight.

Follow-up

  1. Replace stale Electron-specific terminology in architecture docs and platform modeling with web-pwa and tauri-desktop runtime language.
  2. Introduce an explicit runtime-environment model in the codebase so platform detection is injected rather than hardcoded.
  3. Extract the current plugin evaluation seam in PluginManager behind a runtime-neutral host interface.
  4. Replace direct community-plugin access to the full renderer App graph with capability-backed facades.
  5. Implement the web/PWA host first as the constrained renderer host, including structured unsupported-API diagnostics.
  6. Implement the Tauri desktop sidecar host and the structured protocol between the renderer and the sidecar.
  7. Adapt boot flow so runtime resolution and host-registry setup happen before community-plugin activation while preserving plugins-before-layout ordering.
  8. Define the first declarative contribution model for commands, settings tabs, status-bar items, and other renderer-safe UI contributions.
  9. Extend the community-plugin settings and diagnostics UI with runtime mode, capability information, last failure state, and restart or disable controls.
  10. Add verification coverage for runtime-aware preflight, host lifecycle behavior, crash and timeout handling, and cross-runtime boot guarantees.
  11. Keep implementation pages truthful while this work is in flight, and update them only as code actually lands.

ADR-002: Notebook Plugin and Markdown Cell Model

  • Status: Accepted
  • Date: 2026-05-03
  • Owners: Workspace, editor, runtime, and data maintainers

Context

Lapis Notes already has a strong markdown editor and preview stack, a browser-first runtime, a per-vault generated-state database, and a file-first storage model. It does not yet have notebook semantics: multiple executable cells in one note, dependency-driven execution, or persistent notebook outputs.

The target notebook feature must satisfy a specific set of constraints:

  • notes stay markdown-friendly and git-friendly
  • cells can execute in the browser and PWA host without requiring a server by default
  • the runtime computes a DAG and reruns or marks descendants stale when inputs change
  • vault data such as CSV and Parquet files can be loaded into notebook sessions
  • app-derived data such as note metadata, links, tags, and search documents can be queried from notebook cells
  • notebook outputs remain generated state instead of polluting canonical note bytes

The current AppDatabase contract is also an important constraint. It is a generated-state API for metadata and search persistence; it is not today a general-purpose notebook query engine.

Decision

Lapis Notes will pursue notebook support as a bundled first-party plugin named plugin-notebook, using notebook-flavored markdown files as the canonical document format and a browser-safe notebook runtime for execution.

1. Bundled plugin identity

The proposed plugin identity is:

  • package name: @lapis-notes/notebook
  • manifest id: plugin-notebook

Notebook support is therefore part of the first-party packaged plugin landscape rather than a separate external application.

2. Canonical notebook file format

The primary notebook format remains markdown notes.

  • canonical extension: .notebook.md
  • notebook identity: file-path suffix rather than frontmatter opt-in
  • optional notebook config: top-level notebook: frontmatter for runtime settings
  • ordinary prose remains ordinary markdown

The system does not adopt .ipynb as its primary on-disk format.

3. Executable cell syntax

The canonical executable cell unit is a markdown container directive named cell.

Cells carry stable ids and attributes through directive syntax, and code cells wrap ordinary fenced code blocks. This keeps executable cells explicit while preserving ordinary code fences for documentation snippets.

4. Runtime model

Notebook execution runs behind a notebook session runtime in browser and PWA targets.

The runtime is responsible for:

  • parsing notebook cells into a notebook document model
  • computing refs and defs
  • building a DAG
  • tracking stale, running, errored, and disabled states
  • scheduling autorun or lazy execution

The runtime should preserve notebook consistency rules similar to marimo:

  • descendants of changed cells rerun automatically or are marked stale
  • deleting a cell removes its definitions from notebook state
  • multiply defined names and dependency cycles are notebook errors
  • mutations are not tracked across cells

5. Initial cell languages

The first planned cell languages are:

  • JavaScript and TypeScript
  • SQL
  • dynamic markdown

JavaScript and TypeScript cells use static top-level analysis for refs and defs. SQL and dynamic markdown cells use explicit placeholder references such as {{name}} for cross-cell dependency edges.

6. Query and data engine

DuckDB-Wasm is the proposed relational and tabular execution engine for notebook sessions in browser and PWA runtimes.

The notebook runtime will use DuckDB-Wasm to:

  • ingest vault-backed CSV, Parquet, JSON, and other supported file formats
  • expose app-derived tables inside notebook sessions
  • execute SQL cells and ad hoc notebook data queries

7. App-derived data exposure

The first rollout exposes app-derived state to notebook sessions as read-only notebook tables or notebook mounts backed by MetadataCache and AppDatabase snapshots.

The first rollout does not treat the existing AppDatabase API as the notebook runtime’s primary arbitrary SQL execution surface. Direct SQLite bridging remains follow-on work where runtime capabilities make it safe and honest.

8. Output persistence

Notebook outputs, stale state, run metadata, and cached session artifacts are generated state.

They must be persisted outside canonical markdown note bytes, using the same general generated-state discipline already applied to metadata and search state.

9. Rich output protocol

Notebook execution will use a typed output protocol and notebook-owned renderer registry rather than relying on arbitrary imperative DOM output from cells.

The first planned rich output kinds are:

  • text
  • markdown
  • HTML
  • table
  • chart-spec outputs
  • image
  • error

The first rollout should treat interactive tables as a priority feature and support charts through approved spec-driven renderers instead of arbitrary widget code.

Consequences

  • Notebook notes stay readable, editable, and diffable as markdown files.
  • Notebook ownership is decided by file identity, which keeps view routing and command targeting self-contained instead of patching generic markdown leaves.
  • The chosen format fits the current markdown parser direction better than a separate JSON notebook model.
  • The browser and PWA runtime stay first-class, with no default requirement for a backend kernel.
  • The implementation now includes a notebook document model, a hybrid execution runtime that uses the dedicated notebook worker when practical and falls back to the in-process path for DuckDB-backed notebooks, generated-state persistence, and notebook-specific automated coverage.
  • The implementation now also includes a typed output protocol and renderer registry for rich notebook outputs.
  • SQL cell dependencies are intentionally constrained in the first rollout to explicit placeholder references rather than unrestricted SQL introspection.
  • The notebook feature becomes a substantial first-party plugin rather than a thin markdown post-processor.

Alternatives Considered

1. Adopt .ipynb as the primary notebook format

Rejected. It conflicts with the repo’s markdown-first note model and would introduce a JSON canonical format where the rest of the product is explicitly file- and markdown-first.

2. Adopt a marimo-style pure Python notebook format as the primary on-disk model

Rejected. marimo is the right semantic reference for reactivity, but a pure .py canonical format does not fit the current note model or the requirement for markdown-native notes.

3. Embed an existing notebook product such as Starboard or JupyterLite as the notebook surface

Rejected. Those projects solve different layers of the problem. They do not line up cleanly with the existing markdown editor, file model, plugin runtime, and generated-state storage already present in Lapis Notes.

4. Treat any fenced code block as an executable notebook cell

Rejected. That would blur the line between documentation snippets and executable cells, make metadata attachment awkward, and fit the current parser model worse than explicit container directives.

5. Use the current app database as the notebook runtime’s primary SQL interface

Rejected for the first rollout. The existing AppDatabase contract is intentionally narrow and should stay truthful. Notebook execution needs its own runtime and query layer, with read-only bridges back into app-generated state.

Follow-up Status

  1. The first spec pages, bundled plugin-notebook package, notebook-aware parsing/rendering, typed output protocol, generated-state persistence model, notebook commands, and Playwright coverage are implemented.
  2. The runtime now ships with DAG construction, stale propagation, scoped execution commands, DuckDB-Wasm query execution, and vault file registration.
  3. Inline per-cell controls and explorer-style notebook panels are now implemented. Richer live-preview chrome and slash-menu notebook authoring remain follow-on work.

ADR-003: Electron-First Desktop Shell

  • Status: Accepted
  • Date: 2026-05-17
  • Owners: Desktop shell maintainers
  • Supersedes (in part): ADR-001 — desktop runtime target selection

Context

ADR-001 established Tauri as the first-party native desktop shell, explicitly rejecting Electron. A functional Tauri shell was built and validated (packages/desktop, now packages/desktop-tauri).

After shipping the Tauri MVP, the following practical concerns emerged:

  • Build and toolchain friction. The Tauri build chain requires Rust, platform-specific system libraries, and a non-trivial CI matrix. Renderer and main-process code cannot share a single TypeScript toolchain—all native IPC commands must be maintained as a matching pair of Rust code and TypeScript bindings.
  • Webview rendering divergence. WKWebView (macOS) and WebView2 (Windows) do not share the same rendering engine as the development browser. CSS, layout, and canvas behaviour diverge silently and have already required several Tauri-specific workarounds in packages/workspace.
  • IPC model. Tauri’s string-serialised Rust IPC adds an extra marshalling layer and makes it harder to share types between the host and the renderer without a separate code-generation step.
  • Plugin host trajectory. The community-plugin host direction described in ADR-001 assumes a JavaScript-capable sidecar process. Electron’s Node.js main process makes that sidecar trivially available as a worker/child process; Tauri would require a separate JS runtime binary alongside the Rust shell.
  • Ecosystem tooling. The packaging, signing, notarisation, and auto-update ecosystem around Electron is more mature and better integrated with TypeScript monorepos.

The conclusion is that Electron is the better long-term host for a TypeScript-native notes app and is better aligned with the plugin host goals of ADR-001.

Update (2026-05-27): The paused Tauri shell (packages/desktop-tauri) and all tauri-desktop runtime code have since been removed from the repository. Electron is now the sole first-party desktop host. Legacy tauri-folder vault profiles are still accepted and migrated to desktop-folder on open.

Decision

Lapis Notes adopts Electron as the first-party native desktop shell.

1. Runtime targets

The approved product targets are:

  • web-pwa — browser-hosted and installable web application (unchanged)
  • electron-desktop — first-party native desktop distribution using Electron

2. Package layout

PackagePurposeStatus
packages/desktop-electronFirst-party Electron shellActive

Root dev:desktop and desktop:build scripts target @lapis-notes/desktop-electron.

3. Desktop host responsibilities (unchanged)

The host-responsibility split from ADR-001 and the existing desktop spec applies to the Electron shell unchanged:

  • @lapis-notes/workspace remains the renderer shell. It owns layout, vault bootstrap, bundled-plugin registration, and sequential boot.
  • @lapis-notes/desktop-electron owns Electron app lifecycle, native window behaviour, IPC command handlers, and desktop-only runtime adapters.
  • @lapis-notes/api remains the runtime kernel. The desktop host adds adapters, not domain services.

4. Native IPC command surface

The Electron shell exposes this native command surface through the shared API bridge:

  • desktop_pick_vault_folder — native folder-picker dialog
  • desktop_fs_* — vault-scoped file operations (exists, stat, read_text, read_binary, write_text, write_binary, list, mkdir, rmdir, remove, copy, rename)
  • desktop_db_load_state / desktop_db_save_state — generated-state persistence outside the vault

Path-safety rules are preserved: .., root, and absolute-subpath escapes are rejected; all operations are scoped beneath the selected vault root.

5. Desktop-neutral API bridge

The shared native desktop bridge in packages/api uses a runtime-neutral interface (NativeDesktopBridge, setNativeDesktopBridge, hasNativeDesktopBridge, etc.) implemented by the Electron shell.

RuntimeTarget includes electron-desktop. VaultStorageKind uses desktop-folder; legacy tauri-folder profiles are accepted and migrated transparently on open.

6. Community-plugin host trajectory

The plugin host direction from ADR-001 is unchanged in intent. The Electron main process takes the role of the JavaScript-capable sidecar. The concrete design of the Electron community-plugin host boundary is captured in Community Plugin Host Boundary.

7. Packaging and signing

Packaging identifiers, icons, signing/notarisation, auto-update policy, and release-channel metadata for the Electron shell are deferred to a separate task (TASK-DESKTOP-013).

Consequences

  • Electron becomes the gating validation target for native desktop work. CI, smoke tests, and first-party documentation describe the Electron shell.
  • The workspace renderer uses electron-desktop as the desktop runtime discriminator and data-desktop-drag-region markers for frameless window dragging.
  • Legacy tauri-folder vault profiles are migrated to desktop-folder on open so existing desktop vault state is preserved.

Alternatives Considered

1. Continue with Tauri and address toolchain friction incrementally

Rejected. The IPC type-safety and sidecar architecture problems are structural, not incidental. Tauri improvements would require ongoing parallel Rust and TypeScript maintenance that is disproportionate to the benefit given the current team size.

2. Target only web-pwa and drop native desktop entirely

Rejected. A native desktop distribution is a first-class product goal. File-system vault access, native menus, OS-level theming integration, and desktop-grade performance under large vaults are not achievable in the browser-only target.

3. Introduce a new desktop framework rather than reverting to Electron

Rejected. Electron’s ecosystem maturity, TypeScript-native main process, and existing Chromium rendering engine alignment with the development browser are decisive advantages. No other framework provides the same combination at this time.

Follow-up

  1. Rename packages/desktop to packages/desktop-tauri and create packages/desktop-electron (done).
  2. Implement the Electron native IPC command surface (done).
  3. Neutralise legacy desktop bridge names to NativeDesktopBridge and related symbols (done).
  4. Add Electron desktop validation and smoke coverage (done).
  5. Choose packaging identifiers, icons, signing/notarisation, and updater policy (TASK-DESKTOP-013).
  6. Add native file watching for local-folder vaults (done).
  7. Design the Electron community-plugin host boundary (completed in Community Plugin Host Boundary); implementation proceeds through the Plugin Runtime items documented on the owning contract pages.
  8. Remove the paused Tauri shell and tauri-desktop runtime code (done, 2026-05-27).

ADR-000: Title

  • Status: Proposed
  • Date: YYYY-MM-DD
  • Owners: TBD

Context

Describe the problem, pressure, or ambiguity that requires a decision.

Decision

State the decision in one or two direct paragraphs.

Consequences

List the expected outcomes, tradeoffs, and follow-on constraints.

Alternatives Considered

Capture the main rejected options and why they were not chosen.

Follow-up

List any implementation tasks, migration work, or documentation updates that should happen after the decision is accepted.