# Migrate the UI to GPUI-Kit, then native-theme > **Status: Phase A is implemented** (on the working tree, uncommitted). The > app builds against `gpui-kit` (resolved 0.7.3 / gpui-pre 0.3.9), every UI > element comes from the kit, and `crates/theme` is deleted. > > **Follow-up (this revision, also uncommitted):** the last custom modules in > `crates/ui` are gone — `motion`, `typography`, `layout` and `styled` were > deleted and every call site now reads gpui-kit tokens directly (`text_xs` / > `text_sm`, `cx.theme().radius` / `radius_lg`, `gap_2`, `Separator`). The > dock wears the previous skin again, ported from the `signed` reference > implementation onto the kit dock engine (`workspace::dock`: pill tabs, the > 44px bar that doubles as the title bar, window controls, collapse buttons, > chromeless sidebar group). The avatar with the pixel fallback is back > (`ui::Avatar`): user image when set, deterministic pixel art from the > profile/group seed otherwise, also as the image-load fallback. Desktop > traffic lights recenter for the 44px bar (`workspace::TAB_BAR_HEIGHT`). > Validation: `cargo check`, `cargo clippy` and `cargo fmt` are clean > workspace-wide and the desktop binary builds; the wasm32 check fails > identically before and after (pre-existing `state` → `gpui_tokio` and > `rustls` blockers, verified at HEAD). Plan to move coop's custom UI elements onto [GPUI-Kit](https://gpui-kit.com) (`gpui-kit` 0.7.1), delete the custom theme implementation (`crates/theme`), and then integrate [`native-theme`](https://github.com/tiborgats/native-theme) through its official `native-theme-gpui` connector. > **Revision.** This supersedes the earlier approach in this file (depend on > the `native-theme` core crate and hand-write a `ResolvedTheme` → coop token > mapping). Going through GPUI-Kit first makes that mapping unnecessary: the > connector already projects native themes onto gpui-component's `Theme`, and > every call site reads `cx.theme()` from gpui-component directly. > > **Revision 2.** Full replacement, no hybrids: no custom UI element is kept > or maintained after Phase A. Earlier hybrid positions in this file (keep > `ui::message` where it fits, possibly keep nav composites, keep an icon > mapping shim) are dropped — kit elements are adopted outright, chat-specific > composition moves into `chat_ui`, and the only coop-owned visuals left are > brand SVG assets rendered through the kit `Icon`. ## 1. What the git history shows - `516eb0e` — *move gpui-components to ui crate*: the UI crate **was** the upstream component library, vendored into `crates/ui`. Call sites used library components and its theme vocabulary (`cx.theme().elevated_surface_background`, `surface_background`, `border_variant`, …). - `0c1c23f` — *migrate to gpui-base (#51)*: the vendored library was deleted and replaced by the `gpui-base` crate plus hand-written wrappers (Button, Switch, Dialog, Input, Dock, Menu, Notification, …) and the custom `crates/theme`. "Migrate like before" therefore means: go back to the upstream component library — this time as a dependency (`gpui-kit`), not vendored — and let it own the theme. ## 2. Strategy ```mermaid graph LR A[Phase A: GPUI-Kit] --> B[Phase B: native-theme-gpui] A1[A1 Bootstrap
init + open_window + Root] --> A2[A2 Component swap
~20 custom modules] A2 --> A3[A3 Theme removal
cx.theme = gpui-component] A3 --> B1[B1 Connector
apply_system_theme] B1 --> B2[B2 Appearance + presets
settings wiring] ``` **Phase A** replaces the custom elements with `gpui_kit::component` components and moves all color reads onto gpui-component's `Theme` (`ThemeColor`, 139 semantic fields). The app runs on gpui-component's built-in light/dark palettes at the end of Phase A; `crates/theme` is gone. At the end of Phase A `crates/ui` contains no UI elements: every element comes from the kit, and only app utilities survive there (§4.4). **Phase B** adds `native-theme-gpui`, which installs native OS themes (or any of 16 bundled presets) into that same `Theme`. No call site changes — the connector is verified against exactly the versions Phase A pins. ### Target dependency set (exact versions) | Crate | Version | Note | |---|---|---| | `gpui-kit` | 0.7.1 | facade: `gpui_kit::*` = GPUI (`gpui-pre`), `gpui_kit::component` (gpui-component 0.7.1), `gpui_kit::base` (gpui-base 0.7.1), `gpui_kit::assets` | | `gpui-pre` | =0.3.8 | already pinned; gpui-kit 0.7.1 requires it | | `native-theme-gpui` | 0.6 | Phase B; verified against gpui-kit 0.7.1 / gpui-pre 0.3.8; needs Rust ≥ 1.95 (toolchain is 1.98) | Do not jump to `gpui-kit` 0.8: `native-theme-gpui` 0.6.1 pins 0.7.1, and a gpui-component patch release has broken the connector before. ## 3. Current custom UI inventory `crates/ui` is ~8,700 lines across 34 files. Consumers import it at ~138 sites (`workspace` 14 files, `chat_ui`, `nip29_ui`, `nip4e`, `settings`, `desktop`, `web`); theme tokens are read at ~340 sites across the same crates. | Custom module | Lines | GPUI-Kit replacement | |---|---|---| | `menu/` (popup, dropdown, item) | 983 | `menu::{PopupMenu, PopupMenuItem, DropdownMenu, ContextMenu}` | | `dock/` (mod, panel) | 1,032 | `dock` (entity-backed DockArea / panels) | | `motion.rs` | 668 | keep — animation utility, not an element; no theme dependency | | `notification.rs` | 594 | `notification::Notification` + `WindowExt::push_notification`; placement via `Theme.notification` | | `button.rs` | 581 | `button::{Button, ButtonGroup, Toggle, DropdownButton}` | | `message.rs` | 553 | `message::{Message, Bubble, MessageGroup, MessageScroller}` (avatar/header/content/footer slots); author grouping, date separators and receipts move into `chat_ui` as app code | | `avatar.rs` | 445 | `avatar::Avatar` | | `root.rs` | 430 | framework `Root` via `gpui_kit::open_window` (owns dialogs, notifications, tooltip/menu layers, focus traps) | | `dialog.rs` | 394 | `WindowExt::open_dialog` / `AlertDialog`; custom footer buttons → Dialog's footer API | | `input/` | ~290 | `input::{Input, InputState, Textarea, NumberInput}`; delete the custom styling (its `input_bg`/`selection`/`caret` reads die with it) | | `icon.rs` | 274 | kit `Icon` + `IconName` (Lucide) at every call site; the module and the coop enum are deleted (see §4.3) | | `kbd.rs` | 270 | `Kbd` | | `tab/` | 385 | `tab::{Tab, TabBar, Tabs}` | | `title_bar.rs` | 218 | `TitleBar` (keep the client-side-decoration constants) | | `popover.rs` | 170 | `popover::Popover` | | `styled.rs` | 161 | `h_flex`/`v_flex`/`StyledExt` from the kit; drop coop-specific helpers as their users migrate | | `switch.rs` | 157 | `switch::Switch` | | `markdown.rs` | 146 | `text::TextView::markdown` (code styling via `Theme.highlight_theme`) | | `nav.rs` + `nav_item.rs` | 268 | `sidebar::{Sidebar, SidebarHeader, SidebarGroup, SidebarMenu, SidebarMenuItem, SidebarFooter}`; the nav layout is adapted to the kit structure, not preserved | | `group_box.rs` | 126 | `group_box::GroupBox` | | `scroll/` | ~124 | `scroll::{Scrollbar, Scrollable}` | | `resizable/` | 31 | `resizable::{h_resizable, v_resizable, resizable_panel}` | | `tooltip.rs` | 34 | `.tooltip()` | | `indicator.rs` | 59 | `marker::Marker` / `badge::Badge`; the custom module is deleted either way | | `window_ext.rs` | 90 | `gpui_kit::component::WindowExt` | ## 4. Phase A — GPUI-Kit migration ### 4.1 Bootstrap - `desktop/main.rs` and `web/lib.rs`: `gpui_kit::init(cx)` first, then open the window with `gpui_kit::open_window(opts, cx, |window, cx| …)`, which installs the framework `Root` (dialog, sheet, notification and tooltip layers). Delete `ui::Root` and its layers; `workspace::init` stays as the window's content view. - Register a combined `AssetSource` at app construction: coop's existing per-platform source for `assets/brand` and images, with `gpui_kit::assets::Assets` as the fallback for the component icon bundle (embedded on desktop; CDN-on-demand on wasm). - `TitlebarOptions` / client-side decoration setup stays coop's (`CLIENT_SIDE_DECORATION_*`, `TABBAR_HEIGHT` move to `ui`). - Keep `bind_keys` **before** `set_menus` (guide rule); coop already does. ### 4.2 Component slices (each PR compiles) Replace one family at a time; consumers update in the same PR: 1. **Deps + bootstrap** (above). Custom components still work alongside the framework Root. 2. **Icons** — rewrite every icon call site onto kit `Icon`/`IconName` and delete `ui::icon` in the same PR (see §4.3). Mechanical churn: one Lucide target per coop variant, `Icon::path` for brand marks; doing it early settles the imports for all later slices. 3. **Small controls** — Button, Switch, Kbd, GroupBox, Avatar, Tooltip, Popover; Indicator → `Marker`/`Badge`. 4. **Menu** — PopupMenu/DropdownMenu/ContextMenu (+ the popup-menu actions already imported from `gpui_base::actions`). 5. **Input** — Input/InputState/Textarea; port the chat composer, dialog fields, relay editor; drop the clear-button and input-styling modules. 6. **Dialog + Notification** — `open_dialog`/`AlertDialog`/ `push_notification`; port the settings, relays, profile, new-chat, import… dialogs; the custom footers move to the Dialog footer API. 7. **Scroll + Resizable + Tab** — panels and the workspace tab strip. 8. **Sidebar/Nav** — port the nav onto `sidebar::{Sidebar, SidebarGroup, SidebarMenu, …}` and delete `nav.rs`/`nav_item.rs` in the same PR. 9. **Title bar** — kit `TitleBar` + traffic-light handling. 10. **Dock** — the biggest single item (805 + 205 lines, `dock::add` registry, `DockPlacement`). Port panels onto `DockArea`; do it last so the panels it hosts are already kit-based. 11. **Message + Markdown** — TextView::markdown; port chat rows onto `Message`/`Bubble`/`MessageGroup` + `MessageScroller`. Chat-specific composition (author grouping, date separators, receipts) is app code in `chat_ui`, built from kit elements — `crates/ui` keeps no message component. 12. **Cleanup** — delete the replaced modules; `crates/ui` ends as a utilities-only crate (see §4.4). ### 4.3 Icons No mapping shim and no coop `IconName` enum: every call site uses the kit's `Icon` + `IconName` (Lucide) directly. During slice 2, give each of coop's 128 variants a Lucide target — exact equivalent where one exists, nearest match otherwise, accepting the visual difference. The only coop-owned SVGs that survive are true brand/product marks under `assets/brand`, rendered through the kit element with `Icon::path("brand/….svg")` (the kit `Icon` accepts any asset path). Delete `assets/icons` entirely once the last call site is mapped. ### 4.4 Theme removal - **Call sites**: `theme::ActiveTheme` → `gpui_kit::component`'s `ActiveTheme` (`cx.theme() -> &Theme`). Rewrite the ~340 token reads with the mapping below (done per consumer crate, one slice each: `ui`, `workspace`, `chat_ui`, `nip29_ui`, `settings` + `nip4e`). | coop token (`cx.theme().x`) | gpui-component `ThemeColor` | |---|---| | `bg` | `background` | | `surface` | `sidebar` | | `surface_raised` | `secondary` | | `surface_card` | `muted` | | `surface_dialog`, `surface_overlay` | `popover` | | `element_hover` | `accent` (gpui-component's accent **is** the item highlight) | | `element_active` | `list_active` | | `border` | `border` | | `border_faint`, `border_strong` | `border` (one border token; faintness loss accepted) | | `ring` | `ring` | | `text` | `foreground` | | `text_muted`, `text_faint`, `text_dim` | `muted_foreground` | | `cursor`, `caret` | `caret` | | `selection` | `selection` | | `solid` / `on_solid` | `primary` / `primary_foreground` | | `accent`, `accent_strong` | `primary` (emphasis ink) | | `danger` / `danger_muted` | `danger` | | `danger_strong` | `button_danger` | | `warning` / `warning_muted` | `warning` / `warning.alpha(≈0.15)` at the call site | | `success` | `success` | | `input_bg`, `selection`, `caret` inside the custom input | die with the custom Input (§3) | | `code_text`, `code_wash` | die with `ui::markdown` → `TextView` | | `drop_line` / `drop_target` | `drag_border` / `drop_target` | - **Non-color fields** move to `Theme` directly: `font_sans`/`font_mono` → `theme.font_family`/`mono_font_family`; `shadow` → `theme.shadow`; `scrollbar_mode` → `theme.scrollbar_mode`; notification placement → `theme.notification` (gpui-component ships the same `NotificationSettings` coop's was modeled on); `is_dark()` → `theme.mode`. - **`crates/theme` disposition**: - delete: `lib.rs` (Theme global, `AppExt`, `sync_base`, `PaletteBuilder`), `colors.rs`, `palette.rs`, `brand.rs`, `appearance.rs`, `paint.rs`, `color.rs`, `syntax.rs`, `notification.rs`, `scrollbar_mode.rs`, `platform_kind.rs`; - the `typography`, `layout`, `styled` and `motion` modules were NOT kept: a follow-up revision removed them and adapted every call site to gpui-kit tokens — the `TextStyle` ladder collapsed to `text_xs`/`text_sm` (plus `font_semibold`/`font_medium`), the radius ladder to `cx.theme().radius` / `cx.theme().radius_lg`, `SPACE` to `gap_2`, `divider` to the kit `Separator`, and motion was deleted unused. `geometry.rs` and the CSD constants never carried over (the framework Root draws the window border); `TAB_BAR_HEIGHT` (44px, the dock tab bar that doubles as the title bar) now lives in `workspace::dock` and `SIDEBAR_WIDTH` in `workspace`; - end state: `crates/ui` is `avatar.rs` + `window_drag.rs` only — see the status note at the top of this file for the restored dock skin and avatar (the two deliberate exceptions to "no custom elements"); - `AppearanceMode` (System/Light/Dark, serde-persisted) moves into `crates/settings`. - The gpui-base projection coop did in `sync_base` is now the framework's job (`Theme::update` / `Theme::change` keep colors, tokens and the base layer in step per the coding guides). ### 4.5 Appearance handling (end of Phase A) - `settings::apply_theme` → `Theme::change(mode, None, cx)` for a pinned Light/Dark, `Theme::sync_system_appearance(None, cx)` for System. - `workspace`'s `Theme::sync_system_appearance(Some(window), cx)` call stays, now against the kit's `Theme`; window-appearance observation comes with the framework Root/theme (verify; if not, keep the existing `observe_window_appearance` wiring). ## 5. Phase B — native-theme-gpui 1. Add `native-theme-gpui = "0.6"` (pulls `native-theme`; keep default features or trim icon features coop doesn't use). 2. Boot, after `gpui_kit::init` and before opening the window: ```rust,ignore match native_theme::SystemTheme::from_system() { Ok(sys) => native_theme_gpui::apply_system_theme(&sys, cx), Err(error) => { log::warn!("system theme unavailable: {error}"); // per-platform fallback preset: macos-sonoma / kde-breeze / windows-11 let prefs = native_theme_gpui::AccessibilityPreferences::default(); if let Ok((theme, resolved)) = native_theme_gpui::from_preset("macos-sonoma", true, &prefs) { native_theme_gpui::apply(theme, &resolved, &prefs, cx); } } } ``` `apply_system_theme` stores **both** variants and a `ThemeConfig` per mode; afterwards the Phase A controls keep working unchanged: `Theme::sync_system_appearance` / `Theme::change` switch between the native palettes. 3. Optional preset picker (the same one proposed in the superseded plan): `settings.theme_preset: Option`, a dropdown in Preferences → Appearance; selecting one calls `from_preset(name, is_dark, &prefs)` + `apply` for both variants. 4. Fonts, radius, scrollbar geometry and the base layer are written by the connector (it also re-asserts them after each upstream rebuild via its observer). Coop writes nothing theme-related itself. wasm: `SystemTheme::from_system` is unavailable on wasm32 — cfg-gate step 2 to native targets; on web, apply the fallback preset (or extend `native-theme` with a web story later; presets need no OS reader). ## 6. Testing and QA - Per slice: `cargo check` workspace, `cargo clippy`, `cargo fmt --check`, plus the affected crate's tests; UI behavior verified by hand in the desktop app. - **Early spike (before slice 2)**: `cargo check -p web --target wasm32-unknown-unknown` with the `gpui-kit` dependency added. The vendored library was wasm-capable; confirm the published crates are too before committing to the migration. If they are not, stop and reassess — the web target is a launch blocker. - End of Phase A: - all dialogs open/close, focus trap and restore works; nested popover-in-dialog works (framework Root owns the layers); - notifications stack and auto-hide; settings' appearance dropdown switches Light/Dark/System with a full repaint; - dock panels move, split and zoom; sidebar resizes; scrollbars behave in Hover/Always/Scrolling modes; - markdown code blocks render through `TextView` with syntax colors from `Theme.highlight_theme`; - every icon renders from the kit Lucide bundle, and brand marks render via `Icon::path` on desktop and web (on wasm the kit bundle is fetched from CDN on demand; coop brand assets still come from coop's own source). - End of Phase B: - macOS: OS light/dark follows; pinned modes; accent changes picked up at next launch (documented limitation — no `watch` feature); - Linux (KDE + GNOME/portal) and Windows: theme reads work or fall back to the platform preset with a logged warning; - preset switch renders in both modes; inputs/menus/dialogs/tooltips/ scrollbars all paint from the native palette; - startup shows no wrong-palette flash (apply before the first window). ## 7. Risks | Risk | Mitigation | |---|---| | Scale: ~8.7k lines of custom UI, ~140 `ui::` imports, ~340 token reads | Slices in §4.2 each compile; `crates/ui` acts as the facade during the migration so consumer churn is incremental, then shrinks to utilities (§4.4) | | Dock port (largest, most stateful) | Scheduled last; panels are kit-based by then; DockArea owns layout state coop currently hand-rolls | | gpui-component on wasm | Spike in §6 before committing; fallback would be staying on 0.7.1 desktop-only and parking the web target | | Visual identity changes (shadcn-style stock palette in Phase A; kit Message/Sidebar look replacing coop's chat/nav styling) | Accepted — requirement says the current palette need not survive; Phase B replaces it with native themes anyway | | Icon gaps (coop 128 vs Lucide 106) | Nearest-Lucide target per variant in slice 2; only true brand marks stay as coop assets via `Icon::path` | | API drift between coop's wrappers and the kit (e.g. `Sizable`, `Disableable`, callback shapes) | Per the coding guides: never invent APIs; verify each component page / source signature during its slice | | `TextStyle` ladder is a deliberate holdout from rem-based text | Documented exception; sizes are absolute points and don't claim to zoom. Revisit only if the kit's text scale is adopted later | | native-theme-gpui version coupling | Pin `gpui-kit = "=0.7.1"`-era set (0.7.1, gpui-pre =0.3.8) matching the connector's verified table; bump all together | ## 8. Out of scope - Migrating `motion.rs` or the panel-background image feature. - Accessibility text scaling / reduce-motion plumbing (connector supports it; adopt after Phase B if wanted). - Replacing coop's composited panels (chat, screening, backup…) with kit `Sheet`/`Settings` — revisit after the migration settles.