Desktop (GPUI)

Native Rust desktop UI on raw GPUI — celestia-ui wraps the gpui-component library under the Celestia tokens (a compile-time theme.json) plus celestia-only builds, with no facade crate in between

celestia-ui is the desktop counterpart of @celestia-project/ui: a Rust component library for native desktop apps built on GPUI (gpui-pre 0.3.6) directly — the two widget layers it wraps, gpui-base and gpui-component 0.6.6, are named as themselves, so every import states the crate it comes from. The shape mirrors the web package: packages/ui wraps Base UI primitives with celestia tokens; celestia-ui wraps the gpui-component library the same way, so most web files map 1:1 onto a desktop module (drawer.tsx → sheet, toast.tsx → notification, file-tree.tsx → tree).

Looking for the visual tour?

This page is the package overview — the crates, the architecture and the component-family map. For the run instructions, requirements, token swatches and the app bootstrap, see the dedicated Desktop (GPUI) surface.

Invisible to pnpm and turbo

packages/desktop is a self-contained Cargo workspace. pnpm/turbo tasks don't see it — run cargo directly inside packages/desktop/, or use the root pnpm desktop script, which shells into cargo. Nothing under this package participates in turbo typecheck or the web build.

Where it lives

PathWhat it is
packages/desktopCargo workspace root (cargo run -p celestia-desktop from here)
packages/desktop/crates/uiThe reusable library — theme, palette, components
packages/desktop/crates/showcaseThe showcase binary (component gallery window)
packages/desktop/crates/starterMinimal boilerplate app — copy this crate to bootstrap your own window
reference/GetCat/The design reference the stack and idioms were modeled on
reference/gpui-kit/The upstream gpui-kit repo checkout (v0.7.0 crates/story examples — usage inspiration only; verify signatures against the installed 0.6.x source)
reference/zeron/The Zeron app checkout — source of the ported motion kit, loaders, notice chip and context badges (see below)
reference/zui/Zeron's gpui fork checkout (zeronsh/zui at the rev zeron pins) — documents the fork-only render primitives that can not be ported

Running it

Rust ≥ 1.97 (edition 2024); no environment variables — the theme JSON is compile-time embedded. macOS needs no extra toolchain, Linux needs Vulkan + Wayland/X11/fontconfig headers, Windows needs MSVC.

bash
pnpm desktop                        # from the repo root — build & open the gallery windowpnpm desktop:starter                # minimal boilerplate window (copy crates/starter to start an app)cargo run -p celestia-desktop       # or directly from packages/desktop/  (⌘D / Ctrl+D toggles theme)cargo run -p celestia-desktop-startercargo test --workspace              # unit + gpui TestAppContext tests (theme pins live here)

The gallery sidebar lists one entry per component (Button is a dedicated page), grouped by category, with a search field above the nav to filter by name or package path. The upper-right Package card copies the selected component’s primitive/… / composite/… path; use Visual / Docs for props, a copyable Rust usage snippet, and wiring notes.

Architecture

One file per component, split into two category modules mirroring packages/ui/src/components/{primitive,composite}:

  1. Celestia wrappers — components with a shadcn-shaped prop surface that render gpui-component primitives under Celestia theming: button.rs (Button + ButtonVariant — primary/secondary/destructive/outline/ghost/quiet/link, plus desktop-only success/warning/info status fills — with ButtonSize's five height steps and four icon steps, leading_icon/trailing_icon slots, the Button::icon constructor, and a loading state that swaps the leading slot for a Spinner or ButtonLoadingStyle::Diamond), badge.rs (Badge + BadgeVariant — composed from a plain div rather than gpui-component's Tag, because the web status variants are a 10% tint of the accent under accent-coloured text and the Tag paints solid fills plus a hover dim the web does not have), avatar.rs (Avatar + AvatarSize, the web's 24/32/40px scale), card.rs, section_heading.rs, text_editor.rs and code_editor.rs.
  2. Per-component re-exports — the remaining components each get a file (switch.rs, popover.rs, table.rs, …) re-exporting the matching gpui-component API, with a doc comment recording the web-file → desktop mapping (the table below). Wrapper types are also flattened at celestia_ui::components::* (e.g. components::Button); module-only components are reached through their category-qualified path (components::primitive::table::…, components::composite::sidebar::…).
  3. Raw-GPUI components — written directly on gpui (gpui-pre) and importing nothing from gpui_component: primitive/alert.rs, primitive/progress.rs, primitive/skeleton.rs, primitive/spinner.rs, primitive/loader.rs, primitive/shimmer.rs, primitive/icon.rs, primitive/badge.rs, primitive/label.rs, primitive/rating.rs, composite/loaders.rs, composite/notice.rs, composite/context_badge.rs. They render with gpui::div / gpui::svg / gpui::canvas and read colors from cx.theme().

Dependency layers

There is no facade crate. celestia-ui depends on the framework and its two widget layers directly, so every import names the crate it actually comes from:

LayerCrateUsed for
Frameworkgpui (gpui-pre)everything a component draws
Basegpui-basethe inner component library (Checkbox, Popover, Tree, Resizable, Slider, Tabs, Table, the motion stack, scroll) plus the free style helpers (h_flex / v_flex, StyledExt, RoleOverride, box_shadow)
Widgetsgpui-componenta thin styling layer over gpui-base; the styled controls a file still re-exports

Components move from (2) → (3) one family at a time, and crates/ui/src/components/mod.rs tracks what is done. Rewritten so far:

  • Feedback — primitive/: alert, progress, skeleton, spinner, shimmer, icon; composite/: loaders, notice, context_badge.
  • Structural — primitive/: separator, kbd, link, breadcrumb; composite/: group_box, status_bar, title_bar, empty.
  • Display — primitive/: badge, label, rating.
  • Layout — primitive/: card; composite/: section_heading, sidebar_layout, and the SwiftUI vocabulary h_stack, v_stack, z_stack, spacer, scroll_view, v_grid, frame, alignment.
  • Conversation — primitive/: bubble; composite/: message, marker. (message_scroller is split out and still a shim — it is the one part of the conversation stack sitting on gpui-base motion and scroll.)

Pick the next family by transitive cost, not file size, measured on two axes. First, what the target's own imports drag in: the question is not "how big is it" but "does the closure reach a gpui-base state machine". StyledExt, h_flex / v_flex, RoleOverride and the sizing traits are style helpers and cost nothing to inline; Checkbox, Slider, Popover, Tree, ResizableState and the motion stack are state machines and have to be reimplemented. Second, who else imports it: bubble and message import each other (MessageContent::bubble takes the crate's own Bubble), so they move together, and menu is imported by ten upstream modules.

