Settings & Configuration
An app instance no longer points at a workspace directory. It is bound to exactly one encrypted cloud workspace, and its configuration is split across three layers with three different lifetimes (epic #4204 D2/D3). Knowing which layer a value belongs to answers both "where is it stored" and "does it follow the user to another device".
The three layers
| Layer | Home | Contents | Synced |
|---|---|---|---|
| Local bootstrap identity | ~/ (v2) + the device key store | workspace id, sync-server/auth configuration, device id, encryption key material; macOS Doc Cloud token in Keychain | never — device-specific |
| Synced preferences | workspace document .zudotext.settings.json | color, editor, shortcuts, frameset, header pins, boards, Doc Cloud preferences (but never its token), … | yes |
| Transient state | memory + device-local storage | activeDraft / draftCount, subscription runtime state, derived values; non-macOS Doc Cloud token by default | never |
The implementation chokepoint is packages/: everything written to the workspace document goes through toSyncedSettingsLayer(), which blanks the device id and drops transient state. Validation rebuilds sync field-by-field, so obsolete identity keys from old documents are discarded rather than trusted.
Workspace binding — config.json v2
{
"workspace": {
"id": "workspace-abc123"
}
}That is the whole schema. There is no workspace path, because the constraint the schema encodes is "one app instance points at exactly one workspace".
Startup resolution has two outcomes, and the distinction is load-bearing:
Bound —
workspace.idparsed successfully. The app proceeds to unlock that workspace.Unbound — the file is missing, unreadable, unparseable, in the retired v1
{"workspace": …}shape, or v2-shaped with a blank/absentworkspace.id. All five fall-throughs resolve to unbound, and unbound routes to onboarding.
Unbound never auto-creates a local directory. The pre-pivot resolver silently fell back to scaffolding ~/Documents/zudo-text/<appname>/, and because it returned a bare String the caller could not tell a real workspace from a fabricated one. That fallback is gone, and the reason is carried on the result so a support report can tell the cases apart.
The binding is persisted only after a successful unlock — after the master password has decrypted a snapshot. Writing it earlier could strand an instance on a workspace it cannot open, with no way out from inside the app. The one exception is LEAF generation, where minting the app and choosing its workspace are the same user action (see App Generation).
Renderer surface: bridge.appBinding.read() / .persist(workspaceId) / .clear(). Rust: tauri-.
Settings document — .zudotext.settings.json
The settings file is a workspace document, not a machine-local file, so colors, editor options, shortcuts, framesets, and pins are the same on every device signed into that workspace. Before unlock the app runs on defaults; nothing is written to a local settings file.
This was only possible once general.projectRoot was removed. That field was a device-absolute path, and it was the sole reason the settings file was excluded from sync.
Full-document last-writer-wins (accepted for v1). The document is pushed whole. Two devices changing different sections at the same moment means the later write's entire document wins. There is no per-section merge and no field-level CRDT. What makes it tolerable is write frequency — settings are essentially only written while a settings dialog is open — plus deviceOverrides, the per-device deep-partial escape hatch for anything that genuinely should differ per machine (see Device override).
Read/write pair: tauri-. Reads fall back to bridge.settings before the workspace can serve them (during pre-mount appearance load); writes never fall back, because a write landing in a machine-local file would resurrect the second store this design removes.
Schema
The schema is defined in packages/ and validated on load by validateSettings() (numeric ranges, field-name migration, enum checks). An abridged view of the shape:
interface AppSettings {
general: {
colorScheme: string; // Theme name (e.g., "default")
colorMode: "system" | "light" | "dark"; // Which of the scheme's two palettes to show (epic #5893)
formatOnArchive: boolean; // Auto-format when archiving
windowOpacity: number; // Window transparency (0–1)
displayScale: number; // UI zoom
appTitle?: string | null; // Optional OS title-bar override
disableSpotlightSearch: boolean;
showMinimap: boolean;
useSpreadsheet: boolean;
useSlides: boolean;
};
color: ColorStructure; // { ramps, map } — see @takazudo/color-themes
activeDraft: number; // transient — stripped before the workspace write
draftCount: number; // transient — stripped before the workspace write
editor: {
vimMode: boolean;
fontFamily: string; // Default: "JetBrains Mono"
fontSize: number; // Default: 16
lineHeight: number; // Default: 1.6
typewriterScrolling: boolean; // Keep cursor vertically centered
showStatusBar: boolean;
markdownListIndent: boolean; // Auto-continue list markers
showLineNumbers: boolean;
showIndentGuides: boolean;
indentType: "tab" | "spaces"; // Default: "spaces"
indentSize: number; // Default: 2
// …
};
vim: {
clipboardSync: boolean; // Sync vim registers with system clipboard
showModeIndicator: boolean;
vimrc: string; // Custom vim key mappings
};
shortcuts: {
// Each shortcut value is string[] — a slot can hold multiple bindings
toggleEditorPreview: string[]; // e.g. ["Mod+E"]
commandPalette: string[]; // e.g. ["Mod+K"]
openSettings: string[];
newDraft: string[];
// …
};
sync: SyncSettings; // mixed — identity fields blanked before the workspace write
docCloud: {
defaultProjectSlug: string | null;
checkpointOnEdit: boolean;
publishNotifications: boolean;
serverOrigin: string; // validated bare origin; never a credential
};
quickActions: { tiles: QuickAction[] };
/**
* Singleton frameset tree — the live layout for the current app window.
*/
frameset: FramesetTree;
/**
* Ordered header pin entries displayed as icon buttons in the toolbar.
*/
headerLeftPins: HeaderLeftPin[];
deviceOverrides: Record<string, DeviceOverride>; // within docCloud, only defaultProjectSlug is overridable
// …plus per-feature sections: menuBar, notifications, pileView,
// directoryView, preview, mindmap, kanban, slides, inlineAiCommand, ios
}Two fields that used to be here are gone with the concepts they described: general.projectRoot (no workspace root exists) and the whole terminal section (the terminal frame and the Rust PTY backend were retired together — D6). A Doc Cloud token is likewise deliberately absent: macOS keeps it in Keychain; other environments keep it in memory unless an adapter is constructed with the explicit DEV-only localStorage opt-in.
sync.cloudDeviceId remains device-local and is blanked before the document is written. The behavioural fields — cloudRealtimeEnabled, cloudDeviceName, agentServerUrl, and alwaysRequireWorkspacePassword — are ordinary synced preferences and do travel. Workspace identity comes from sync-auth-state.getWorkspaceId() / config.json v2, encryption readiness from the armed bridge, and the sync-server URL from runtime bootstrap configuration. The authoritative remaining identity list is LOCAL_BOOTSTRAP_IDENTITY_SYNC_FIELDS in packages/.
Default values
@takazudo/app-defaults (packages/) provides defaults for every field:
| Setting | Default |
|---|---|
| Color scheme | default |
| Color mode | system |
| Editor font | JetBrains Mono, 16px |
| Vim mode | Enabled |
| Header pins | Inbox, Archives |
| Content pin directory | pins |
| Doc Cloud default project | null (Projects) |
| Doc Cloud checkpoint on first edit | true |
| Doc Cloud publish notifications | true |
| Doc Cloud server-origin override | "" (adapter-provided origin) |
Local-directory workspace registry (retired)
~/ held the list of registered local workspace directories that backed the old chooser sidebar. Both are gone: an app instance is bound to exactly one workspace, chosen once at onboarding and persisted in config.json v2, so there is nothing left to enumerate or switch between (D2; the renderer-side removal was #4214).
The registry file and the Rust workspace_registry module that read it were deleted with the local-workspace-directory concept in #4223, along with the workspace_* Tauri commands and the corresponding bridge methods (listAll, register, remove, switchTo, addExisting, setDir, scaffold, …). They were not reimplemented against the cloud workspace — they were removed, because with one bound workspace per instance there was nothing left to list or switch. Existing files on disk are left untouched: per the pre-release policy there is no migration, they simply become unused.
Only two read-only methods survive, both transitional, on what is now the bridge.localDir namespace (renamed from the legacy workspace namespace by epic #4991 Wave 1 to free the domain word): getDir() returns a synthetic root (never a real directory) and listFiles() enumerates the workspace model beneath it. Callers are being moved to bridge.workspaceFiles, which speaks workspace-relative paths and never sees the synthetic root.
For the flow that replaced the chooser, see Cloud-first onboarding.
Recovered as the account-workspace switcher
The "nothing left to enumerate or switch between" claim above held only as long as an app instance was hard-bound to one workspace for its lifetime. Epic #4608 reintroduced switching on top of the multi-workspace-per-account model — without resurrecting a registry file. The workspace list comes live from the sync server (AccountWorkspace[]) rather than a local JSON cache, and the switch itself is an in-session hot-swap (tauri-) rather than a process restart; see Workspace Switcher for the user-facing rail this backs.
Two policy points carried over from the old registry needed a new answer under the workspace model:
Binding semantics — last-opened-wins in ROOT. There is no separate "default workspace" setting. Every successful open, including a switch, persists the app instance's
config.jsonv2 binding to that workspace (commitBinding: true), so the workspace ROOT opens on its next launch is always whichever one was open most recently in this session.Persist-failure policy — open succeeds, notice shown. A switch's destructive teardown-and-open is not conditioned on the binding write landing. If the workspace opens but the
config.jsonpersist fails, nothing is rolled back — the user keeps working in the newly-opened workspace — and a sticky notice (pushWorkspaceSwitchNotice()/WorkspaceSwitchNoticeReplay, both inlib/) explains that the next launch will fall back to the previous workspace instead of this one.workspace- switch. ts
Header Pins (Frameset Arch v2)
Header pins are structural layout templates displayed as icon buttons in the app toolbar. Each pin carries a PinTemplate that rebuilds the singleton frameset tree wholesale when activated. This replaced the old PinConfig directory-browsing model.
interface HeaderLeftPin {
id: string; // Stable kebab-case id (e.g. "inbox-default")
template: PinTemplate; // Frameset tree installed on activation
label: string; // Tooltip text
iconId: string; // Lucide icon name
routeSlug: string | null; // Route slug for user pins (/p/:slug); null for built-ins
visible: boolean;
providerProps?: Record<string, unknown>; // Per-pin provider config
}Because pins live in the synced settings document, a pin added on one device shows up on all of them. Workspace-backed per-pin providerProps use workspace-relative paths; the External File Editor's initialFiles and initialTreeRoot remain local absolute paths because it browses the real filesystem. A Doc Cloud pin carries one leaf and may seed only projectSlug, initialSurface, editorLayout, and outlineCollapsed; it never persists a token, page source, tab list, or concurrency guard.
The retired standalone Directory View id is not migrated. Old pre-release pin or frameset state is disposable; an opaque old pin can render provider-not-found until the user removes it or resets settings. The retained directoryView.lastSidebarWidthPx key controls only EFE's embedded tree.
See Frame/
Config-driven app instances
Different configurations produce different app instances. This is useful for:
Separate content — work vs personal communications, each in its own workspace
Different themes — light theme for daytime, dark for night
Different pin layouts — project-specific header pin templates
Different shortcuts — customized per workflow
Each instance has its own config.json binding and its own workspace; everything inside that workspace — content, settings, pins, framesets — belongs to the instance and travels with the account rather than with the machine. See App Generation.