Files
coop/docs/native-theme-migration.md
T
2026-10-21 16:20:51 +07:00

19 KiB

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 (gpui-kit 0.7.1), delete the custom theme implementation (crates/theme), and then integrate 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

graph LR
    A[Phase A: GPUI-Kit] --> B[Phase B: native-theme-gpui]
    A1[A1 Bootstrap<br/>init + open_window + Root] --> A2[A2 Component swap<br/>~20 custom modules]
    A2 --> A3[A3 Theme removal<br/>cx.theme = gpui-component]
    A3 --> B1[B1 Connector<br/>apply_system_theme]
    B1 --> B2[B2 Appearance + presets<br/>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:

    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<String>, 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.