ADR-0048 — Dark mode, command palette, UI polish, and desktop pack (v0.6 frontend + Tauri)¶
- Status: Accepted
- Date: 2026-07-03
- Sprint: v0.6 (M6 — shippable)
- Features: F1 (3-panel shell / NavRail / chat empty state) · F16 (settings
persistence, i18n IT/EN, GFM rendering,
dataVersion) · F15 (cross-platform: Tauri v2 desktop — multi-server, zoom, native notifications) · F7/F8 (chat & LaTeX rendering surfaces audited for dark theme) - Builds on: ADR-0015 (§CVD-SAFE hex-palette exception for sigma) · ADR-0039
(Tauri v2 shell; no
invoke/window.__TAURI__in React) · ADR-0045 (FA2 server-side layout — client renders precomputed coords) · ADR-0047 (desktop runtime server URL,base.ts, Connect gate, Header server chip) - Reference: R12 (CodeMirror 6 +
@codemirror/theme-one-dark) · R10 (sigma.js) · R13 (Tauri v2) · CLAUDE.md §3 (invariants I2/I3/I4/I6), §12 (no hardcoded config) - Invariants owned: I2 (no client-side layout — graph stays render-only) · I3 (no per-token work — palette/notifications add none) · I4 (CodeMirror 6, no WYSIWYG; virtualization — palette results capped so none needed) · I6 (pluggable inference — untouched). No invariant is traded for convenience.
- Author: solution-architect
- Implementers: frontend-engineer (
styles/theme.css,styles/markdown.css,store/settingsStore.ts,components/settings/SettingsPanel.tsx,components/common/CommandPalette.tsx,components/graph/*, CodeMirror setup,components/chat/*, NavRail,api/base.ts, Header,en.json/it.json) · devops-engineer (src-tauri/Cargo.toml,src-tauri/capabilities/default.json,package.json@tauri-apps/plugin-notification) · tech-writer (D5 screenshots refreshed light+dark, D6a USER notes for shortcuts/zoom)
1. Context¶
The owner approved four packages for the v0.6 frontend + desktop shell: T1 dark mode, T2 command palette + shortcuts, T3 polish, T4 desktop pack. All four are presentation/UX and desktop-transport concerns; none touch the backend, the vault, or the inference layer.
Verified facts (re-confirmed against the tree):
frontend/src/styles/theme.cssis the single color source::rootblock,--syn-*variables,color-scheme: light. Components consume the vars via inline styles / class primitives; there are no hardcoded hex values in components. Flipping the vars re-themes the app — except two documented hazards this ADR must call out (§2.1).frontend/src/components/graphPalette.tsdocuments that sigma cannot resolve CSS custom properties at canvas draw time, so the hex community palette is the documented exception to token-only usage (ADR-0015 §CVD-SAFE).settingsStore.tsalready persistslanguage/contextinlocalStorage(synapse.lang,synapse.settings) andserverUrl(ADR-0047).- CodeMirror 6 with
@codemirror/theme-one-darkis already in dependencies. - Chat streaming is NDJSON; I3 (no per-token work) and I4 (CodeMirror + virtualized lists) apply.
- Desktop plumbing exists:
api/base.ts(apiBase/getServerUrl/setServerUrl/clearServerUrl/getLastServerUrl/isTauri), a Header server chip, and the ConnectScreen gate (ADR-0047).
Two theme-audit hazards in theme.css that a naive "flip the vars" dark mode would miss:
color-mix(... white NN%)— the.syn-section-notice--*rules and severalfocus-visible/border rules mix accent/semantic colors against a literalwhite(e.g.color-mix(in srgb, var(--syn-green) 8%, white 92%)). In dark mode these produce pale, low-contrast fills regardless of the--syn-*overrides, becausewhiteis not a token. These must be re-expressed against a token (e.g. a new--syn-mix-baseset to the theme's surface) or duplicated in the dark block.- Raw scrollbar/thumb hex (
#c7ccd4,scrollbar-color) and shadow rgba tuned for a light background — visible but wrong on dark; audit alongside the vars.
2. Decision¶
Ratify the contract for all four packages. Amendments are limited to: the dark-mode audit
scope (§2.1, the color-mix/scrollbar hazard above), the graph dark-mode treatment (§2.1),
and reuse of the existing ADR-0047 persistence semantics for T4 multi-server (§2.4). All
other contract points are accepted verbatim.
2.1 (T1) Dark mode — RATIFIED, with an expanded audit scope¶
- New
:root[data-theme="dark"]override block intheme.csscarrying the full--syn-*dark palette andcolor-scheme: dark. Light stays the base:root; dark is an attribute override (single stylesheet, no second import, no FOUC beyond the applier's first paint). settingsStore.tsgainstheme: "light" | "dark" | "system", persisted inlocalStorage["synapse.theme"], default"system".- An applier effect sets
document.documentElement.dataset.themeto the resolved value."system"resolves viamatchMedia("(prefers-color-scheme: dark)")with a change listener so the OS toggle re-resolves live. The applier writes the resolved value ("light"/"dark"), never the literal"system", so CSS only ever sees a concrete theme. - Selector UI (3-way: Light / Dark / System) in
components/settings/SettingsPanel.tsx, i18n-labelled. - CodeMirror swaps to the
oneDarkextension when the resolved theme is dark, back to default when light. The swap is aCompartment.reconfigure(or equivalent) on resolved- theme change — not per keystroke and not per token; I3/I4 untouched. - GraphViewer: the node palette stays as-is — it remains the ADR-0015 §CVD-SAFE
documented exception and is readable on dark. Amendment: the sigma stage background
and
labelColor(and edge/label defaults derived from surface/text) become theme-aware, read from the resolved--syn-*values in JS (viagetComputedStyle(document.documentElement)), re-applied on resolved-theme change. This is a render-property update, not a re-layout — I2 holds (coords stay server-computed; ADR-0045).graphPalette.tsis not modified. - Audit scope (expanded, mandatory):
markdown.cssaudited for hardcoded light colors, ANDtheme.css'scolor-mix(..., white NN%)sites (semantic notices, focus outlines, borders) re-expressed against a token so they follow the theme, AND the raw scrollbar hex (#c7ccd4) + light-tuned shadow rgba made theme-aware. Acceptance: no literalwhite/ light-only hex remains in a rule that renders a surface/fill/text color on both themes (the CVD-SAFE graph palette ingraphPalette.tsis the sole allowed exception).
2.2 (T2) Command palette + shortcuts — RATIFIED¶
- New
components/common/CommandPalette.tsx; a global keydown listener (cmd/ctrl+Ktoggle,Escclose). - Sources: app sections + wiki pages. Pages fetched via
fetchAllPagescached once per open (not per keystroke), title substring match, results CAPPED at 20 → no virtualization needed. This is the explicit I4 compliance path: a hard cap keeps the list bounded without a virtualizer, which is acceptable because it is capped; an uncapped list would require TanStack Virtual. Enteropens the selected page/section via existing store navigation actions (no new navigation surface).Cmd/Ctrl+N→ new conversation;Cmd/Ctrl+1..5→ switch section, via existing store actions.- Shortcuts are ignored while typing in inputs / textareas / CodeMirror — except
cmd+K, which must remain reachable from a focused editor. Detection is by event target (tagName/isContentEditable/CodeMirror DOM), not a global flag. - i18n namespace
palette.*in bothen.jsonandit.json(parity test must pass).
2.3 (T3) Polish — RATIFIED¶
- NavRail truncated labels fixed via shorter i18n labels and/or 11px font with a
titletooltip. No fixed-width regression on the other locale (verify IT and EN both fit). - Chat empty state (
components/chat): brand logo + 3 clickable example-question chips (i18nchat.examples.*) that prefill/send via the existing message-input store action (no new send path). - Micro-transitions (hover/focus, 120–150ms) in
theme.css. Constraint: transitions MUST NOT animate the theme switch. Implementation MUST scope transitions to specific properties (e.g.background-color/border-color/opacity/transformon interactive elements) and/or apply a.theme-switchingclass on<html>during the applier's theme change that suppresses transitions for one frame. A blankettransition: allon*is rejected — it would animate every var swap on theme change and cause a visible smear.
2.4 (T4) Desktop pack — RATIFIED, with one reuse amendment¶
All T4 UI is Tauri-only, guarded by isTauri(); in a browser every branch is inert. I6 is
untouched (backend routes providers via provider_config; nothing here hardcodes a
provider).
(a) Multi-server — RATIFIED, amended to reuse ADR-0047 semantics.
base.ts gains getKnownServers(): string[] and addKnownServer(url: string): void
(localStorage["synapse.servers"], JSON array, deduped, max 5). Amendment:
addKnownServer is invoked from the existing successful-connect path established by
ADR-0047 (setServerUrl is called only after a 2xx GET /status), so a known server is by
construction a server that connected at least once — do not add unvalidated raw input to
the list. The URL stored is the normalized value setServerUrl produced (trimmed, trailing
slash stripped, http(s) only per ADR-0047 §2.7), so the list is deduped on the normalized
form and cannot contain a hostile/typo'd base.
The Header server chip (ADR-0047) becomes a dropdown listing known servers; picking one
calls setServerUrl(url) then window.location.reload().
Why full reload (documented): switching backend changes the base for all 12 clients,
every cached query, the graph, conversations, and dataVersion. A full reload is the
simplest correct state reset — it guarantees no stale cross-server data leaks into the
new session, with zero bespoke invalidation logic. The cost (a ~1s reload) is paid only on
an explicit, rare user action. Selective invalidation is rejected as clever-and-fragile.
(b) Zoom — RATIFIED. Cmd/Ctrl +/-/0 adjust
document.documentElement.style.zoom between 0.8 and 1.4, step 0.1, persisted
localStorage["synapse.zoom"], restored on load (Tauri only). CSS zoom is used
deliberately: it works in WKWebView/WebView2, whereas a root font-size scale would not
work because the app styles are in px, not rem. The zoom keydown handler shares the T2
"ignore while typing" discrimination only where it would collide; +/-/0 with a
modifier are safe in inputs and may remain active.
(c) Ingest-completion notification — RATIFIED. Via
@tauri-apps/plugin-notification: Cargo.toml dep + capability notification:default in
src-tauri/capabilities/default.json + npm @tauri-apps/plugin-notification. Fired where
the frontend already observes an ingest run reaching a terminal state (reuse the existing
ingest-activity observation from ADR-0046 — do not add a new poll), and only when
isTauri(). Permission is requested lazily on first fire (isPermissionGranted →
requestPermission), not on startup.
3. Consequences¶
Positive
- One stylesheet, one attribute (data-theme), one persisted key (synapse.theme) drives
the whole app's theme; "system" tracks the OS live. CodeMirror and sigma follow the
resolved theme without per-token or per-frame cost.
- The command palette gives keyboard-first navigation with a hard 20-result cap that keeps
it I4-compliant without pulling in a virtualizer.
- Desktop gains multi-server switching, zoom, and native ingest notifications, all guarded by
isTauri() so the web/PWA build is byte-identical.
- Multi-server reuses ADR-0047's validated-then-persist path, so the known-servers list can
never hold an unreachable or hostile base.
Trade-offs / limitations (stated explicitly)
- Dark mode requires re-expressing every color-mix(..., white) and raw-hex site in
theme.css/markdown.css; the graph node palette stays a hardcoded exception (ADR-0015),
so a genuinely dark-optimized node palette is not delivered here — the existing palette
is merely verified readable on dark. A future ADR may add a dark node palette if contrast
testing demands it.
- window.location.reload() on server switch throws away all in-memory state by design;
unsaved chat drafts on the previous server are lost. Accepted: it is the simplest correct
reset and the action is explicit and rare.
- document.documentElement.style.zoom is non-standard (works in the target WebKit/Chromium
webviews and Chromium browsers, ignored by Firefox). Scoped to isTauri(), so the
non-support case never runs.
- Micro-transitions must be property-scoped or frame-guarded; a mistaken transition: all
would smear the theme switch — this is called out as a reject-on-review condition.
Invariant check
- I2: graph stays render-only. Dark mode changes only sigma render properties (stage
background, labelColor) read from resolved vars; coords remain server-computed
(ADR-0045). No client layout. Holds.
- I3: no per-token work added. Theme swap, palette open, and notifications are
event-driven (theme change, cmd+K, ingest terminal state), never per stream token.
Holds.
- I4: editor stays CodeMirror 6 (oneDark is a theme extension, not a WYSIWYG). Palette
results are hard-capped at 20 → bounded list, no virtualizer required; existing virtualized
lists unchanged. Holds.
- I6: inference layer untouched; no provider referenced or hardcoded anywhere in this
work. Holds.
- I5 (Obsidian): no vault, frontmatter, or wikilink change. Unaffected.
- No invariant is traded for convenience. CONFIRMED: none violated.
4. Risks (surfaced to orchestrator)¶
color-mix(..., white)regression: if the dark-mode audit misses the semantic-notice / focus-outlinecolor-mixsites, dark mode ships with pale unreadable fills — the audit acceptance grep (no literalwhite/light hex in dual-theme rules) is the gate.transition: allsmear: an unscoped micro-transition would animate every--syn-*swap on theme change; enforce property-scoped transitions or the.theme-switchingguard at review.- CSS
zoomnon-standardness: works in the Tauri WebKit/WebView2 targets but is not a web standard; keep it strictly behindisTauri()so the PWA never depends on it.
5. Contract the implementers follow verbatim¶
theme.cssgains a:root[data-theme="dark"]block (full--syn-*dark palette,color-scheme: dark); everycolor-mix(..., white NN%)and raw light-only hex (scrollbar, shadow) is re-expressed against a token or duplicated for dark. OnlygraphPalette.tsmay keep hardcoded hex (ADR-0015 §CVD-SAFE).settingsStore.tsaddstheme: "light"|"dark"|"system"(default"system",localStorage["synapse.theme"]); an applier effect writes the resolved value todocument.documentElement.dataset.theme, resolving"system"viamatchMediawith achangelistener. SettingsPanel exposes a 3-way selector.- CodeMirror reconfigures to
oneDarkon resolved-dark, default on resolved-light — via a compartment reconfigure on theme change, never per keystroke/token. - GraphViewer reads sigma stage background +
labelColorfrom resolved--syn-*(getComputedStyle) and re-applies on theme change; no re-layout;graphPalette.tsunchanged. components/common/CommandPalette.tsx:cmd/ctrl+Ktoggle,Escclose; pages viafetchAllPagescached once per open; substring match; results capped at 20;Enternavigates via existing store actions;Cmd/Ctrl+Nnew conversation;Cmd/Ctrl+1..5switch section; shortcuts ignored while typing exceptcmd+K.palette.*in bothen.json/it.json(parity test passes).- NavRail labels fixed (shorter i18n and/or 11px +
title), verified in IT and EN. Chat empty state: brand logo + 3chat.examples.*chips that send via the existing message-input store action. Micro-transitions (120–150ms) are property-scoped and/or guarded by a.theme-switchingclass; notransition: allon*. base.tsgainsgetKnownServers()/addKnownServer(url)(localStorage["synapse.servers"], JSON, deduped, max 5);addKnownServeris called only from the successful-connect path (ADR-0047setServerUrlafter 2xx/status) with the normalized URL. Header chip becomes a dropdown; selecting a server callssetServerUrl(url)thenwindow.location.reload().- Zoom:
Cmd/Ctrl +/-/0setdocument.documentElement.style.zoomin[0.8, 1.4]step0.1, persistedlocalStorage["synapse.zoom"], restored on load; Tauri-only. - Ingest notification:
@tauri-apps/plugin-notification(Cargo.toml dep +notification:defaultcapability insrc-tauri/capabilities/default.json+ npm dep); fired from the existing ingest terminal-state observation (ADR-0046), permission requested lazily on first fire, only whenisTauri().
6. Do NOT (reject any PR that does these)¶
- Do NOT add hardcoded hex/
white-mixed colors to any dual-theme rule outsidegraphPalette.ts— dark mode must follow the tokens (breaks the theme; audit gate). - Do NOT run a force/FA2 layout on the client for graph theming — dark mode changes render properties only; coords stay server-side (I2, ADR-0045).
- Do NOT parse markdown/LaTeX or do any work per stream token for the empty state, notification, or palette (I3).
- Do NOT ship an uncapped or virtualization-skipping palette list — the 20-cap is what makes it I4-compliant without a virtualizer (I4).
- Do NOT use
transition: all(would animate the theme swap); scope transitions or guard with.theme-switching. - Do NOT add unvalidated URLs to
synapse.servers, and do NOT invent a second persistence path — reuse ADR-0047'ssetServerUrl-after-successful-/statussemantics. - Do NOT call Tauri IPC beyond the
plugin-notificationAPI and the passiveisTauri()check; every T4 branch stays behindisTauri()(ADR-0039 §9.1 carve-out). - Do NOT hardcode any provider anywhere (I6 untouched).