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:
- “loading” — Attempts to restore a previous vault profile from
CurrentVaultProfile. - “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), andNew Legacy Vault(Lightning FS). Desktop actions areCreate New Vault,Open Demo Workspace, andOpen Vaultfor native folder vaults. The demo action opens a stable built-in sample vault from a generated bundle of the repoe2e-vaultfixture, excludesplugin-test, strips stale.lapis/cachegenerated 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 beforeApp.sveltemounts, 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 fromOpen a vaulttoCreate 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 legacytauri-folderprofiles throughNativeDesktopVaultAdapterand migrate them todesktop-folderon open. - “permission” — Requests read-write permission for File System Access handles.
- “blocked” — Fallback state used only when a competing tab already owns the per-vault SQLite OPFS database and delegation is unavailable.
- “ready” — Transitions to
App.svelte. - “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:
Loading file system→app.vault.load()Loading configuration→app.configuration.load()plusapp.commands.loadHotkeys()for vault-scoped shortcut overridesLoading core plugins→ Instantiates all core pluginsLoading plugins-> registers the workspace CommonJS dependency resolver, registers explicit legacy CommonJS dependency overrides fromdeps.ts, givesPluginManagerthe 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 pluginsLoading layout→app.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
Escapefor 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__orlocalStorage["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.
Sidebar Layout (sidebar-root.svelte)
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
WorkspaceWindowcontainers 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
2pxneutral line to a4pxvar(--interactive-accent)line on hover and focus-visible.
Left Sidebar (sidebar-left.svelte)
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.
Right Sidebar (sidebar-right.svelte)
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.alwaysFocusNewTabsconfig. - Each leaf renders its
ViewHeaderabove a flexedview-contentregion 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 underCore pluginsandCommunity 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.jsonseparately from manual community plugins discovered byPluginManager, 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 areleasedAttimestamp, 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-pluginfile 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 throughapp.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.jsonbutton 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.editorAssociationsuses that control with glob-pattern keys and a value dropdown sourced from the liveWorkspace.editorViewsregistry 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 throughapp.notifications, and keeps the reload action for applying imported.obsidiansettings.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 (updatesappearence.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
.obsidianand 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, andloadedevents.
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→.jstypescript→.tsjson→.json,.datatext→.txt,.textyaml→.yaml,.ymlcss→.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 / systemappearence.accentColor— color pickerappearence.font.fontSize— 10–30pxappearence.font.quickFontSizeAdjustment— booleanappearence.interface.showInlineTitle— booleanappearence.interface.showRibbon— booleanappearence.interface.showTabTitleBar— booleanappearence.advanced.zoomLevel— 10–30
Editor (editor.schema.json)
editor.alwaysFocusNewTabs— booleaneditor.defaultViewForNewTabs— enum: editing / readingeditor.defaultEditingMode— enum: live-preview / sourceeditor.readingSpeed— 100–600 wpmeditor.display.*— readable line length, fold, line numbers, indentseditor.behaviour.*— spellcheck, indent tabs, indent width
Workspace (workspace.schema.json)
workspace.mobile.mode— enum (autoby default) choosing whether the renderer uses the mobile shell automatically, always, or neverworkspace.mobile.defaultPage— enum (editorby default) choosing whether the mobile shell opens on the active leaf or the tabs pageworkspace.mobile.showBottomNav— boolean (defaulttrue) controlling whether the mobile shell shows the floating dockworkspace.mobile.includeSidebarsInTabs— boolean (defaulttrue) controlling whether the mobile tabs page lists left and right sidebar leavesworkspace.mobile.includeFloatingInTabs— boolean (defaulttrue) controlling whether the mobile tabs page lists floating and popout leavesworkspace.mobile.breakpointPx— number (default768, clamped to320..1280) defining the auto-mode viewport thresholdworkspace.editorAssociations— record map from VS Code-style glob patterns to editor-view IDsworkspace.fileExplorer.autoRevealCurrentFile— boolean (defaultfalse); 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/srcpackages/ui/srcpackages/plugins/plugin-markdown/srcpackages/plugins/plugin-bases/srcpackages/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-shellon the ready-state layout root inApp.svelte - Elements:
workspace-shell__segment(double underscore), for exampleworkspace-shell__status-bar-left - Modifiers:
workspace-shell__segment--modifier(double hyphen), for exampleworkspace-shell__vault-chooser--loading
| Hook prefix / block | Owning Svelte surfaces |
|---|---|
workspace-shell__main, __split, __pane, __split-empty | sidebar-root.svelte, tabs-split.svelte |
workspace-shell__tabs, __tab-header*, __leaf*, __drop-overlay | tabs-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-host | view-header.svelte, leaf.svelte |
workspace-shell__status-bar* | status-bar.svelte, proxy-status-item.svelte |
workspace-shell__ribbon*, __sidebar-footer, __settings-trigger | sidebar-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 packagesluxon— Date/timezod— Schema validationclsx— Class utilitybits-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.