Epic: Design System & Branding (Desktop)
The desktop equivalent of Design System & Branding (epic 7) — the same warm cream/clay identity, icon system, and screen redesigns, ported to
apps/desktopinstead ofapps/mobile.
Overview
Epic 7 ported packages/design-tokens/reference.html's warm cream/clay visual identity into the mobile app: palette/typography, a curated Lucide icon system, redesigned Chat/Models/Settings/Connectors screens (sections, status pills, a real segmented control, a unified connector-detail screen), and new release assets (app icon + splash screen) using a hand-coded "one gate" mark SVG.
packages/desktop-ui/ThemeProvider.tsx already imports its tokens from the same shared design-tokens package epic 7 repointed at the warm palette — so the base colour/typography layer already reached desktop for free, no task needed for it. Everything past that — icons, section/pill/segmented- control chrome, the unified connector-detail screen, release assets — is still at pre-epic-7 shape on desktop and is this epic's scope.
reference.html already mocks up every desktop screen this needs (#dscreen-chat, #dscreen-models, #dscreen-connectors, #dscreen-connector-detail, #dscreen-settings), using the identical Lucide-style stroke-icon convention and the same "one gate" mark path data mobile's Mark.tsx uses — that file is the literal spec here too.
Versioning note: unlike mobile's per-task version-bump convention, desktop's package.json/Cargo.toml/tauri.conf.json version is bumped only by apps/desktop/scripts/bump-version.mjs (task 14.4's release tool, which commits/tags/pushes) — ROADMAP.md's desktop rows already use — instead of a version number for this reason, and this epic's rows do too.
Tasks
✅ 15.1 — Icon system
Goal: Give desktop-ui a curated icon set and the app mark, mirroring mobile task 7.4.
Deliverables:
lucide-react(the DOM equivalent oflucide-react-native, same version 1.31.0) as a dependency ofpackages/desktop-ui.- A curated
Iconcomponent (packages/desktop-ui/components/Icon.tsx) mirroring mobile's own<Icon name size color aria-hidden|aria-label>contract andiconSizetoken binding —colordefaults tocurrentColorhere (unlike mobile's required prop), since the DOM's SVG renderer, unlike React Native's, does inherit a surrounding element'scolor. - A
Markcomponent (packages/desktop-ui/components/Mark.tsx) — the same "one gate" path data asapps/mobile/src/design-system/components/ Mark.tsx, for the connector-provenance glyph (used starting task 15.2). apps/desktop/src/shell/AppShell.tsx's four sidebar destinations wired to real icons:message-circle/Chat,cpu/Models,settings/Settings, and theMarkitself (not a generic Lucide glyph) for Connectors — matchingreference.html's own desktop sidebar mockup, which ties the connector concept back to the mark's own motif rather than a generic plug/link icon.
Dependencies: None (desktop-ui's token layer already carries the palette).
Review checklist:
- ✅
pnpm --filter desktop-ui typecheck,pnpm --filter desktop typecheck,pnpm --filter desktop lint, and the full test suite (53 tests, 8 files) all pass. - ✅ Verified in a browser against the app's own Vite dev server (port 1420, same content a Tauri window loads), both light and dark: all four sidebar icons render at the correct size, the active destination's accent colour applies to its icon via
currentColorwith no per-row colour prop needed, and navigating between destinations (confirmed via Connectors) works correctly with theMarkrendering correctly in its active state too.
✅ 15.2 — Chat screen: icons, provenance receipts, multiline composer
Goal: Bring ChatScreen.tsx up to reference.html's #dscreen-chat mockup.
Deliverables:
- Circular icon send/stop button (a local
ComposerButton, matching mobile's own local-not-shared pattern for this), replacing the full-width textButton— samearia-labels ("Send"/"Stop") as before, so no existing test needed to change. ChatBubble's connector receipt restructured from inlinevia {name}text inside the bubble to a separate row below it using the newMarkglyph — the same restructure mobile's task 7.5 made,packages/ desktop-ui/components/ChatBubble.tsxnow mirroringapps/mobile/src/ design-system/components/ChatBubble.tsx's shape.ModeBar.tsxchips gain icons alongside their existing labels, via aMODE_ICONmap identical to mobile's own (task 7.5) — same icon per concept on both platforms, not a per-platform reinterpretation. (reference.html's own desktop mockup draws Brainstorm with a pencil icon rather than a lightbulb; mobile's already-shipped choice was treated as the more deliberate, finalized decision to mirror.)- Composer becomes a real auto-growing
<textarea>, capped attouchTargetMin * 3before scrolling internally —reference.html's own desktop mockup uses a staticrows="2"textarea with no growth behavior at all, but mobile's already-proven "grows then caps" composer was judged the more faithful thing to mirror; growth is JS-driven (scrollHeightmeasured into an explicitheighton every keystroke), since a plain HTML<textarea>does not do this on its own the way RN'sTextInputdoes. - The risky-model warning banner also gains an
alert-triangleicon, matching mobile's own iconified warning banner.
Dependencies: Task 15.1.
Review checklist:
- ✅
pnpm --filter desktop-ui typecheck,pnpm --filter desktop typecheck,pnpm --filter desktop lint, and the full test suite (53 tests, 8 files) all pass unchanged — every existing query inChatScreen.test.tsx/ModeBar.test.tsxtargetsrole/aria-label, none of which changed, so no test needed editing for this task. - ✅ Verified in a browser against the app's own Vite dev server, both light and dark: mode chip icons, the circular send button, and the connector receipt's
Markglyph all render correctly. The composer's grow-then-cap-then-scroll behavior was verified by scripting real input events into the<textarea>(bypassing itsdisabledstate, which needs a live Tauri backend/loaded model this browser-only preview doesn't have) and reading backscrollHeight/clientHeight: growth confirmed up to the cap, thenscrollHeight(214px) exceedingclientHeight(130px ≈ the 132px cap) confirmed the internal-scroll hand-off engages correctly past it. - ⚠️ The composer's real
disabled/model-loaded gating and the actual send→reply round trip were not exercised end-to-end — this browser-only Vite preview has no Tauri IPC backend behind it (no native window available to drive interactively in this environment). Covered instead by the existing, unchangedChatScreen.test.tsxsuite, which drives the realsend()/stop()handlers against a mockedlib/tauribackend.
✅ 15.3 — Models screen: sections, progress bar, fit badge
Goal: Bring ModelsScreen.tsx up to reference.html's #dscreen-models mockup.
Deliverables:
- Installed/Available section split, using a new
SectionLabelcomponent (packages/desktop-ui) — same uppercase-via-JS treatment as mobile's ownSectionLabel(task 7.6), notreference.html's literal sentence-case CSS, for the same "mirror mobile's shipped decision" reasoning task 15.2 already used for icon choices. - A new
ProgressBarcomponent (role="progressbar",aria-value*) renders during an active download, replacing the inline percentage/byte- count text — the byte-count stays in the subtitle since the badge and bar already cover the percentage. - A new
FitBadgecomponent (good/tight/bad/neutral, same variant name and colour mapping as mobile's own —packages/desktop-uiandapps/mobile/src/design-systemnow agree on this vocabulary) replaces the mono-coloured accessory text everywhere: install state (Download/Installed/In use), transfer state (percentage/Verifying/Retry), and refusal (Too large, colour-coded red rather than an accent-styled "download anyway"). The badge now names a status; the action it implies moved into the subtitle (e.g. "click to download anyway"). ListItemgained an optionalfooterslot (content below the subtitle, in the same column) so the progress bar can render without overlapping the badge — same addition mobile's ownListItemgot in task 7.6.
Dependencies: Task 15.1.
Review checklist:
- ✅
pnpm --filter desktop-ui typecheck,pnpm --filter desktop typecheck,pnpm --filter desktop lint, and the full test suite (54 tests, 8 files) all pass. Tests that asserted on the old uppercase mono labels (DOWNLOAD,IN USE,INSTALLED,RETRY,CANCEL) switched to the new sentence-case badge text; theCANCELaccessory (now a live percentage badge) is asserted via the same placeholder (…) the subtitle's own unknown-total fallback already used; one new test covers the Installed/Available section split. - ✅ Verified visually against the app's own Vite dev server, both light and dark: since this browser-only preview has no Tauri backend behind it (
listModels()/activeModelId()reject with nothing to render), verification used temporarily seeded fixture data covering all four badge variants and an in-progress download at once (installed/active, downloading at 67%, available/comfortable, available/unsupported) — confirmed section split, progress bar fill, and all fourFitBadgecolours render correctly in both schemes, then reverted the fixture data before finalizing.
✅ 15.4 — Settings segmented control + Connectors restructure
Goal: Fix the same two structural gaps mobile task 7.7 fixed: the theme picker's plain-button role="radiogroup" (Settings and Search setup both use this pattern), and Connectors having no section headers or per-connector detail screen.
Deliverables:
- A new
SegmentedControlcomponent (packages/desktop-ui), replacing the plain-button radiogroup in bothSettingsScreen.tsx's theme picker and the Search provider picker.SettingsScreen.tsxalso gainedSectionLabels (Appearance/Privacy/About) and a chevron on its Connectors row, mirroring the same section treatment task 15.3 gave Models. - Connectors gains section headers (
SectionLabel: Search/Calendar/ Installed — desktop has no Device tier, unlike mobile) and statusFitBadges (Allowed/Not granted/Not set up), replacing the flatToggle-per-row list with rows that navigate into an inline connector detail view (grant/revoke, and — for store-installed connectors only — remove) built insideConnectorsScreen.tsxitself, using the same list/detail-toggle-in-one-file patternConnectorStoreScreen.tsxalready established, rather than a newAppShelldestination. SearchSetupScreen.tsxfolded intoConnectorsScreen.tsx's ownSearchDetailsub-view (its own file and test deleted); Search keeps its "reconfigure, no remove" treatment since it's built into the app, not store-installed.- Calendar connectors keep task 10.2's real-OS-permission gate: granting calls
requestCalendarAccess()first and only proceeds tosetConnectorGrantedon success; revoking never re-requests OS access. - Scope reduction, stated explicitly rather than left implicit: unlike mobile's manifest-based connectors, desktop's
ConnectorStatusDTO is{ id, name, granted }only — no scope/origin data crosses the IPC boundary for installed connectors. Desktop's connector detail views therefore have no "Reaches" section (mobile's does); this is a real backend API gap the frontend can't paper over, not an oversight, and is left for a future task if scope display is wanted here.
Dependencies: Tasks 15.1, 15.3 (reuses SectionLabel/FitBadge).
Review checklist:
- ✅
pnpm --filter desktop-ui typecheck,pnpm --filter desktop typecheck,pnpm --filter desktop lint, and the full test suite (48 tests, 7 files) all pass —SearchSetupScreen.test.tsx's cases moved intoConnectorsScreen.test.tsx(12 tests covering empty/configured Search, section pills, store-installed Remove, and all three Calendar OS-permission-gate cases),SettingsScreen.test.tsx's 7 existing tests pass unchanged against the newSegmentedControlmarkup. - ✅ Verified visually against the app's own Vite dev server, both light and dark: since this browser-only preview has no Tauri backend behind it (
listConnectors()never resolves), verification used temporarily seeded fixture data (four connectors spanning Search/Calendar/Installed, granted and not-granted) — confirmed the sectioned list (pills, chevrons), the Search detail view (Allowed pill, Revoke, providerSegmentedControl, Instance URL field, Save), the store-installed detail view (Revoke + Remove), and the not-granted Calendar detail view (neutral "Not granted" pill, accent "Grant access" button, no Remove) all render correctly in both colour schemes — then reverted the fixture data before finalizing, same as task 15.3.
✅ 15.5 — Release assets: app icon + splash window
The splash window described here no longer exists. It shipped as described, then was removed wholesale in
c13ddf1—public/ splashscreen.html, thesplashscreenwindow intauri.conf.json, theshow_main_windowcommand (with itsbuild.rsCOMMANDSentry andcapabilities/default.jsonpermission), andApp.tsx's first-mountuseEffectare all gone, andmainno longer startsvisible: false. The reason is the risk the last checklist item below flagged and could not test here: the hand-off got stuck on the splash indefinitely on a real Tauri runtime. Deleting it was judged simpler than debugging it, and opening straight to the main window matches the reference product. That commit also added thecore:window:allow-start-draggingcapability the custom titlebar's drag region had always been missing.The task stays ✅ because both halves did ship and were reviewed; the icon half is still live. Nothing here is a regression to fix — this note exists so the deliverables below aren't read as a description of the current code.
Goal: Replace the stock Tauri template icon with the "one gate" mark, and add a real splash window covering the gap before the app's first paint.
Deliverables:
apps/desktop/src-tauri/icons/*regenerated viapnpm tauri icon ../mobile/assets/icon.png— the workflowapps/desktop/AGENTS.mdalready documents. That command's default output also includes iOS/ Android/Windows-Store-tile assets this desktop-only bundle doesn't use (not referenced bytauri.conf.json'sbundle.iconorbundle.targets, and absent from the originaltauri initscaffold) — deleted after generation rather than committed as unused cruft; only the same 7 files the scaffold originally tracked were kept, now regenerated with the mark.- A second
tauri.conf.jsonwindow (splashscreen: transparent, undecorated, centered,skipTaskbar) showing a staticapps/desktop/public/splashscreen.htmlwith the inline mark SVG and aprefers-color-schememedia query, using the same light/dark colour pairs mobile's splash screen used (grey50/clay700light,grey950/clay300dark) — hardcoded in the HTML rather than read from CSS vars, since this window never mountsThemeProvideror any app JS. Lives inpublic/(Vite's static-asset convention) rather than a page Vite processes, so it needs no bundler entry of its own and resolves identically in dev (served at/splashscreen.htmlby the same Vite dev server) and in a production build (copied intodist/verbatim). mainwindow starts"visible": false; a newshow_main_windowTauri command (registered inlib.rs'sinvoke_handler!andbuild.rs'sCOMMANDSlist — the latter is what actually makes theallow-show-main-windowpermission this command'scapabilities/ default.jsonentry names exist at all; missing it fails the Rust build itself with "Permission ... not found", not a silent gap, perbuild.rs's own doc comment) thatApp.tsxinvokes once, in a first-mountuseEffect, to showmainand closesplashscreen. Looked up by label viaget_webview_window, which returnsNonerather than panicking on a missing label, so React 19StrictMode's dev-only double effect invocation (a second, harmless call aftersplashscreenis already closed) is not a bug to guard against.
Dependencies: None (independent of 15.1–15.4, same as mobile 7.8 was independent of 7.4–7.7).
Review checklist:
- ✅
pnpm --filter desktop typecheck,pnpm --filter desktop lint, and the full test suite (48 tests, 7 files) pass. On the Rust side:cargo check,cargo test --lib(150 tests) andcargo test(every target),cargo clippy --all-targets -- -D warnings, andcargo fmt --checkall clean insrc-tauri/. - ✅ Regenerated
icon.pngvisually confirmed against the prior stock Tauri gradient placeholder — now the "one gate" mark on a cream background, matchingapps/mobile/assets/icon.pngexactly (same source file). - ✅
splashscreen.htmlverified directly in a browser (no Tauri backend needed for a static file) in both colour schemes: light shows the clay mark on a cream background, dark shows the lighter clay tint on a near-black background, matching mobile's own splash colour pairs. - ⚠️ The actual windowed hand-off —
mainstarting hidden, the splash window appearing first, andshow_main_windowclosing it after React's first paint — was not exercised end-to-end in this environment (no Tauri runtime/native window available here, only the browser-only Vite preview used for every other visual check in this epic). Covered instead by:cargo check/clippy/fmtconfirming the Rust side (window lookup-by-label, command registration, capability wiring) is correct: and a manual review oftauri.conf.json's window config against Tauri v2's own schema (src-tauri/gen/schemas/*.json), whichcargo checkalready validates by deserializing it at build time. A realpnpm tauri dev/pnpm tauri buildlaunch to watch the hand-off with your own eyes is the one verification step this task couldn't do here. This is exactly where it broke — that launch, once someone did it, showed the app stuck on the splash; see the note under this task's heading. A worked example of why "the Rust compiles and the config validates" is not the same claim as "the window hand-off works."
Related Docs
- Mobile Design System & Branding (epic 7 — the completed mobile counterpart this epic mirrors)
- packages/design-tokens/reference.html