34 shims remain, and exactly one of them is still free. attachment (1017 upstream lines) references gpui_base not once — it draws from gpui plus the free style helpers, and its only component dependency, shimmer, is already migrated. title_bar was the other one; it is done. Everything else names at least one gpui-base module directly, and the large ones close over ~45k upstream lines each — for those the base layer is the next milestone, not another component batch. (resizable is the one the analyzer cannot rank: it reports a zero because gpui_component::resizable is an inline pub mod over gpui-base rather than a file. A name that resolves to nothing is a red flag, not a cheap target.)

Two things make that measurement reproducible rather than remembered. scripts/ui-audit/gpui-migration-cost.py resolves each shim's target module and reports a gb1 column — the gpui-base modules the target's own files import, at depth 1. Read gb1, not the closure columns: a closure total saturates at ~45k lines for nearly every shim and over-reaches (stepper's closure claims markdown_ext, number_input and virtual_list, which a stepper plainly does not use). Depth 1 cannot be inflated transitively, so gb1 == 0 means a one-file port and nothing else does.

And a correction that reframes the whole remaining list: gpui-base is not a helper crate, it is the inner component library. gpui-component is a thin styling layer over it — upstream checkbox.rs is a struct holding base: gpui_base::Checkbox, and popover.rs does use gpui_base::Popover as BasePopover. Only the pure style helpers re-exported through it (h_flex / v_flex, StyledExt, RoleOverride, FocusableExt, box_shadow, AxisExt, LengthExt, Edges, Placement, Side, Measure) are free to inline, which is why title_bar — zero of them — was cheap.

The substitutions that recur, and the two places the old facade was load-bearing:

Was (gpui-kit facade)Now
use gpui_kit::{div, px, …}use gpui::{div, px, …} — same items, so mostly mechanical
use gpui_kit::component::ActiveThemeuse crate::theme::ActiveTheme as _
gpui_kit::component::Icon / assets::IconNamecrate::components::primitive::icon::{Phosphor, PhosphorIcon}
IconName::StarFillPhosphor::new(PhosphorIcon::Star).weight(PhosphorWeight::Fill)
.refine_style(&style)el.style().refine(&style)
h_flex() / v_flex()div().flex().flex_row() / div().flex().flex_col(), plus the cross-axis rule the helper supplied — h_flex() is .items_center(), so a row that relied on it keeps an explicit .items_center()
Tooltip::new(..).build(..).tooltip(|_, cx| cx.new(|_| MyHint).into())
Sizable / Sizea crate-local metric bucket, e.g. RatingSize, GpuiBadgeSize

refine_style and the h_flex/v_flex helpers are gpui-base, not core gpui, so a migrated file spells out style().refine(..) and the flex direction. Two further traps: ParentElement must be imported by name (not as _) if the file implements it, and Styled / InteractiveElement / Refineable must be in scope or h_4 / child / id / refine silently fail to resolve. A third: a facade enum that derives Clone may not survive the port — widening one arm to hold an AnyElement (not Clone) means dropping the derive, not boxing around it. The full recipe — and how to pick the next family by transitive cost rather than file size — is the module doc of crates/ui/src/components/mod.rs. The theme service (cx.theme() → gpui-component's Theme/ThemeColor) is the last piece scheduled to move; it is re-exported from crate::theme so component files never name gpui_component directly.

One rule runs through everything: raw hex lives only in theme.json and palette.rs. Call sites take colors by role through cx.theme() (semantic roles: background, foreground, primary, border, sidebar…) or palette(cx) (brand + charts).

Component map

Every component lives in crates/ui/src/components/, under one of two categories that mirror packages/ui/src/components/{primitive,composite}:

  • primitive/ — one control, or one visual atom, per file.
  • composite/ — surfaces assembled from several primitives, plus application-level assemblies (chrome, shells, editors).

The split is by composition, not size: a file that renders two or more primitives, or that owns a layout region rather than a control, is a composite.

File (desktop)Web counterpart
primitive/button.rs (wrapper: ButtonVariant, ButtonSize)button.tsx
primitive/badge.rs (wrapper: BadgeVariant incl. Brand; GpuiBadge for count/dot)badge.tsx
primitive/input.rs, primitive/textarea.rs, primitive/input_otp.rs, primitive/select.rs, composite/combobox.rsinput.tsx, textarea.tsx, input-otp.tsx, select.tsx, combobox composite
primitive/switch.rs, primitive/checkbox.rs, primitive/radio.rs, primitive/slider.rs, primitive/rating.rs, primitive/toggle.rs (Toggle + ToggleGroup), composite/stepper.rs, composite/toolbar_menu.rsswitch.tsx, checkbox.tsx, radio-group.tsx, slider.tsx, —, toggle.tsx (+ toggle-group), —, toolbar-menu.tsx
primitive/calendar.rs, composite/date_picker.rs, composite/color_picker.rs, primitive/label.rscalendar.tsx, —, —, label.tsx
primitive/alert.rs, primitive/toast.rs (toasts + WindowExt), primitive/progress.rs, primitive/spinner.rs, primitive/loader.rs, primitive/skeleton.rs, primitive/shimmer.rsalert.tsx, toast.tsx / sonner.tsx, progress.tsx, spinner.tsx, (desktop-only pixel loaders), skeleton.tsx, ai/shimmer.tsx
primitive/popover.rs, primitive/dialog.rs, composite/modal.rs (Modal / ModalHeader / ModalTitle / ModalDescription / ModalBody / ModalFooter), primitive/sheet.rs, composite/menu.rs, primitive/menubar.rs, primitive/navigation_menu.rs, primitive/hover_card.rs, primitive/tooltip.rs, primitive/command.rspopover.tsx, dialog.tsx, drawer.tsx / sheet.tsx, dropdown/context, menubar.tsx, navigation-menu.tsx, hover-card.tsx, tooltip.tsx, command.tsx
primitive/avatar.rs (wrapper: AvatarSize — web 24/32/40px), primitive/breadcrumb.rs, primitive/item.rs, composite/list.rs, composite/file_tree.rs (FileTree / FileTreeFolder / FileTreeFile), composite/tree.rs (gpui-component host), composite/pagination.rs, composite/toolbar_menu.rs, composite/marker.rs, composite/empty.rs, composite/virtual_list.rsavatar.tsx, breadcrumb.tsx, item.tsx, list delegate, ai/file-tree.tsx, —, pagination.tsx, toolbar-menu.tsx, marker.tsx, empty.tsx, —
primitive/table.rs — pure GPUI composition table (Table, TableHeader, TableBody, TableFooter, TableRow, TableHead, TableCell, TableCaption) + declarative DataTable (from_key_value, striped rows). Columns share width via flex_basis(relative) so cells align across rowstable.tsx (primitives), the data-table composite; GetCat kv_table.rs inspired the Key/Value constructor
primitive/separator.rs, primitive/tabs.rs, primitive/accordion.rs, primitive/collapsible.rs, primitive/carousel.rs, primitive/resizable.rs (ResizablePanelGroup / resizable_panel + Celestia handle chrome), composite/resizable_split.rs (two-pane drag twin of SplitPane), primitive/scroll_area.rs, primitive/aspect_ratio.rs, composite/group_box.rsseparator.tsx, tabs.tsx, accordion.tsx, collapsible.tsx, carousel.tsx, resizable.tsx, scroll-area.tsx, aspect-ratio.tsx, —
composite/sidebar.rs, composite/sidebar_layout.rs (shell: SidebarLayout + header/nav/item/footer), composite/dock.rs, composite/title_bar.rs, composite/status_bar.rssidebar.tsx (composite); the layout shell, dock and window chrome are desktop-only
primitive/bubble.rs, composite/message.rs, composite/message_scroller.rs, composite/marker.rs, composite/attachment.rsthe web ai/ family's bubble, chat-message + message-scroller, attachment
composite/charts.rs (chart, plot — generic API, series colors from palette(cx).chart(i))chart.tsx + the chart-*.tsx composites
composite/h_stack.rs, v_stack.rs, z_stack.rs, spacer.rs, scroll_view.rs, v_grid.rs, frame.rs, alignment.rs (SwiftUI-style layout: HStack/VStack/ZStack with alignment + spacing, Spacer, axis ScrollView, VGrid + GridItem, Frame)SwiftUI's layout vocabulary — no web counterpart; gallery has a dedicated section
primitive/icon.rs (Phosphor icons: PhosphorIcon catalog + Phosphor weight wrapper)lucide-react on web; desktop uses Phosphor Core (no web counterpart)
primitive/card.rs, composite/section_heading.rs, composite/text_editor.rs, composite/code_editor.rs (wrappers / celestia-only)web Card / SectionHeading idiom; text-editor.tsx / block-text-editor
primitive/chip.rs, status_dot.rs, caption.rs, image_icon.rs (ImageIcon — image/icon mark ± label), meter.rsChip / presence / caption / image+label / capacity meter (desktop-first)
composite/app_header.rs, toolbar.rs, action_bar.rs, start_bar.rs, system_tray.rs, start_menu.rs, button_group.rs, input_group.rs, panel.rs, media_object.rs, activity_feed.rs, code_block.rs, chat_composer.rs, command_palette.rs, breadcrumb_bar.rs, tab_bar.rs, split_pane.rs, inspector_panel.rs, form_field.rs, settings_page.rs, search_field.rs, filter_bar.rs, keybinding_recorder.rs, data_table_toolbar.rs, file_browser.rs, file_tree.rs, kanban_board.rs, timeline.rs, stat_card.rs, confirm_dialog.rs, toast_stack.rs, banner.rs, onboarding_tip.rs, drop_zone.rs, dnd.rs, sortable_list.rsApp-level composites; each has its own searchable gallery page under Composites
primitive/drag_handle.rs, drop_indicator.rsDnD grip + insert line (pair with composite/dnd / sortable_list)

The desktop kit ships no AI surfaces: the ai module (prompt input, reasoning, tools, plans, citations, terminal, …) was removed, and only the conversation pieces — bubble, message, message_scroller — were kept. Their web counterparts still live in packages/ui/src/components/ai/.

App composites

Presentational shells for common desktop app surfaces. The host owns Entity state (inputs, open/close, selection); builders take callbacks and slots. Import via celestia_ui::components::… (most are flat-reexported) or components::composite::tab_bar::TabBar (document tabs — the name collides with primitive::tabs::TabBar, so it stays module-path only). Full builder tables and the new primitives (Toggle, Chip, Meter, Menubar, NavigationMenu, …) live on Desktop Components.

rust
use celestia_ui::components::composite::command_palette::{CommandPalette, CommandPaletteItem};use celestia_ui::components::composite::form_field::FormField;use celestia_ui::components::composite::confirm_dialog::ConfirmDialog;use celestia_ui::components::composite::modal::{Modal, ModalSize};use celestia_ui::components::primitive::icon::PhosphorIcon;
CommandPalette::new("palette")    .item(CommandPaletteItem::new("new", "New file").icon(PhosphorIcon::File));
FormField::new("name")    .label("Display name")    .required(true)    .control(/* Input::new(&state) */);
Modal::new("edit-profile")    .title("Edit profile")    .size(ModalSize::Medium)    .child(/* body */);
ConfirmDialog::new("delete", "Delete project?")    .body("This cannot be undone.")    .destructive();

Button

Button is the one component that goes past its web counterpart, so its extra surface is worth spelling out.

Sizes are web's height ladder plus its icon ladder, in one place:

StepWebHeightPaddingGapTextIcon
XSmallxs20px8px4px10px10px
Smallsm24px10px4px12px12px
Medium (default)default26px12px6px12px14px
Mdmd28px10px4px12px12px
Largelg36px14px8px14px16px
Iconicon26px square——12px14px
IconXs / IconSm / IconLgicon-xs / icon-sm / icon-lg16 / 20 / 32px square——10 / 12 / 14px10 / 12 / 16px

Md and Medium are different steps — web's default and md are not the same thing. The icon column is deliberately non-monotonic: Md is sm with one more step of height and nothing else changed, so the taller button keeps the smaller icon. Large is the one deliberate deviation — web's lg is 36px but keeps text-xs and gap-1.5; the desktop widens both.

Variants are the web set (primary / secondary / destructive / outline / ghost / quiet / link) plus three desktop-only status fills — Success, Warning, Info — which complete the set alert.rs and badge.rs already carry. All four status variants share one recipe: the accent is the surface and the border, the paired *_foreground token is the text, and the 3D edge is the accent darkened toward black.

Icon slots and loading are typed rather than assembled by hand:

rust
use celestia_ui::components::primitive::button::{    Button, ButtonLoadingStyle, ButtonSize, ButtonVariant,};use celestia_ui::components::primitive::icon::PhosphorIcon;
// Leading / trailing slots — a bare PhosphorIcon renders at the size step's// icon size and inherits the button's text colour, so it follows the hover// fade and the disabled dimming for free.Button::new("export")    .variant(ButtonVariant::Outline)    .leading_icon(PhosphorIcon::DownloadSimple)    .trailing_icon(PhosphorIcon::CaretDown)    .label("Export");
// Icon-only: the `icon` constructor defaults to `ButtonSize::Icon`.// Always set `accessibility_label` (and usually `tooltip`) so AT and// keyboard users get a name — Tab stops + focus rings are built in.Button::icon("settings", PhosphorIcon::Gear)    .accessibility_label("Settings")    .tooltip("Settings");
// Loading: the mark takes the *leading slot* rather than being pushed in// front of it, so the label and the trailing icon keep their positions and the// button does not reflow. Interaction is blocked, but the fill, the elevation// and the opacity are kept — a loading button is busy, not forbidden.// Default mark is Spinner; pass `.loading_style(ButtonLoadingStyle::Diamond)`// for the eight-pixel diamond chase.Button::new("save")    .variant(ButtonVariant::Primary)    .loading(true)    .loading_style(ButtonLoadingStyle::Diamond)    .label("Saving");

Two GPUI-specific notes on the icon ladder. Web expresses it with a descendant selector ([&_svg:not([class*='size-'])]:size-3.5); GPUI has no descendant styling, so the slot is an explicit wrapper whose text_size a bare PhosphorIcon inherits — an icon that carries its own explicit size still wins, which is what web's :not([class*='size-']) guard buys. And Button::icon_size is the escape hatch for a glyph whose proportions need another step.

CursorStyle has no wait/progress variant, so a loading button keeps the plain arrow cursor; OperationNotAllowed is the disabled signal and would read as a broken control.

Select

Select is upstream's widget plus one addition: options can carry an icon. The widget is data-driven — the dropdown row and the trigger's committed value are drawn by the item type, so the glyph lives on SelectOption rather than on a prop:

rust
use celestia_ui::components::primitive::icon::PhosphorIcon;use celestia_ui::components::primitive::select::{    Select, SelectItems, SelectOption, SelectState,};
let status = cx.new(|cx| {    SelectState::new(        SelectItems::new(vec![            SelectOption::new("draft", "Draft").icon(PhosphorIcon::PencilSimpleLine),            SelectOption::new("review", "In review").icon(PhosphorIcon::Eye),            SelectOption::new("blocked", "Blocked")                .icon(PhosphorIcon::WarningCircle)                .icon_color(cx.theme().warning),            SelectOption::new("shipped", "Shipped")                .icon(PhosphorIcon::CheckCircle)                .icon_color(cx.theme().success),            SelectOption::new("archived", "Archived")                .icon(PhosphorIcon::Archive)                .disabled(true),        ]),        None,        window,        cx,    )    .searchable(true)});
Select::new(&status).placeholder("Status").cleanable(true)
BuilderEffect
SelectOption::new(value, label)value + the text drawn in the row and in the trigger
.icon(PhosphorIcon)leading glyph, in the row and the trigger
.icon_color(color)fixed colour instead of the surrounding text colour — status semantics only
.disabled(bool)greyed-out, unclickable row

Three things are worth knowing:

  • title() stays text. The accessible value is the plain label, so a screen reader never announces a glyph — display_title() is presentation only, per upstream's contract.
  • The icon supersedes Select::title_prefix. Upstream draws one or the other, never both, so an icon option drops the prefix. Fold the qualifier into the label instead.
  • SelectItems is only needed for search. A plain Vec<SelectOption<_>> is a delegate too; SelectItems (a SearchableVec) is what makes searchable(true) actually filter.

Icons inherit the surrounding text style, so they dim with a disabled trigger and take the row's foreground on hover with no extra wiring — .icon_color is the escape hatch for a colour that must not follow the text.

TitleBar

The window chrome is desktop-only, so TitleBar is a port of gpui-component's rather than of a web component. It is the cheapest kind of port — upstream's file reaches zero gpui-base components, so nothing had to be reimplemented on its behalf.

Use it as the first child of the window's root view, and start the window from TitleBar::window_options():

rust
use celestia_ui::components::composite::title_bar::TitleBar;use celestia_ui::gpui::WindowOptions;
let options = WindowOptions {    window_min_size: None,    ..TitleBar::window_options()   // app_owns_titlebar_drag: true};

window_options() sets app_owns_titlebar_drag: true deliberately. The bar moves the window itself via start_window_move, so AppKit must not also treat it as a system window-move region — otherwise macOS handles double clicks on its own in addition to ours and delays every title bar click while it disambiguates.

Four deviations from the upstream file, all marked DEV: in the source:

  • Caption glyphs. Upstream's IconName::WindowMinimize / WindowRestore / WindowMaximize / WindowClose are not in the Phosphor catalog, so they map to the closest Phosphor shapes: Minus, CopySimple, Square, X.
  • Vec in place of SmallVec<[AnyElement; 1]> — one inline slot is not worth a new direct dependency.
  • No Render impl on the drag state. Upstream carries an empty one with a "remove this when GPUI has released v0.2.3" TODO; Window::use_state<S: 'static> places no such bound.
  • The real ClickEvent is passed to the close callback instead of a synthesized ClickEvent::default().

Two upstream helpers were inlined rather than ported: on_double_click is on_click plus a click_count() == 2 guard, and Icon::new(..).small() resolves to a 14px glyph, which is Phosphor::new(icon).size(px(14.0)) here.

The caption cluster is platform-conditional and worth knowing about: it renders nothing on macOS (the traffic lights are the OS's) or in the browser, and on Linux it stands down entirely when window.window_decorations() is not Decorations::Client — under server-side decorations the window manager already draws min/max/close, so ours would stack a duplicate pair on top, most visibly two close buttons.

Zeron-port primitives

A first family of primitives is ported from the Zeron reference (reference/zeron, a GPUI chat app). These are celestia-native APIs — colors resolve from cx.theme() / palette(cx), never from zeron's theme — and all motion runs through one shared module:

  • celestia_ui::motion — the animation kit: CSS-exact CubicBezier curves, a MotionSpec catalog (FADE_IN, MENU_IN, DIALOG_IN, PULSE, GRADIENT_SPIN, …) with entrance helpers (fade_in, menu_in, dialog_in), premultiplied-sRGB mix, and the pulse clock: a shared ~30fps driver that keeps every mounted loader phase-locked and parks entirely once the last one unmounts (a window with no spinner schedules nothing). Reduced motion (gpui's reduce_motion flag) snaps loaders to a static rest brightness and zeroes the wave phases.
  • components/primitive/loader.rs — icon-sized (default 16px) pixel loaders that drop into the same slot as Spinner. Chase: diamond (web spin-pixel), orbit, box_dots, snake, zigzag, blocks. Processing (Cursor-style): wave, bounce, cascade, breath, spark, plus dots / bars. Boxy 3×3 (two-tone theme primary): grid_zigzag, grid_shift (scrambled phases), grid_spin — override with .color / .secondary. Uses with_animation (no EntityId); reduced motion parks every cell at a static rest brightness.
  • components/loaders.rs — the cell loaders: pulse_loader (5-cell opacity/scale wave), gradient_spinner (3×3 grid on the brand → brand_deep ramp, the pulse travels bottom-edge → top-centre), mini_spinner / mini_mono_spinner (2×3 radial chase in a self-contained view), and progress_ring (stroked-polyline radial percent, works over dimmed imagery with a white tint).
  • components/notice.rs — notice_chip: the tinted failure card (accent wash + border, alert triangle, copy-to-clipboard button, wrapping message — never truncating stderr), Plain (inline) and Tile (block) icon treatments, danger/warning palettes.
  • components/context_badge.rs — MessageBadge pills with BadgeDetail rows: a 24px chip that lifts a hover card of location/tag/body rows after a 280ms hover delay. The three slots are generic — the module never learns what the context cites.

The gallery's Feedback section renders all of them live.

What could not be ported from Zeron

Zeron does not run stock gpui — it builds on its fork zeronsh/zui (checked out at reference/zui), which adds renderer-level primitives: BackdropBlur (frosted glass), per-pixel EdgeFade / Window::with_edge_fade (scroll-edge fades), transparent-window compositing and image corner-radius masks. the pinned gpui (the gpui-pre series) has none of these, so zeron's frost.rs, glass.rs and edge_fade.rs stay unported — adopting them would mean replacing the whole pinned stack with the fork. Everything built on the shared core API (div/Styled, Animation, canvas, element state) ports cleanly, which is the family above.

Motion

celestia_ui::motion has two layers, and it is worth knowing which one a given component is on.

1. The design-system token layer. The same curves and durations the web primitives use, ported from @theme in packages/ui/src/styles/globals.css:

TokenValueUsed for
EASE_OUT_TOKENcubic-bezier(0.23, 1, 0.32, 1)the signature curve — hovers, indicator, entrances
EASE_IN_OUT_TOKENcubic-bezier(0.77, 0, 0.175, 1)deliberate moves
EASE_DRAWER_TOKENcubic-bezier(0.32, 0.72, 0, 1)sheets, drawers, the overlay scrim
DURATION_INSTANT80mspress feedback (active:*)
DURATION_FAST150mshover colour and background fades
DURATION_NORMAL220msthe tabs indicator, panel entrances
DURATION_SLOW320mssheets, navigation menus, toasts
DURATION_SLOWER500mscaret blink, long reveals

They are exposed three ways. Named MotionSpecs (HOVER, SLIDE, PRESS, POPUP_IN, OVERLAY) let a component name the effect it is reproducing; spec_transition(spec) (and the hover_transition / press_transition / slide_transition shorthands) turn one into a gpui_base value-transition policy for transition-colors-style fades; and motion_tokens() packages the whole scale as gpui-component's MotionTokens.

rust
// `transition-colors duration-fast ease-out` — a hover fade between two states.let t = transition(("btn-hover", self.id.clone()), hovered as u8 as f32,                   motion::hover_transition(), window, cx);let bg = motion::mix(rest, hover, t);

theme.json cannot carry this scale — gpui-component's Theme::motion is #[serde(skip)] — so theme::install assigns it directly. That one assignment is what re-times every re-exported gpui-component component (switch, checkbox, slider, accordion, collapsible, progress, carousel, TabBar) off gpui-component's own 0/120/180/280ms ladder and onto the design system's 80/150/220/320ms. Because apply_config never touches the field, it survives Theme::change and system-appearance switches.

Colour blending goes through motion::mix, which interpolates component-wise in sRGB with premultiplied alpha — the way a browser transitions a colour — so a wash fading in from transparent brightens rather than passing through grey.

2. The Zeron catalogue. The entrances and the loader pulse clock described under Zeron-port primitives below are a separate, older layer: Zeron's own curves (EASE_OUT_EXPO, EASE, …) and periods (PULSE, GRADIENT_SPIN). They have no web-primitive counterpart to match and were deliberately left alone.

Focus rings

Every focus ring is 2px at full strength — focus-visible:ring-2 focus-visible:ring-ring, enforced on the web by scripts/ui-audit/focus-rings.mjs. gpui-component's own focus_ring_style draws 3px at 50% opacity, which that rule forbids, so celestia_ui::focus_ring::focus_ring is an in-crate replacement: it reads the element's own border widths and corner radii, adds 2px, and attaches an absolutely-positioned bordered child at -inset coloured cx.theme().ring with no alpha.

Shared interactive primitives (Button, Switch, TabsTrigger, SidebarNavItem, Fab) track focus with tab_stop(true), announce a Role, and paint that house ring when focused. Icon-only controls need an explicit accessibility_label (falling back to tooltip / visible label on Button).

What GPUI cannot animate

GPUI has no element transform — only images and SVGs take a TransformationMatrix, so there is nothing to scale a subtree with. Two web effects therefore cannot be reproduced literally:

  • active:scale-[0.97] on a tab trigger. The desktop keeps the web's colour behaviour (no colour change beyond hover) and drops only the geometry.
  • zoom-in-[0.99] on the tabs panel entrance. The 1% zoom is approximated by a 2px rise, which reads the same on a panel edge.

gpui-component documents the same constraint for the dropdown entrance in its popover.rs. Anything built on the shared core API — div/Styled, Animation, canvas, element state — ports cleanly; only the renderer-level extras do not.

The component-by-component audit that produced this layer, including the per-wrapper parity tables and the deliberate non-goals, lives at packages/desktop/AUDIT-component-motion.md.

Layout stacks — SwiftUI vocabulary

components/composite/ carries SwiftUI's layout vocabulary — one file per construct, no web counterpart. Each is a thin RenderOnce wrapper over a gpui flex row / column / layer, so they compose with every other component in the library:

ComponentMaps toNotes
HStackHStack(alignment:spacing:)flex row; spacing becomes the flex gap (default 8px); alignment is the vertical cross axis (Top / Center / Bottom)
VStackVStack(alignment:spacing:)flex column; defaults Leading / 8px; alignment is the horizontal cross axis (Leading / Center / Trailing)
SpacerSpacer(minLength:)expands along the parent stack's main axis; min_length floors BOTH axes (min-width and min-height)
ZStackZStack(alignment:)every child becomes an absolutely-positioned full-bleed layer; the single alignment pins all layers; fills its parent, so give it a bounded frame
VGridLazyVGrid(columns:spacing:)children chunk into rows sized by GridItem::Fixed(px(...)) / GridItem::Flexible rules; rows wrap in child order; spacing applies between rows and columns
rust
use celestia_ui::components::composite::alignment::{    HorizontalAlignment, VerticalAlignment, ZAlignment,};use celestia_ui::components::composite::h_stack::HStack;use celestia_ui::components::composite::spacer::Spacer;use celestia_ui::components::composite::v_grid::{GridItem, VGrid};use celestia_ui::components::composite::v_stack::VStack;use celestia_ui::components::composite::z_stack::ZStack;
// Toolbar idiom: content leading, actions trailing, Spacer in between.HStack::new(VerticalAlignment::Center).spacing(px(8.))    .child(title)    .child(Spacer::new())    .child(Button::new("act").label("Action"))
// One alignment pins every ZStack layer; compose a layer (an inner// HStack) to place content on independent edges.ZStack::new(ZAlignment::BottomTrailing)    .child(div().size_full().bg(photo))    .child(        div().w_full().p_3().child(            HStack::new(VerticalAlignment::Bottom)                .child(caption)                .child(Spacer::new())                .child(badge),        ),    )
// Column rules: Fixed pins a width, Flexible shares what's left.VGrid::new([GridItem::Fixed(px(88.)), GridItem::Flexible, GridItem::Flexible])    .spacing(px(8.))    .children(cards)

Three gotchas worth internalizing:

  • The stacks are layout-only — they implement ParentElement but not Styled, so there is no HStack::new(..).p_3(). Wrap them in a plain div() for width, padding, or background.
  • ZStack is always full-bleed. Its root is relative size_full and each child is wrapped in an absolute inset-0 layer, so a ZStack inside an unsized container collapses. Put a real frame (h(px(..)) / size_full) under it.
  • VGrid grows vertically — wrap long content in a ScrollView (the axis ScrollView lives beside it in composite/).

The gallery's Layout section (crates/showcase/src/sections/layout.rs) renders every alignment variant of all three stacks live, states the defaults and gotchas in each card's footer, and ships an interactive playground: pick the axis (row / column / depth), alignment, spacing, and child count, and the preview plus the exact constructor snippet update together.

Icons — Phosphor

crates/ui/src/components/primitive/icon.rs ships the Phosphor icon set (Phosphor Core 2.1.1, MIT — license vendored at assets/phosphor/LICENSE-PHOSPHOR). The upstream gpui-phosphor crate can't be used directly — it targets the gpui 0.2 lineage and can't link against the pinned gpui-pre types — so the approach is ported onto this stack instead:

  • SVGs — 1,512 glyphs × 3 weights (regular, bold, fill) vendored under crates/ui/assets/phosphor/<weight>/<name>.svg. Duotone is skipped: GPUI's SVG renderer paints every path with one color, so duotone would render as solid.
  • PhosphorIcon — a typed catalog enum generated by crates/ui/build.rs at compile time (PhosphorIcon::Heart, PhosphorIcon::PaperPlaneTilt, …, plus ALL / COUNT / name()). It renders through a plain gpui::svg — no third-party icon component sits in between — and bare .child(PhosphorIcon::Heart) renders at the surrounding text style, regular weight.
  • Phosphor — the weight-aware wrapper (the counterpart of gpui-phosphor's Icon builder): Phosphor::new(PhosphorIcon::Heart).weight(PhosphorWeight::Fill).size(px(20.)).color(…). Size and color default to the surrounding text style. The weight is just the asset-path prefix (phosphor/<weight>/<name>.svg), so every enum variant exists at every embedded weight.
bash
cargo test --workspace   # asset-source tests: every glyph resolves at every weight

Asset source registration (required)

gpui takes a single asset source at startup. Register the composite from celestia_ui::assets in main — it serves the vendored Phosphor SVGs and reports every other path as a clean miss:

rust
use celestia_ui::assets::CelestiaAssets;
gpui_platform::application()    .with_assets(CelestiaAssets)          // phosphor/…    .run(|cx| { /* … */ });

Without a registered source gpui's fallback serves nothing and every icon renders blank silently — CelestiaAssets fixes it. Phosphor is the only catalog: the crate used to fall through to a bundled Lucide set, which was dropped once the three Lucide glyphs still in use (a tab's BookOpen, the showcase Visual/Docs toggle's Eye and BookOpen) proved to have direct Phosphor equivalents. The gallery's Phosphor Icons section (crates/showcase/src/sections/icons.rs) renders the full catalog with live search, a Regular/Bold/Fill weight switcher, and click-to-copy PhosphorIcon::… names.

An application can layer its own media on top the same way: embed a folder with rust_embed and wrap CelestiaAssets as the fallback. The showcase does exactly this — crates/showcase/src/assets.rs embeds its demo avatars (assets/avatars/**, used by the chat section's image Avatars) and registers a ShowcaseAssets that checks the demo media first, then falls through to CelestiaAssets.

Theming

crates/ui/src/theme.json is the sRGB-hex port of packages/ui/src/styles/globals.css — the web CSS file stays the single source of truth, and the JSON is re-derived from it when tokens change (grays via OKLab → linear sRGB = L³, chromatic tokens through full OKLab → sRGB with gamut clamping, alpha tokens as 8-digit hex). init() installs Celestia's own Theme global from that JSON and mirrors the light/dark pair onto gpui-component's widget theme, so one mode switch keeps the whole window consistent.

App authors never edit theme.json by hand. They call celestia_ui::init, then either flip light/dark or overlay tokens with AppTheme.

How end users use the theme config

WhenCall
Startupcelestia_ui::init(cx), then optional AppTheme { … }.apply(cx)
Settings “Dark mode”Theme::toggle(cx) or Theme::change(…)
Brand / radius / font prefsmutate AppTheme → .apply(cx)
Multiple skins.register("Name", cx) + select("Name", cx)
Reset to defaultsAppTheme::default().apply(cx)

Always import ActiveTheme / Theme / ThemeMode from celestia_ui::theme (or celestia_ui), never from gpui_component — the two crates each have a Theme global, and mixing them produces a split shell (light sidebar on a dark content pane).

Theme::change / Theme::toggle also swap the syntax highlighter used by CodeEditor (tree-sitter). Celestia’s theme.json is colors-only; the bridge loads gpui-component’s default light/dark highlight themes so the editor pane and token colors follow the active mode.

Live demo: the gallery’s Theming & Config section (crates/showcase/src/sections/theming.rs) switches families, registers a Nocturne custom theme, and swaps radius presets, font faces, and accent palettes.

1. Bootstrap once

rust
celestia_ui::init(cx); // installs Celestia light/dark

2. Light and dark

rust
use celestia_ui::theme::{ActiveTheme as _, Theme, ThemeMode};
Theme::toggle(cx);                  // flip light ↔ dark (⌘D in the gallery)Theme::change(ThemeMode::Dark, cx); // or set explicitlyTheme::sync_system_appearance(Some(window), cx);
let dark = cx.theme().is_dark();let bg = cx.theme().background;     // every component already reads these

3. Theme config (AppTheme) — the main knob

theme.json is the compiled-in default; the runtime knob over it is celestia_ui::theme::AppTheme (crates/ui/src/theme/config.rs) — the desktop counterpart of the shadcn :root / .dark blocks in globals.css. One config controls the whole library's colors, radius, and font. Every Scheme field is optional: None keeps the Celestia default. Every AppFont field is optional too: None keeps Geist and the platform monospace face. Colors are written as hex(0xrrggbb).

rust
use celestia_ui::theme::{hex, AppFont, AppTheme, Scheme};use gpui::px;
AppTheme {    radius: px(10.),    font: AppFont {        family: Some("Georgia".into()), // None = Geist (embedded)        size: Some(px(18.)),            // None = 16px, also the rem size        mono_family: Some("Menlo".into()),        ..Default::default()    },    light: Scheme {        primary: Some(hex(0x2563eb)),        ..Default::default()    },    dark: Scheme {        primary: Some(hex(0x6aa5ff)),        background: Some(hex(0x0b1120)),        ..Default::default()    },}.apply(cx); // whole window re-themes; survives later Theme::toggle

Reset:

rust
AppTheme::default().apply(cx);

Useful Scheme fields (per mode): background, foreground, surface, popover, primary, secondary, muted, accent, the status accents (destructive / success / warning / info, each with a foreground), border, input, ring, and the sidebar* family.

Semantics:

  • Overlay, not replacement. AppTheme::default().apply(cx) is a re-assert of theme.json, not a wipe.
  • One token drives every key it owns. Setting primary also re-colors hover/active steps, button.primary.*, ring, caret, link.*, slider/progress fills, drag.border, list/table active borders, the 22% selection wash, and primary-tinted surfaces (list / tab / table.active, drop_target, at 10% over the background).
  • One radius. radius maps to shadcn’s --radius; dialogs/notifications use radius_lg = radius + 4px.
  • One font. font is an AppFont. family / size are the UI face and the window rem size (unset is Geist at 16px — the faces in assets/fonts/, SIL Open Font License, registered by celestia_ui::init). mono_family / mono_size are the code face (unset is Menlo, Consolas, or DejaVu Sans Mono at 13px). apply writes both onto Celestia’s Theme and onto gpui-component’s theme, which the window root uses for the cascading family and set_rem_size. Code, terminals, and Typography code read cx.theme().mono_font_family. Name a family that is installed, or one embedded with add_fonts. .SystemUIFont is the platform face when an app wants to leave Geist.
  • Mode-safe. apply overlays the active family and re-applies the current mode, so later Theme::toggle / sync_system_appearance keep the configured palettes and the font.

4. Named theme families (optional)

Light, dark, and custom themes share the same token vocabulary. A family is a named light + dark pair. install() seeds Celestia from theme.json; crates/ui/src/theme/book.rs keeps the registry.

rust
use celestia_ui::theme::{active, families, hex, select, AppTheme, Scheme};use gpui::px;
AppTheme {    radius: px(8.),    dark: Scheme {        background: Some(hex(0x0b1120)),        primary: Some(hex(0x38bdf8)),        ..Default::default()    },    ..Default::default()}.register("Nocturne", cx); // register + activate
select("Celestia", cx); // switch back — light/dark mode is preservedfamilies(cx);           // ["Celestia", "Nocturne"]active(cx);             // "Celestia"
  • register derives the pair from the Celestia base (same fan-out as apply), names the configs "<Family> Light/Dark", and activates them. Re-registering a name replaces that family.
  • apply is family-aware. It overlays whichever family is active and stores the result on it, so token tweaks survive switching away and back.
  • Mode toggles stay inside the family. Theme::toggle / Theme::change / sync_system_appearance operate on the active pair; ⌘D in the showcase flips light/dark within it.

The mechanics live on Celestia’s Theme / ThemeColor / ActiveTheme (also re-exported at celestia_ui::{Theme, ThemeMode, ActiveTheme, …}). Custom families stay out of gpui-component’s ThemeRegistry; mode flips still sync that registry’s Celestia light/dark pair so re-exported widgets match.

Pins and product colors

Key values are pinned by cargo test --workspace: light primary #d40c1a, dark #ff4d46 (out-of-gamut oklch(0.68 0.22 27) clipped), the dark border as white-alpha, selection always primary at 22%, and a guard that fails if primary regresses to the retired brand blue. config.rs / book.rs tests cover default apply, radius derivation, per-mode overrides, font apply (both theme globals, surviving a mode toggle, reset), and register / select / apply interplay.

Mode-independent product colors (brand #dc2626, brand_deep #b51230, brand_foreground, the chart ramp) live in palette(cx) — like the web :root, they are defined once and never re-themed.

State management

crates/ui/src/state/ is the desktop counterpart of the web app's zustand stores and React contexts, built on GPUI's entity model (an entity re-renders when it calls cx.notify()). Two layers:

  • Store / StoreHandle — the zustand layer. StoreHandle::new(state, cx) creates a store entity; read(cx) is getState(), set(cx, …) / update(cx, …) are setState() and notify every observer, replace(cx, state) swaps the whole value, and set_if_changed skips the notification when the state compares equal afterwards. Actions are plain &mut self methods on the state type, passed as the closure: store.set(cx, AppState::close_tab).
  • StoreContext<T> — the React-context layer. StoreContext::<T>::provide(state, cx) registers an ambient, store-backed value once at bootstrap; any code later calls StoreContext::<T>::require(cx) (or consume) and gets the full read/write handle with no prop drilling. There is one ambient value per state type per app — for multiple live instances of the same type, skip the ambient layer and hand out StoreHandles explicitly.

The re-render gate is observe_slice: bind an entity to a selector over the state, and its callback (where you call cx.notify()) only runs when that slice's value actually changes — the useStore(selector) equivalent. subscribe is the unfiltered variant for side effects (persistence, logging). Like GPUI's own observers, callbacks run once per update batch with that batch's final state, not once per set call.

The showcase's State & Stores section (crates/showcase/src/sections/state.rs) renders this live: a GalleryState store with per-slice notification counters, and a Session ambient context provided in main.rs. The layer is unit-tested with #[gpui::test] tests inside crates/ui/src/state/.

Full usage guide — defining stores, actions, gating, contexts, testing, and the live demo — at Desktop State.

Building a desktop app on it

Start from the starter crate

Scaffold a standalone app with the create CLI:

bash
npx @celestia-project/create my-desktop --template desktopcd my-desktop && cargo run -p my-desktop

Or run the in-repo skeleton with pnpm desktop:starter (packages/desktop/crates/starter). Both wire assets, celestia_ui::init, TitleBar, SidebarLayout, theme toggle, and a two-page home screen.

Depend on the crate (celestia-ui = { path = "…/crates/ui" }, plus gpui, gpui-component and gpui-platform), register the asset source with .with_assets(celestia_ui::assets::CelestiaAssets) (see Icons — Phosphor — without it icons render blank), call celestia_ui::init(cx) inside application().run(...) before opening any window, and wrap your root view in a gpui-component Root. Dialog / sheet / toast overlays are mounted by the root plugin that celestia_ui::init registers (gpui-component 0.7) — do not re-mount Root::render_*_layer in your view.

Bootstrap order (from crates/starter/src/main.rs):

rust
gpui_platform::application()    .with_assets(CelestiaAssets)    .run(|cx| {        celestia_ui::init(cx);        // keybindings, StoreContext::provide, …        cx.open_window(options, |window, cx| {            let view = cx.new(|cx| MyApp::new(window, cx));            cx.new(|cx| Root::new(view, window, cx))        })        .expect("open window");        cx.activate(true);    });

The showcase (crates/showcase) remains the full component reference; the starter is the app skeleton.

  • Desktop Components — Toggle, Chip, Meter, Menubar, and app composites (header, toolbar, panel, feed, …)
  • Desktop State — StoreHandle, ambient context, selector-gated subscriptions
  • Desktop Bridge — HTTP client from GPUI to apps/api