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:
| Type | Shape | Description |
|---|---|---|
| String | type: "string" | Free-text value |
| Number | type: "number" | Numeric value; minimum and maximum render as a slider/range control |
| Boolean | type: "boolean" | Toggle |
| Enum | type: "string" + enum[] | Fixed set of choices rendered as a select |
| Enum Array | type: "array" of enum | Multi-select list rendered from a multiple-select dropdown |
| Object Group | type: "object" | Nested settings section with inherited group title and description |
| Record Object | type: "object" with schema-valued additionalProperties | Dynamic key/value map rendered as an editable table |
| Custom | type: "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
| Method | Description |
|---|---|
register(key, schema) | Register a configuration key with its schema and default. |
getConfiguration(key) | Returns a WorkspaceConfiguration proxy for the key. |
validateConfigurationOption | Validates 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:
| Method | Description |
|---|---|
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:
| File | Contents |
|---|---|
app.json | Core application settings plus canonical plugin compatibility data under pluginData |
appearance.json | Appearance-specific settings |
hotkeys.json | Vault-scoped command hotkey overrides, keyed by command ID |
core-plugins.json | Disabled bundled plugin IDs plus optional bundled-plugin enable overrides |
community-plugins.json | Enabled external plugin IDs, including installed official plugins for compatibility |
installed-plugins.json | Verified external install records and provenance |
plugins/<id>/data.json | External plugin compatibility mirror of canonical app.json plugin data |
<id>.json | Bundled 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 wrappersetting-section-heading setting-control-heading— heading block outside the panelsetting-section-heading-title/setting-section-heading-description— title and optional descriptionsetting-section-body— rounded panel that owns row dividers and receives subsequentnew 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
| Component | Purpose |
|---|---|
SettingGroup | Named group with collapsible header |
Setting | Single setting row with name, description, and control |
addToggle | Boolean on/off switch |
addText | Text input |
addTextArea | Multi-line text input |
addDropdown | Select from options or enum-array multi-select |
addSlider | Numeric range |
addColorPicker | Color value |
addButton | Action button |
addExtraButton | Secondary action |
addMomentFormat | Date/time format string |
addSearch | Searchable selection |
addObjectMap | Dynamic 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:
| Section | Purpose |
|---|---|
Core plugins | Lists bundled plugins plus installed official plugins, allows toggling non-required entries, and links their setting surfaces |
Community plugins | Lists 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
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
| Key | Type | Default | Description |
|---|---|---|---|
theme | "light" | "dark" | "system" | "system" | Color mode |
accentColor | string | "" | Custom accent hue in oklch |
fontSize | number | 16 | Base font size in pixels |
fontFamily | string | "" | Custom font family |
lineWidth | number | 700 | Max editor line width in pixels |
Editor
| Key | Type | Default | Description |
|---|---|---|---|
lineNumbers | boolean | false | Show line numbers |
indentSize | number | 4 | Spaces per indent |
vimMode | boolean | false | Enable Vim keybindings |
spellcheck | boolean | true | Browser spellcheck |
foldHeadings | boolean | true | Enable heading folding |
foldIndent | boolean | true | Enable 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
| Key | Type | Default | Description |
|---|---|---|---|
files.links.newLinkFormat | "shortest" | "relative" | "absolute" | "shortest" | Default target-path style for generated internal links |
files.links.useWikilinks | boolean | true | Prefer Obsidian-style wikilink syntax for generated internal links |
files.links.omitMarkdownExtension | boolean | true | Omit .md and .markdown when formatting internal links where possible |
files.links.useShortestUniqueSuffix | boolean | false | In 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
| Key | Type | Default | Description |
|---|---|---|---|
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.showBottomNav | boolean | true | Show the mobile shell’s bottom navigation bar |
workspace.mobile.includeSidebarsInTabs | boolean | true | Include left and right sidebar leaves in the mobile tabs page |
workspace.mobile.includeFloatingInTabs | boolean | true | Include floating and popout leaves in the mobile tabs page |
workspace.mobile.breakpointPx | number | 768 | Viewport width below which workspace.mobile.mode = auto uses mobile mode |
workspace.editorAssociations | Record<string, string> | {} | Glob-pattern to editor-view ID overrides for file-backed view routing |
workspace.fileExplorer.autoRevealCurrentFile | boolean | false | Automatically 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:
- Contribution —
LapisConfigurationContributionis parsed from the manifest and installed byPluginManager.installManifestConfiguration. - Schema —
ConfigurationSchema.registerflattens nested object groups into namespaced leaf keys, validates writes with zod-backedSchemaTypedescriptors, and materializes defaults intoapp.json(or the plugin namespace). - Control mapping —
getSettingControlKindinpackages/workspace/src/lib/components/configuration/configuration.tsmaps each leaf schema to aSettingControlKind. - Rendering —
createPropertySettingmounts Obsidian-compatSetting.add*helpers frompackages/api/src/lib/settings.svelte.ts, which wrap@lapis-notes/uiprimitives viamountComponent.
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 shape | SettingControlKind | Setting API | @lapis-notes/ui |
|---|---|---|---|
boolean | toggle | addToggle | Switch |
string (plain) | text | addText | Input |
string + enum | select | addDropdown | bits-ui Select (api wrapper) |
string + optionsSource (closed set) | select | addDropdown | bits-ui Select |
string + optionsSource + allowUnknownOptions: true | combobox | addOptionsCombobox | searchable Input + suggestion list |
string + editPresentation: "multilineText" or format: "textarea" | textarea | addTextArea | Textarea |
string + format: "color" | color | addColorPicker | Input type="color" |
string + format: "icon" | icon | addIconPicker | api icon list (Popover + Button) |
string + format: "date" / "time" | date | addDatePicker | DateTimePickerDialog + trigger Button |
string + format: "email" / "uri" / "ipv4" | text (typed Input) | addText | Input (email / url / text) |
number or integer + minimum + maximum | range | addSlider | Slider |
number or integer (no min/max) | number | addText (type="number") | Input |
array + enum items | multiselect | addDropdown (multiple) | Select |
array + string items + optionsSource (closed set) | multiselect | addDropdown (multiple) | Select |
array + string items + optionsSource + allowUnknownOptions: true | list | addList | Select / Input rows with suggestions |
array + primitive items (string / number / integer / boolean) | list | addList | Input / Switch / Button per row |
array + flat object items (fixed primitive/enum/optionsSource columns) | object-array | addObjectArray | Table + row grip reorder + add/remove + cell editors |
flat primitive object value (leaf grid) | object-grid | addObjectGrid | Table + cell editors |
record object value with schema-valued additionalProperties | object-map | addObjectMap | Table + key editor and value control |
nested object (manifest grouping) | (section only) | group headers | layout chrome |
type: "custom" | custom | SettingTab mount | workspace shell |
| anything else | unsupported | addButton (“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 examplesetting-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-headingon the header row (declarative schema headings and imperativeSetting.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 insidesetting-section-bodyand span the panel viagrid-column: 1 / -1. Heading actions such as reload buttons chain throughSetting.setHeading().addButton(...). Heading descriptions useSetting.setDesc()before or aftersetHeading(), matching other setting rows. - Custom plugin tabs (
type: "custom"):setting-control-customon the external wrapper alongsidesetting-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 toEditorViewRegistryinvalidation). - Plugin sources — registered at runtime via
Plugin.registerConfigurationOptionSource(id, provider). Relative IDs are prefixed with the plugin manifest id (ruleIds→markdown-lint.ruleIds). Registration is cleaned up on plugin unload. - Schema references — use fully qualified source ids in manifest JSON (
"optionsSource": "markdown-lint.ruleIds"). optionsSourceParams— optionalRecord<string, unknown>on string schemas for parameterized providers (for examplemetadata.fieldValueswith{ "field": "status" }).- Searchable combobox — top-level
stringsettings withallowUnknownOptions: truerender as a combobox that passesqueryandlimitto the resolver. The same combobox control is used foroptionsSourcecolumns in object-map and object-array table cells. - Session cache — providers may set
cache: "session"to reuse resolved option lists (including queried results keyed byqueryandlimit) 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
| Field | Schema | Settings UI |
|---|---|---|
title, description, markdownDescription | yes | yes |
default | yes | yes (+ restore default) |
enumDescriptions, enumMarkdownDescriptions | yes | dropdown labels |
enumItemLabels | yes | dropdown labels (short label; value unchanged) |
order | yes | sort within category / group |
minimum, maximum, step | yes | slider / validation |
minItems, maxItems | yes | validation + object-array add/remove disable |
deprecationMessage, markdownDeprecationMessage | yes | hide unless non-default; warning chrome when visible |
format (color, icon, textarea, date, time, email, uri, ipv4) | yes | mapped controls above |
editPresentation | partial (multilineText) | textarea |
optionsSource, allowUnknownOptions, optionsSourceParams | yes | dynamic option lists for string, object-map, object-array, select, multiselect, and option-backed lists |
pattern, minLength, maxLength | planned | — |
VS Code scope, tags, keywords | N/A | N/A |
VS Code comparison
See VS Code configuration contribution points. Summary:
| Capability | Lapis declarative | VS Code |
|---|---|---|
| Boolean, string, enum, multiline | yes | yes |
enumItemLabels | yes | yes |
number / integer | yes | yes |
| Slider (min + max) | yes | yes |
format: color / icon | yes (Lapis extension) | not in public VS Code schema |
| Primitive arrays (editable list) | yes | yes |
| Flat primitive object grid | yes | yes |
| Array of flat object rows (table editor) | yes | partial / extension-specific |
| Dynamic record-object map | yes | yes |
| Complex arrays/objects (nested cells, free-form rows) | JSON fallback | JSON fallback |
order in UI | yes | yes |
| Deprecation UX | yes | yes |
Standard format strings (date, email, uri, …) | yes | yes |
JSON Schema pattern / length | planned (TASK-PLUGIN-053) | documented |
Gaps and Task IDs
Prior open work is tracked by task id:
task_id | Scope |
|---|---|
TASK-PLUGIN-047 | Spec matrix + VS Code gap documentation (this section) |
TASK-PLUGIN-048 | order, enumItemLabels, integer + e2e-vault Phase A |
TASK-PLUGIN-049 | Primitive array list control + e2e-vault Phase B |
TASK-PLUGIN-050 | Leaf object-grid + e2e-vault Phase C |
TASK-PLUGIN-051 | String format + deprecation UX + e2e-vault Phase D |
TASK-PLUGIN-052 | e2e-vault INDEX.md + fixture checklist alignment |
TASK-PLUGIN-066 | Array-of-flat-object object-array control + e2e Phase E |
TASK-PLUGIN-067 | Object-array row reorder via shared @lapis-notes/ui/table-dnd |
TASK-PLUGIN-073 | Extensible optionsSource registry for declarative settings UI |
TASK-PLUGIN-074 | Top-level/optionsSourceParams follow-up for declarative settings UI |
TASK-PLUGIN-075 | Searchable combobox and metadata.fieldValues option source |
TASK-PLUGIN-076 | optionsSource 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).
| Phase | Manifest keys (examples) | UI under test |
|---|---|---|
| A | display.mode (enumItemLabels, order), behavior.retryCount (integer) | dropdown labels, sort order, integer/slider |
| B | display.tags, behavior.flags | primitive string/boolean lists |
| C | behavior.limits (flat object) | object grid |
| D | display.contactEmail, display.docsUrl, display.scheduledAt, display.legacyMode | typed formats; deprecation |
| E | behavior.rules (object array), behavior.unsupportedComplexRows | object-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.