Files
gpui-native-kit/docs/PLAN.md
T

79 KiB
Raw Blame History

gpui-native-kit — Project Plan

Rev 8 (2026-11-01). Changes from rev 7: the signed migration step is removed — its §10 and §12 traces cleaned up; signed stays the first downstream consumer whose screens drive §6.3 and §6.4, but porting the app stays app-side and off the kit roadmap. A new layout crate (§6.4, D26) bootstraps the application shell — left sidebar on, right sidebar off, center dock with drag-to-split panes — reversing the old "dock stays app-side" deferral; Phase H now covers it. The rev 7 rules stand: components are built one element at a time behind a review gate — each unit is designed, implemented, tested, shown in the gallery, and reviewed before the next starts (batch waves are gone). gpui-kit base is re-admitted as the API-design reference: its unstyled-control conventions (controlled state, one activation path for pointer + Enter + Space, semantic states, a11y labels) shape our component APIs, and small utilities may be adapted where they help. Everything else (empty-project reset, theme engine as the product, crate split, per-component features) stands.

1. Vision

GPUI components that feel native to the OS they run on — macOS, Windows 11 (WinUI), GNOME, KDE, Omarchy — with a designed neutral fallback for unknown systems. Every color, radius, spacing, metric, and icon comes from engines that read the running system or a user-supplied theme. The theme and icon engines are the product; components are their proof.

Consumption is flexible: small focused crates, per-component cargo features, and a thin optional facade. A hello-world app can depend on gpui-native-core with two features; a full app can depend on the gpui-native-kit facade with --all-features.

2. Evaluated foundations (2026-10-28)

Crate What it is Verdict What we take
gpui-kit (longbridge) Unstyled base behavior layer (controlled state, one activation path, semantic states, a11y roles, focus utilities) + styled component library Not a wholesale foundation: the full base is larger than we need and pins gpui-pre snapshots. But base is the API-design reference for our components (D20), and its small utilities are adapted where they help API shapes: Button::new("save") unstyled + .on_click as the single path for pointer/Enter/Space; controlled checked + on_change(next); disabled/selected semantic states resolved after the builder chain, disabled last; accessibility_label; composable parts (CheckboxIndicator, SwitchTrack/SwitchThumb) where a component needs them. Utilities worth adapting: h_flex/v_flex, StyledExt, StateStyle, focus-ring helper
gpuikit (iamnbutler) Web/SwiftUI-style toolkit (editor, input, keymap, markdown, selection, a11y modules) on gpui-unofficial Not a foundation: builds on the gpui-unofficial crates.io distribution (conflicts with D3); generic theme system with no per-OS native look Component API ergonomics; showcase/gallery pattern
bezel (crabtalk) SwiftUI-lean library; style flows through the environment — Theme::of(cx) + catalog traits, zero style parameters, Brand/Tint hue re-theming; own gpui fork (bezel-gpui) Not a foundation: gpui fork (conflicts with D3); one visual language (macOS-leaning HIG), not per-OS native Environment-first styling: components read the theme global at paint time and take no color/size parameters; Brand-style hue overrides inform custom themes
zeron (zeronsh) Production gpui app (coding-agent control plane; crates: ui, theme, text, syntax, term, …) Not a foundation — an application, not a library Existence proof that gpui scales; app-level patterns for data-heavy screens

Decision D15: we depend on no UI toolkit and port no wholesale subsystems. These crates (plus native-theme, D1) are read for API and design ideas — and always simplified, never mirrored 1:1. When in doubt, ship the smaller API.

3. Background facts (verified)

  • GPUI install shape — the only framework dependency:
    gpui          = { git = "https://github.com/zed-industries/zed", rev = "ec86047d67acd6fddd81392f44ed6ebcc6e7f531" }
    gpui_platform = { git = "https://github.com/zed-industries/zed", rev = "ec86047d67acd6fddd81392f44ed6ebcc6e7f531", features = ["font-kit"] }
    
    font-kit is required on macOS for glyph rasterization; Linux needs wayland and/or x11; Windows needs nothing. No gpui-pre, no forks, no gpui-unofficial. Toolchain 1.98.1 via rust-toolchain.toml (matches zed at the pinned rev; verified building on this machine). Bumping the pin is a deliberate act: re-verify, fix drift, note it in the bump commit message.
  • native-theme design (the blueprint we adapt, not a dependency): sparse TOML-shaped Theme (Option fields) resolves into a fully populated ResolvedTheme; bundled presets; AccessibilityPreferences; thin per-toolkit connectors. Its GPUI connector targets gpui-component — a case study only.
  • xdg-desktop-portal Settings (verified against the spec): org.freedesktop.appearance standardizes color-scheme (u), accent-color ((ddd) RGB 0–1), contrast (u), reduced-motion (u), with SettingChanged — one DBus interface covering mode, accent, contrast, and reduce-motion across GNOME, KDE, and generic Linux.
  • Omarchy theme contract (verified against the manual and the shipped tokyo-night theme): active theme is $HOME/.local/state/omarchy/current/theme/colors.toml with theme.name beside it (legacy ~/.config/omarchy/current; current/theme is a symlink into the theme set). colors.toml states mode = "dark" | "light" plus semantic keys — accent, selection, muted, background/dark_background/darker_background/lighter_background, foreground/dark_foreground/light_foreground/bright_foreground, and red/yellow/orange/green/cyan/blue/magenta/brown with bright_*; the ANSI color0..15 format is an accepted alternative.
  • Platform icon systems: macOS SF Symbols — thousands of template glyphs, runtime lookup via NSImage(systemSymbolName:) rendered at a requested size/weight; Apple's license forbids redistributing the set, so it is used at runtime only, never bundled. Windows 11 Segoe Fluent Icons — a system font addressed by codepoint. Linux (and Omarchy) — the freedesktop icon-theme spec: lookup across the active theme (Breeze, Adwaita) and hicolor. Lucide (ISC) is the bundled offline fallback everywhere else.
  • Prior art — gpui-omarchy (huacnlee, MIT): validates the Omarchy adapter design: read colors.toml + theme.name, watch filesystem events for palette edits and theme-symlink replacement, follow_system / explicit apply to pause/resume following, whole-palette fallback to Tokyo Night when files are missing or invalid.

4. Architecture

4.1 Workspace layout (Phase A)

gpui-native-kit/                 # repo root = workspace root
├── Cargo.toml                   # [workspace]; root package = facade
├── rust-toolchain.toml
├── AGENTS.md
├── docs/PLAN.md
├── src/lib.rs                   # gpui-native-kit: thin facade (re-exports, feature-driven)
├── crates/
│   ├── theme/                   # gpui-native-theme: the native theme engine
│   ├── icons/                   # gpui-native-icons: system icons + bundled fallback
│   ├── utils/                   # gpui-native-utils: internal shared helpers (not re-exported by the facade)
│   ├── core/                    # gpui-native-core: components every app needs
│   │                            # (one cargo feature per component)
│   ├── data/                    # gpui-native-data: List, Tree, Tabs, Sidebar (one cargo feature each)
│   ├── layout/                  # gpui-native-layout: application layout bootstrap + center dock (§6.4)
│   └── editor/                  # gpui-native-editor: created when the editor units need it (D12, §6.3 M15)
└── examples/
    └── gallery/                 # showcase; a page per component + theme/icon inspector

Advanced crates (data for Tree/List/Tabs/Sidebar, layout for the application shell (§6.4), charts, pickers) are created on demand when their wave starts — not speculative scaffolding.

graph TD
    FAC[gpui-native-kit facade — optional] --> CORE[gpui-native-core]
    FAC --> FUT[data / layout / editor / …]
    CORE --> T[gpui-native-theme]
    CORE --> I[gpui-native-icons]
    T --> P[gpui + gpui_platform — zed, pinned rev]
    I --> P

One-way, no cycles. Nothing depends on gpui-component, gpui-kit, gpuikit, bezel, or native-theme. Applications may depend on gpui-native-core directly and skip the facade.

4.2 Feature model

  • One feature per component in core (button, checkbox, switch, radio, toggle, link, slider, progress, avatar, input, popover, tooltip, menu, dialog, alert, toast, …) and in data (list, tree, tabs, sidebar). default = ["full"]; lean apps use default-features = false + the features they want.
  • Features gate whole modules only (#[cfg(feature = "…")] pub mod button;) and are additive. A component that composes another declares the dependency in Cargo.toml (dialog = ["button"]). No cfg logic may cross modules.
  • Facade features map to crates: core (default), data, layout, editor, …
  • Shared helpers promoted per D16 live in an ungated common module so every feature combination sees them.
  • CI checks three points, not a full matrix: default, --no-default-features, --all-features, plus a compile check of each feature alone.

4.3 Component design

  • One element at a time, review-gated (D20). Each component is one unit: API sketch → implementation → tests → gallery page → review. The next unit starts only after the previous one is approved. No batch waves.
  • API shape follows bezel and gpuikit conventions where they fit — both are read as references only, never ported (D15) — always simplified:
    • Controlled components: the owner passes the current value, the callback reports the next value, the owner updates state and calls cx.notify(). Checkbox::new(id).checked(v).on_change(move |next, window, cx| …).
    • One activation path: pointer, Enter, and Space all fire the same on_click (gpui synthesizes keyboard clicks for tracked focus elements).
    • Semantic states as builders — disabled(bool), selected(bool), checked(bool), pressed(bool) — resolved after the builder chain and painted from theme state colors; disabled resolves last and always wins.
    • accessibility_label(...) on every control; correct accesskit role per control (Button, CheckBox, Switch, RadioButton, Link, …).
    • Composable parts (CheckboxIndicator, SwitchTrack/Thumb) only where a component genuinely needs independently painted pieces — we paint from tokens, so most parts collapse into the component itself; decide per review.
  • Value-like controls are #[derive(IntoElement)] + RenderOnce; stateful ones are Entity<XxxState> the application owns.
  • No style parameters (bezel's model): a component reads the theme global at paint time and takes zero color/size arguments. Overrides are gpui style modifiers chained by the caller.
  • Behavior is inline. Activation, focus, keyboard, dragging, and a11y are written per component with gpui primitives (Interactivity, FocusHandle + tab_stop(true), actions!, a11y builders). A helper is promoted to shared common only when a third component needs it — no pre-built behavior layer.
  • Deliberate divergence from the references: our Link opens href through the platform by default (cx.open_url) — native feel is the product — with on_click as an override for apps that route internally. Neither bezel nor gpuikit opens URLs; we do unless told otherwise.
  • Each component has a named native reference per platform (NSButton / WinUI Button / Adwaita / Breeze) recorded on its gallery page; "native feel" is reviewed against those references.
  • Naming seams per AGENTS.md: is_/has_ readers, with_ builders, set_ mutation, on_ callbacks, State/Delegate suffixes, stable domain-derived ElementIds.
  • Motion is inline and per component (D21). Each component animates with gpui's own primitives (with_animation, with_spring, the gpui easings, with_max_fps) directly in its own file, a few lines at most, using theme durations where they're shared (Motion.overlay). No shared motion module, no ghost panels, no hover-wash plumbing; overlay exit animations and hover washes are dropped until a genuinely simple approach exists.
  • Theme tokens are read via cx.theme(), held when a render can hold it — a component's own render does let theme = cx.theme(); (tooltip-style) and reads theme.x at the paint sites, provided nothing after it needs &mut cx; helpers passed cx: &App hold their own borrow the same way. Never pass &Theme between functions or re-bundle theme values into local structs. Compute repeatedly-needed allocations (shadow lists) once per render where cheap.

4.4 Icons (crates/icons)

  • IconRole — a semantic enum of the glyphs UI actually needs (~25 to start): chevrons, check, close, plus/minus, search, folder, warning/info/success, copy, arrow-left/right, calendar, clock, settings, dots-horizontal, … Components and apps reference roles, never glyph names.
  • Resolver cascade per platform, behind one Icon element:
    • macOS: SF Symbols via NSImage(systemSymbolName:), rasterized through AppKit at (size, weight, appearance, tint) and cached; never bundled. Deferred — macOS rides the Lucide fallback until it shows it matters.
    • Windows: Segoe Fluent Icons codepoints rendered as text (system font). ✅ Landed (2026-10-30): role→codepoint table transcribed from Microsoft's glyph list (Close uses ChromeClose E8BB, not Cancel E711), drawn at the icon's size in Segoe Fluent Icons; unverified on hardware until the Windows screenshot pass.
    • Linux/Omarchy: freedesktop icon-theme lookup (active theme → hicolor), SVG painted via gpui's svg(). ✅ Landed (2026-10-30): bounded walk over $HOME/.icons + the XDG data dirs handling both real layouts (size/context/ for Adwaita and Papirus, context/size/ for Breeze); scalable wins, else the closest numbered size; inherits chain capped at five themes, then hicolor; theme name read from Omarchy's icons.theme → kdeglobals [Icons] → gsettings → Adwaita; results cached per (role, size) for the process; only .svg matches (PNG-only themes fall through); a miss falls back to the bundled Lucide. Resolver fixtures run on every OS.
    • Fallback everywhere: a bundled Lucide subset (ISC; attribution in NOTICE) — ships first so components work before platform resolvers land.
  • Public API stays tiny: icon(role) → element with .size(...); tint defaults to theme tokens. A missing glyph logs once and falls back — never panics.
  • Gallery page: icon browser showing every role resolved on the current platform.

4.5 Theme pipeline

graph TD
    OS[Adapters: macOS / Windows 11 / GNOME / KDE / Omarchy / fallback] --> ST[SystemTheme normalized snapshot]
    PR[ThemeDefinition presets + user overlays] --> RS[resolve]
    ST --> RS
    RS --> RT[ResolvedTheme + AccessibilityPreferences]
    RT --> TK[token projection onto the Theme global]
    TK --> WIN[all windows repaint]
    EVT[runtime change events] -->|re-read, re-resolve, re-install| TK
  1. Read — one adapter per platform produces a normalized SystemTheme (mode, accent, semantic palette, accessibility flags, style hint).
  2. Detect — capability cascade: Omarchy marker → KDE (kdeglobals present) → portal reachable (GNOME/generic Linux) → fallback. macOS/Windows by compile target.
  3. Resolve — overlay chain: platform reader → bundled preset → user theme → runtime overrides → accessibility adjustments. Pure function, unit-testable without a GUI.
  4. Project — ResolvedTheme → the token structs components consume, installed on the theme Global.
  5. Observe — ThemeWatcher forwards real change events (distributed notifications, WinRT ColorValuesChanged, portal SettingChanged, inotify on KDE/Omarchy files); each event re-runs read → resolve → project → repaint.

5. Theme engine (crates/theme)

5.1 Architecture

  • ThemeSource trait — one adapter per platform: snapshot() -> SystemTheme plus watch(callback). Adapters are thin over parseable payloads (ini text, registry dumps, portal replies) so parsing is fixture-testable on any OS.
  • SystemTheme — normalized snapshot: mode, accent, semantic palette, high-contrast, reduce-motion, reduce-transparency, text-scaling, style hint, platform identity.
  • ThemeDefinition — sparse TOML-serializable Option-field struct; bundled presets and user themes share one schema.
  • ResolvedTheme — fully populated; resolve(system, definitions…, prefs) is pure.
  • AccessibilityPreferences — text scaling multiplies font sizes (through rem, all rem-relative sizing); reduce-motion forwards to GPUI; reduce-transparency drops overlay backdrops; high-contrast sharpens borders and removes subtle fills.
  • ThemeWatcher — owns adapter watches, drives the observe step.

5.2 Role mapping

Token role Source
background / foreground window background / default text
surface / surface_foreground cards / secondary areas
primary, secondary, accent button + selection colors (macOS: accent-derived)
muted secondary text and hover shades
destructive danger role
border / input control border line / field background
ring focus ring
selection text selection background

Omarchy colors.toml maps almost 1:1: lighter_background → surface, dark_background/darker_background → elevation depth, dark_foreground → muted foreground, accent → primary/accent/ring, red → destructive, green/yellow/orange → success/warning. Every mapping lives in one module; never literals. Tests assert finite, distinct, semantically sane tokens for every preset × mode.

The set stays role-minimal and grows one concept at a time, driven by the unit that needs it. Known planned additions:

  • Overlay scrim + shadow/elevation tokens — ✅ landed with unit 11 (2026-10-29): colors.overlay (translucent-black scrim, denser in dark; TOML-overridable; no platform states one), Radius.overlay (10 pt), Elevations.popover/dialog (builder → Vec<BoxShadow>), and Motion.overlay (200 ms enter transition). Popover consumes elevation + motion; the scrim waits for Dialog. Per-platform stacks (2026-11-01, D24): Elevation became a two-layer key + ambient stack and resolve picks it per SourceKind — macOS soft and layered (key shadow at 12% alpha), Windows tight and offset, Linux compact and darker; dark mode shares one stack — deleting the Alert's light-mode alpha hack
  • Scrollbar thumb token + metrics — ✅ landed with the ScrollArea unit (2026-10-29): colors.scrollbar_thumb (translucent over content: black in light, light gray in dark; TOML-overridable) and Theme.scrollbar (width 8 pt, inset 2 pt, min_thumb 24 pt).
  • success / warning roles — ✅ landed with Toast (2026-10-29): colors.success/warning (+ foregrounds; light and dark variants; TOML-overridable; no platform states them) and derived Ramps.success/warning via the OKLCH engine, matching the destructive treatment. info joined them (2026-10-31) — the fourth severity, same treatment (colors.info + foreground + Ramps.info, neutral sky blue, no platform states it, TOML info =). Trigger: the Alert's Info severity was painting from the primary ramp, so a warm platform accent (GNOME red, Omarchy red themes) made Info read as Danger; the token keeps Info calm however the accent turns out. Severity tokens now use the same picked value for the ramp and the token (the success/warning tokens used to re-pick with the system slot dropped, diverging from their own ramps if a platform ever stated one).
  • Motion tokens — after the reverted framework attempt (2026-10-30), motion stays minimal: Motion.overlay (200 ms) is the only token. Components reach for gpui's own easings and SpringConfig inline (D21); a token is added only when a third component shares the same value (D16). TOML [motion] overrides and per-platform motion presets wait for a real need. The reduce-motion forwarding fix ✅ landed (pass a, 2026-10-30): reapply calls cx.set_reduce_motion(resolved.accessibility.reduce_motion), so gpui's animation elements snap under the OS setting.
  • Ghost hover token — ✅ landed with the dock tabs (2026-11-02): colors.muted_hover (light zinc-200 / dark zinc-700, one step past muted; TOML muted_hover =; no platform states a hover wash). Trigger: the ghost Button's hover painted muted — the same token the selected pill fills with — so hovering a close control on a selected tab was invisible. Button::Ghost and the selected pill hover from muted_hover now.
  • knob / control-fill role — resolved without a token: Slider (unit 7) shipped on the Switch's borrowed roles (primary_foreground fill, border off-track); a dedicated role waits for a third consumer.

Controls through unit 10 paint from existing roles and ramps.

5.3 Platform matrix

Target Detection Sources read Live changes
macOS compile target NSColor semantic palette (windowBackgroundColor, labelColor, controlColor, controlAccentColor, selectedTextBackgroundColor, keyboardFocusIndicatorColor, …) in the effective appearance via objc2-app-kit; NSWorkspace accessibility flags distributed notifications + GPUI appearance events
Windows 11 (WinUI) compile target ✅ Landed (2026-10-30): WinRT UISettings (UIColorType Background/Foreground/Accent — the stated AccentLight/Dark variants wait until the engine takes a full ramp, D13), TextScaleFactor scales the root font, AccessibilitySettings high-contrast; Personalize registry fallback (mode + transparency only — the accent key is skipped). WinUI metrics: 4px control radius, 32px field height, 14px root font. Reduce-motion not read yet (Win32 SPI). Verified against the windows 0.62 bindings; a full cross-build waits for hardware (gpui's manifest embedding needs llvm-rc) UISettings.ColorValuesChanged + HighContrastChanged
GNOME / generic Linux portal reachable (else gsettings) portal org.freedesktop.appearance (color-scheme, accent-color as (ddd), contrast, reduced-motion); gsettings fallback (color-scheme name + the libadwaita accent-name table) portal SettingChanged
KDE kdeglobals present kdeglobals [Colors:*] groups (Window/View/Button/Selection) + [General] AccentColor (-1,-1,-1 = wallpaper-derived, covered by the portal); portal for mode/accent inotify on ~/.config, filtered to kdeglobals (survives kconfig's replace-on-write)
Omarchy ~/.local/state/omarchy marker (theme.name, current/theme, or ~/.config/omarchy) current/theme/colors.toml (semantic keys, or the ANSI color0..15 fallback the alacritty generator writes); portal for contrast/reduced-motion; mode from the file's mode key or background lightness inotify on the current dir — catches theme-set's whole-directory swaps and theme.name writes (hand edits of the deployed copy need a switch to re-fire)
Fallback / unknown none of the above, or reader failure bundled neutral light/dark theme manual only

Linux adapters landed 2026-10-30 (fixture-tested parsers on every OS; payload facts transcribed from the portal spec, plasma's Breeze schemes, omarchy's bin/omarchy-theme-set, and libadwaita). The pinned gpui's AsyncApp is not Send, so watcher threads signal through the shared source/signal.rs bridge — an async_channel whose drain loop runs on the main thread via App::spawn — which the Windows events reuse.

5.4 Accent ramp engine

Every target supplies one accent color (or a stated ramp). Where the platform states shades (WinUI AccentLight1–3/AccentDark1–2, KDE scheme ramps) use them as-is; elsewhere derive the state shades components need (hover, pressed, selected, disabled, subtle fills) in OKLCH with fixed lightness/chroma offsets toward the background or foreground, hue preserved. Derived colors compute once per resolve, never per frame.

5.5 Native metrics

Native feel is half geometry. Per-platform tables for: control heights and paddings, corner radii (WinUI control vs card rounding; macOS control radii), font fallback lists resolved through .SystemUIFont (SF Pro / Segoe UI Variable / Adwaita Sans / Noto Sans — no fonts bundled), focus-ring policy (macOS keyboard ring, WinUI two-layer ring, GTK outline), scrollbar style (overlay vs inline), menu metrics (popup elevation landed 2026-11-01 as static per-platform profiles, D24). Motion stays per-component timing on gpui's own primitives; when a platform source needs to state it (macOS NSPopover-class timing, Fluent's decelerate curve, GNOME/KDE shell timings), it states Motion.overlay first and further tokens arrive per the role-minimal rule. Values are transcribed from each toolkit's reference metrics and verified with side-by-side screenshots (macOS in Phase B, the rest in Phase F).

5.6 Custom themes

  • ThemeDefinition TOML: presets and user files share one schema; may override any role or metric, or just the accent (accent = "#7c3aed" → full derived ramp).
  • Merge priority: platform reader → preset → user file(s) → runtime overrides → accessibility adjustments. mode = "system" | "light" | "dark" forces a mode.
  • Sources: --theme <preset|path> flag, a config directory scanned at startup, and an apply API for in-app settings UIs.
  • Omarchy interop: the loader accepts Omarchy colors.toml (semantic or ANSI) directly, so community themes drop in unchanged.
  • Live reload via notify; parse errors surface as a notification, never a panic.
  • Follow-mode API: follow_system(cx) keeps adapting to system changes; explicit apply detaches; system_or_default() takes a one-time snapshot.

5.7 Implementation vehicles

objc2 + objc2-app-kit (macOS), windows (WinRT UISettings), zbus 5 blocking API on a dedicated thread (portal DBus), async-channel (watcher→main-thread reapply bridge), notify (file watches), serde + toml (definitions) — all behind the ThemeSource seam so the engine core stays platform-free and testable.

5.8 What we do differently from native-theme

Real semantic colors per platform (NSColor / UISettings / portal) instead of hardcoded approximations; OKLCH accent-ramp derivation; native metrics as a first-class deliverable; Omarchy as a first-class target; a designed neutral fallback; custom themes with overlay merge priority and live reload; a role-minimal token set instead of a 139-field toolkit mapping.

6. Component roadmap (review units, in order)

Each row is one review unit: build it, show it in the gallery, review, then move on. Units 1–10 ✅ landed (2026-10-29) — Button through Input (§6.2); the rows keep their original scope notes. Rows 11 onward carry their status inline.

Unit Crate Component Notes
1 core Button Variants (primary/secondary/ghost/destructive) painted from token ramps; single activation path; disabled + a11y role. The API template for everything after. An icon with no label renders the button square — the icon-only shape for toolbars and reveal controls
2 core Checkbox Controlled checked + on_change(next); Check icon from the icons crate.
3 core Switch Controlled pill + thumb; thumb slides once motion exists.
4 core Radio + RadioGroup Group owns the selected value; radios never self-uncheck.
5 core Toggle Controlled pressed (aria-pressed).
6 core Link href opens via platform by default; on_click overrides.
7 core Slider Click-to-set + in-track drag; controlled value.
8 core Progress Determinate + indeterminate.
9 core Avatar Initials; image slot later.
10 core Input (single-line) then multiline, NumberInput String buffer + unicode-segmentation word movement; the field owns its own selection. May seed crates/editor. Single-line finishing pass: §6.2.
11.5 core ScrollArea + Scrollbar ✅ Landed (2026-10-29). Inserted before the remaining overlays — Menus, Lists and Tables all scroll. gpui owns the mechanics (overflow_*_scroll + caller-held ScrollHandle); the component paints an overlay thumb (scrollbar_thumb token, Scrollbar metrics) with drag and geometry unit-tested. Wheel input is gpui's; the area stops wheel propagation so nested areas don't co-scroll. Horizontal thumb deferred; auto-hide on idle deferred. ✅ Fixed (2026-10-30): the caller's size refinement now lands on the outer wrapper too — previously it applied to the pane alone, so a percentage-sized pane resolved against an auto-height wrapper and a window-filling scroll area (the gallery's root shell) never actually scrolled; a headless repro of the gallery shell locks the behavior
12 core Menu ✅ Landed (2026-10-29). Controlled open/on_change + trigger/items (MenuItem: label, icon, shortcut hint, disabled, destructive, checked, separator). Popover shell patterns duplicated per D16 (promote at Dialog); panel = surface + overlay radius + popover elevation, role Menu; claims focus while open; keyboard nav — arrows/home/end over selectable items (wrapping, skipping separators + disabled), enter/space activates (closes menu before the action), escape dismisses; hover selects, click activates; trigger clicks toggle (popover-owned); selection resets on each open; long menus scroll via the ScrollArea once a height is styled. Deferred: submenus, type-ahead, scroll-selected-item-into-view, real shortcut handling
11 core Popover ✅ Landed (2026-10-29). First overlay. Controlled open + on_change (Rc-shared handler); trigger/content element closures; panel = surface + Radius.overlay + Elevations.popover + border, role Dialog with label; anchored() + deferred() (window-overflow flip for free); enter fade via gpui with_animation at Motion.overlay (respects reduce-motion); claims keyboard focus while open; escape and click-outside dismiss (outside clicks swallowed). No scrim — native macOS popover model; the overlay token waits for Dialog. Known v1 gaps: no exit animation, trigger aria-expanded left to the app
11+ core Tooltip ✅ (2026-10-29), Dialog + AlertDialog ✅ (2026-10-29), Toast ✅ (2026-10-29), Alert + Toast rework ✅ (2026-10-31) One unit each, same gate. Tooltip rides gpui's own .tooltip() lifecycle (hover delay, anchoring, dismissal — all framework-owned); ours is the styled view (surface + border + Radius.sm, small text) plus a Tooltip::text closure and a Button passthrough. Dialog + AlertDialog (one component, alert(true) switches semantics): scrim (colors.overlay, occluding) + centered panel (surface, Radius.overlay, Elevations.dialog), role Dialog/AlertDialog, focus claimed while open; modal kind closes on escape + scrim click, alert kind is inert to both (the user answers through the actions); actions footer is caller-built elements closing through owner state. The 2026-10-31 rework split the old Toast in two (D23): Alert (alert feature) — a persistent, fully controlled callout banner rendered inline where the app places it: severity icon (Ramps.<sev>.base on a surface + border + Radius.overlay card with the platform popover shadow (D24)), title and message both optional (a message-only banner paints it as the primary line), close button only when on_dismiss is set, reporting intent instead of hiding anything. A pushed, macOS-style stacking Alerts/AlertsView (collapse-slivers + hover-unroll + slide animations) was tried and rolled back the same day — inline-only won. The reworked Toast (toast feature) — one at a time (pushing replaces the one on screen), a succinct severity-less pill at the bottom center: optional icon + one-line truncating content + one optional action (ToastAction, the suggested-action pill) + an always-present close button, laid out as content, small gap, action, large gap, close; fades in and out (Motion.overlay each — exits via a store-held exiting slot whose handler runs once the fade completes). The store keeps GPUI-executor timers (Toast::DURATION 5 s, zero = sticky). Toast landed the deferred success/warning tokens (§5.2); Alert is the info token's consumer (D22). Sheet dropped from the roadmap
14 core + theme Motion (per component) ✅ Landed in five passes (2026-10-30), replacing the reverted same-day framework attempt — no token tower, no motion module, no ghost panels; just gpui's own primitives used inline, one separately reviewed pass per component: (a) theme — reapply forwards reduce_motion via cx.set_reduce_motion, so gpui snaps every animation under the OS setting; (b) Switch — the thumb absolute-positions in the track and slides on with_spring (inline SpringConfig::new(500, 35, 1), Pixels target, mounts at its target so the initial render never animates; velocity survives mid-flight retargets); (c) Toast — each toast slides up 8 px into its stack slot on with_animation at Motion.overlay with ease_out_quint (margin-driven, so only the entering toast moves; keyed per entry so the clock survives unrelated stack re-renders); (d) Dialog — the scrim enters with the panel (same timing and easing, its own dialog-scrim-enter animation id so the clocks start in the same frame); (e) Progress — the indeterminate sweep caps at 30 fps via with_max_fps. Overlay enter fades already existed from units 11/12. Exits and hover washes stay deferred — they were the complexity sink of the reverted attempt (§ deferred list)
then data (new) List ✅ (2026-10-30), Tree ✅ (2026-10-30), Tabs ✅ (2026-10-30), Sidebar ✅ (2026-10-30) Created on demand, same gate. Table dropped from the roadmap. List: controlled single-selection rows (selected: Option<usize> + on_change); click selects and focuses the list, arrows/home/end move selection skipping disabled rows and never wrapping (native list behavior), hover is paint-only, click focus rides a keyed FocusHandle namespaced by the list id; roles List/ListItem with aria_selected and position/size-of-set; internal scrolling left to the caller's ScrollArea; sheet feature dropped from core as dead config. Tree: the caller flattens its tree into depth-annotated TreeItems (bezel's model — the component never walks a tree); the caller owns expansion and receives toggles via on_disclosure(index) (chevron click, or left/right on the selected row); same controlled selection and row paint as List; roles Tree/TreeItem with aria_selected/aria_level/aria_expanded/set-size; indent guides deferred. Tabs: a controlled strip only — selected: usize + on_change; the panel is the app's to render; active tab marked by an underline on the strip's hairline (bezel's model); arrows/left-right/home/end switch tabs with automatic activation, skipping disabled and never wrapping; re-selecting the active tab reports nothing; roles TabList/Tab with aria_selected; no wrap, no segmented-pill variant yet. Sidebar: List with section headers — SidebarEntry::header(label) / row(label).icon().detail().disabled(); headers are never selectable, selection indices address the flat entry list; same selection/keyboard/paint rules as List; roles List/ListItem (a sidebar is a list to accesskit, like macOS AXList); selection stays filled when unfocused (native)
then pickers or data Calendar/DatePicker, TimeField, ColorPicker Decide at unit time.
editor editor (new) Text/code editor, syntax highlighting (D12) Same gate.

Deferred until a real need: window-wide text selection, overlay exit animations and hover washes (dropped with the 2026-10-30 revert; only return if a genuinely simple approach appears), charts. Markdown/TextView and VirtualList gained consumers through the signed migration — promoted to §6.3 (M14, M17).

6.1 After the core units: sizing API — ✅ landed

Landed (2026-10-29): the Size enum (XSmall / Small / Medium default / Large / Custom(Pixels)), the Sizable trait (with_size(impl Into<Size>) + xsmall() / small() / large()), and the SizeTable of per-class control metrics (height, padding-x, text size, icon/indicator size, avatar size) now live where they belong — Size/SizeTable/ControlMetrics in the theme (they are native-metric values, §5.5), the trait in core's ungated common.

Our divergence from gpui-kit held: the per-size metrics resolve from the theme's size table at paint time, not from constants in the component layer. All ten controls implement Sizable; Size::Custom(side) derives the other metrics from the side length. Still open, when a real need arrives: TOML overrides for the size table in ThemeDefinition, and per-platform size tables from the Phase F native-metrics work.

6.2 Input completion (unit 10 finishing pass) — ✅ landed (2026-10-29)

Gap analysis against gpuikit's input (2026-10-29; source read as an API reference, §9 — no porting). gpuikit splits input three ways: core state whose edits are actions, infrastructure (bindings / blink / bidi), and a chrome-less element with chrome in wrapper elements. We keep one component with chrome included; only the ideas below are adopted.

Already at parity: buffer + selection + caret, grapheme/word movement, home/end, clipboard, select-all, IME (EntityInputHandler, marked range, UTF-16 conversions), double-click word / shift-click / drag selection, placeholder, disabled, focus ring, a11y TextInput role, TextChanged/Focused/Blurred/Submitted events, set_value + silent variant.

Remaining, in order:

Step Scope Notes
1 macOS modifier split ✅ Landed. cmd-left/right → home/end, cmd-up/down → start/end of field, cmd-backspace → delete to start, alt modifiers → word ops. The handler used to treat cmd and alt alike — wrong on macOS (D8); ctrl stays the word-modifier fallback off-mac
2 Undo/redo ✅ Landed. Patch-based history (range, replaced text, new length, prior selection), 300 ms grouping (set_group_interval, Duration::ZERO disables merging), cmd-z / cmd-shift-z, 100-entry cap; set_value clears the stacks, any new edit clears redo. Unlike gpuikit — which skips recording inside the interval and so undoes groups lossily — entries merge so a group fully reverses. Redo lands the caret at the end of the restored text; undo restores the pre-edit selection
3 Read-only, Escape-blur, triple-click ✅ Landed. Read-only: focus, movement, selection, copy still work; edits, paste, cut (clipboard included), undo/redo and IME refused — distinct from disabled; programmatic setters unaffected; the caret is hidden while read-only (a caret promises typing lands there). Escape → window.blur(). Triple-click selects all (single-line)
4 Actions + key context ✅ Landed. 25 actions!(input, …) + an "Input" key context; the element registers on_action handlers and the residual on_key_down only inserts printable typed chars. InputBindings table of Option<KeyBinding> fields — Default is platform defaults (cmd/alt on macOS, ctrl elsewhere), empty() starts bare, any field replaceable/unbindable; `bind_input_keys(cx, None
5 Caret blink ✅ Landed. A private CaretBlink entity per InputState: 500 ms tick on the GPUI executor, visible on focus and hidden after one interval. Any field change (InputState::notify) pauses it — caret solid, cycle restarts — so typing, moving and dragging never hide it; generation counters invalidate stale ticks. Read-only suppresses the caret entirely. caret_visible(focused, cx) is the single gate used by paint and tests
6 Paint polish ✅ Landed. IME composition underline (1 px, foreground token) under the marked range. I-beam hover cursor via a canvas-prepaint hitbox + set_cursor_style in paint. Two-sided horizontal scroll: scroll_offset lives in state; caret-affecting changes set a pending flag that the next paint consumes with a fresh layout (two-sided pull-back + clamp to content), while scroll_by (wheel, ScrollEvent, lines→pixels via a fixed unit) bypasses the flag so manual pans survive. Scroll-aware hit-testing, IME bounds_for_range and drag select all come free through the scroll-adjusted origin

Deferred until a real need: bidi/RTL; prefix/suffix adornments; Undo/Redo events; selection/cursor color overrides (theme tokens are deliberate); max length, clear button (gpuikit has none of these either — decide on first need). Password masking and SubmitOn lifted 2026-11-01 — §6.3 M6/M7; the form field wrapper lifts with M9.

6.3 Migration-driven additions — signed is the first downstream app

The first real consumer is signed, a Nostr Git platform desktop client (GPL-3.0, v0.2.0) currently built on gpui-pre 0.3.8 + gpui-component 0.7.1 + native-theme 0.6.1. Its screens drive the units below — the kit ships the components; porting the app stays app-side. A survey of bezel's full catalog (bezel.gallery docs, 2026-11-01) and gpuikit's showcase source shaped the shapes, not the scope — API ideas only per D15.

Wave M1 — each through the review gate:

Unit Crate Component Signed consumer Survey notes
M1 core Badge ✅ Landed (2026-11-01) unsynced-count pills on sidebar repo rows bezel ships quiet + active variants; gpuikit secondary/outline/destructive. Shipped five semantic variants — Neutral (muted fill), Accent, Success, Warning, Danger (the ramps) — painted from existing roles, no badge palette (D22); pill = Radius.full, size-table text at 0.8×, caller-refinable via Styled
M2 core Select ✅ Landed (2026-11-01) default-branch chooser, event-fetch-strategy setting, explore filter bezel: field-shaped trigger over a caller-assembled menu; gpuikit: placeholder + clear. Shipped as a controlled value (selected(Option<T>) + on_change(Option<T>), tuples in) with the popup owned per instance via element-namespace-keyed state; composes Menu — field-shaped trigger (input/border/ring tokens, Role::ComboBox), options as checked items. Menu gained initial_selection so a popup opens keyboard-highlighted on the current value, like native popups, and its panel dropped the max_h(relative(0.75)) fraction — resolved against the wrong box inside nested flex/scroll shells, it clipped the last items (found in the gallery); row height is governed by the ScrollArea's 320 px cap and anchored handles window fit. Feature select = ["menu"]. Follow-ups (2026-11-01): Menu's check indicator moved to the row's right end (Adwaita dropdown look; the optional icon keeps the leading slot), and Select options gained optional leading icons — SelectOption::new(value, label).icon(role), with (value, label) tuples still accepted through From
M3 core Segmented control ✅ Landed (2026-11-01) All/Popular/Recent explore filters bezel's toggle-group pill; single-select only, multiple waits. Shipped as a controlled single-select (Segmented::new(id, selected) + options((value, label)) + on_change(T) — exactly one segment always active); muted pill track, the selected segment on a raised surface plate with border; left/right/home/end move and activate when the strip has focus (no wrap), RadioGroup/RadioButton roles with aria_toggled; renders hugging via a wrapper so it works in block and flex parents alike. Plain segmented feature, no deps. Follow-ups (2026-11-01): the selected plate dropped its border for a small raised shadow — a third Elevations.control class, profiled per platform like popover/dialog (D24); Size::Small became the default and medium/large resolve through the size table — taller tracks (control_height × 1.15 / 1.35), a scaled track inset (0.125 / 0.25 / 0.375 rem) and segment padding (0.5× / 0.75× / 0.75× of padding_x), with the large label staying at the medium size; options take optional leading icons (SegmentedOption::new(value, label).icon(role), tuples still accepted through From); and the selected plate is now a measured, absolutely-positioned layer that springs between segments (per-segment measure canvases feed keyed geometry; the plate springs on left and width separately, Switch-style — mounts at its target so the first frame never animates, retargets mid-flight, and snaps under reduce-motion). Follow-up 2 (2026-11-01): the track inset also scales per size (0.125 / 0.25 / 0.375 rem), and a settled-plate regression test guards the plate's existence and slot (debug_bounds("segmented-plate"))
M4 core Context menu ✅ Landed (2026-11-01) repo rows, file-tree rows, tab bar gpuikit attaches the menu to an existing element; bezel keys one-open-at-a-time. Shipped as a self-contained ContextMenu::new(id).target(el).items(...) — open state and pointer position are namespaced keyed state, right-click opens at the pointer, left clicks pass through to the target untouched, and any click outside, escape, or activation closes. Composes Menu, which gained trigger_button(MouseButton) and open_at(Point) for pointer-anchored opening (outside-click dismissal now closes on any button). Plain context_menu feature, depends on menu
M5 core Empty state ✅ Landed (2026-11-01) inbox, issues, search misses bezel: icon + headline + one line of hint. Shipped as EmptyState::new(headline) with optional .icon(role), .hint(text), and .action(element) — no id (nothing interactive), no size classes (content, always medium metrics); the illustration is the component's own icon_size × 2 decoration, muted like the hint, line_height 1.3 on the headline, everything centered. Plain empty_state feature, no deps
M6 core Password input ✅ Landed (2026-11-01) identity unlock + nsec import passphrase Lifts §6.2's "password masking on first need" — the need arrived. InputState::set_password(bool) masks the painted line one bullet per grapheme (paint-only: the buffer, undo, and caret all stay real; a mask map of grapheme byte offsets converts between the buffer and the painted display for hit-testing, the caret, selection, the IME underline, and bounds_for_range). Masked fields refuse copy and cut so the secret never reaches the clipboard; paste, editing, and reveal stay available. No element builder — the flag is state, like read-only. Follow-up (2026-11-01): a built-in reveal toggle — a ghost icon-only Button (IconRole::Eye/EyeOff, native glyphs per platform) pinned inside the field's right edge, shown on every password field. The state now tracks the field kind (set_password/is_password) and its visibility (set_revealed/is_revealed) separately, so toggling reveal never changes the field's kind; the paint reserves the button's corner (right_padding threaded through paint/store_layout, with a content mask so text never runs under the icon). The reveal press stops propagation so it never places the caret. The input feature now depends on button
M7 input Multiline textarea ✅ Landed (2026-11-01) discussion composer, patch + commit messages bezel: fixed rows or min/max auto-grow; gpuikit: rows on InputState. Reuses Input's buffer; D12's editor crate stays separate. Shipped as a state mode — InputState::set_multiline(bool) — sharing the single-line machinery: shaping moved to gpui shape_text, which wraps to the field's inner width, and the paint layout gained a visual-row table (byte range + y per wrapped row) that the caret, selection, IME underline, hit-testing, triple-click and bounds_for_range all consume; scroll_offset became 2-D (x single-line, y multiline) with scroll-to-caret per axis. Height = the ambient line-height ratio × the element's rows(usize) (default 4); content taller than the field scrolls, and the field stops wheel propagation only while overflowing (min/max auto-grow deferred). Enter inserts a newline and modifier+Enter submits — SubmitOn::ModifiedEnter is the default, SubmitOn::Enter plus the InsertNewline shift-enter binding gives chat-style enter-submit — lifting §6.2's deferral. Up/down (+ shift variants) move between visual rows with a goal column; triple-click selects the clicked row; the sanitizer is mode-aware (multiline keeps newlines, drops \r; single-line unchanged), and the mask map gained per-grapheme display lengths so masked fields survive line breaks. No theme changes — all geometry derives from existing tokens. 7 headless tests join tests/input.rs
M8 core Avatar image profile pictures in rows and panel tabs lifts the deferred image slot: gpui img + initials fallback (signed adds its pixel-art fallback app-side)

Wave M2 — completes the surface: Field wrapper (M9: label/description/error around any control — the settings dialog; lifts another §6.2 deferral), Tag (M10: removable GRASP-server and relay entries), Status dot (M11: sync-drift beads on rows), Combobox (M12: type-to-filter pickers; gpuikit's on_create/keep_unmatched blur modes are worth copying, adopt that), Command palette (M13: filtered actions — gpuikit keeps the matcher caller-owned, adopt that), VirtualList in data (M14: a thin uniform_list binding for commit histories and the inbox), and the Spinner (M18: the indeterminate/determinate loading ring — Tab::busy's placeholder ring in the Pill tab waits on it, as do async buttons and refresh states; picked up next time a consumer lands, before signed wires repo loading into dock tabs).

Wave M3 — content, where crates/editor starts (D12): code view with syntax highlighting (M15 — signed renders highlighted files with tree-sitter today), then the color-coded diff view (M16), then a Markdown view (M17) if a consumer confirms READMEs and issue bodies need one.

Survey notes — worth adopting later, not now: bezel's hover card (profile previews), skeleton rows, breadcrumb, pagination, sortable lists, follow-scroll; gpuikit's table (stays dropped — signed's explore lists read fine as Sidebar rows), kbd chips (our Menu already renders shortcut hints), form fieldset (the Field wrapper covers it), accordion.

6.4 The layout crate (crates/layout) — application shell bootstrap

A new crate (D18: advanced crates are created on demand, not speculatively) that bootstraps the application layout the way zed's workspace and gpui-kit's dock do, with a deliberately smaller API (D26): a left sidebar (on by default), a right sidebar (off by default), and a center dock whose panes the user can drag and split. A panel can declare that it needs the right sidebar; activating it opens the sidebar automatically. signed's crates/dock — a gpui-component DockArea behind a custom skin — is the usage reference and the cautionary tale: its renderer seams, serialization placeholders, and zoom/collapse modes mark the complexity we skip. gpui-kit's dock and zed's editor dock are read as API-design references only (D15, D20). Facade feature layout; the crate depends on core and composes data — the L3 header decision, revised when L4 landed: pane headers compose the data crate's Tabs in its Pill variant (the signed dock tab — pills on a chrome-less bar: [lead element][name][wide gap][togglable busy slot][close control]; the bar carries no background or border because it doubles as the titlebar strip in L4.5), with Tab::closeable + Tabs::on_close for close controls, Tab::lead for avatar-or-custom leading elements, Tab::busy for the loading slot, and a Tab::wrap seam so the dock attaches its drag payload to the rendered tab; data's Tabs keeps the underline design as the default. A window_frame module wraps a window root in the client-side frame the OS owes — border, Radius::overlay corners, dialog-elevation shadow, resize bands — under Decorations::Client and passes through under Server (adapted from bezel's window::frame and gpui-kit's WindowBorder, simplified; the window must open transparent). New IconRoles (panel-left/right toggles, split directions) land in the icons crate with the units that need them. Everything is theme-baked per §4.3 — no style parameters, no renderer/skin seam.

Units, each through the review gate:

Unit Crate Component Notes
L1 layout ResizeHandle ✅ Landed (2026-11-01) The drag divider primitive: a thin themed handle that resizes the adjacent region (sidebar edge, split seam). Pointer drag, double-click resets to the default fraction, splitter a11y role. Lives in layout — promotes to core if a second consumer appears (D16). Shipped as a delta reporter: ResizeHandle::new(id).axis(Axis).on_resize(delta) hands the owner the signed seam movement per event — the owner stores a pixel size and clamps, the InputState::scroll_by pattern — with .on_reset() for double-click; no geometry parameters, so it works at any edge or seam. The hairline is border, highlights to ring on hover/drag; hit slop = dense_padding_x (component-own decoration, D25), hairline 1 px like the input's paint hairlines; Role::Splitter + focus ring, arrow keys move the seam (KEYBOARD_STEP 32 px, input-style const). One gpui dispatch lesson locked in tests: bubble runs user mouse-down listeners before the div's own focus transfer, so a listener that stops propagation must focus explicitly first — the keyboard test caught it. Feature resize_handle; 6 headless tests in crates/layout/tests/resize_handle.rs; gallery section shows a draggable split with live width
L2 layout Shell ✅ Landed (2026-11-01) The bootstrap shell: left sidebar region (on by default) + center + right sidebar region (off by default). Sidebar toggle in each sidebar's header, drag-resize widths through ResizeHandle; visibility and widths live on an application-owned LayoutState (the InputState pattern: set_left_sidebar_visible / set_right_sidebar_visible / is_* readers). The center region hosts the Dock. Shipped so: the left sidebar is the data crate's Sidebar component (.left_sidebar(sidebar) — selection, keyboard nav, and a11y come for free; shell pulls data/sidebar, per the "may compose data" note), the right sidebar and center are app elements (the right side is a details pane, not a list); Sidebar gained a sidebar-{id} debug selector for headless tests. Each visible sidebar renders a strip_height header strip with an end-aligned ghost icon toggle (IconRole::PanelLeft/PanelRight — new roles this unit: Segoe DockLeft/DockRight E90C/E90D per the MDL2 table, Lucide panel-left/panel-right bundled, freedesktop sidebar-expand-* → sidebar-show-symbolic cascade); LayoutState holds visibility + pixel widths with 160/480 px drag clamps (menu-cap px precedent); the right handle inverts the delta so dragging left grows it. shell pulls only core's button + data/sidebar; the facade layout feature re-requests full (core/data workspace deps are now default-features = false). The shell always paints the window frame around itself (L2.5), so it must be the window's root. 5 headless tests in crates/layout/tests/shell.rs: defaults, toggling via the state API, the header toggle button (clicked at metric-derived coordinates — gpui dispatch lesson from L1 applies to on_click too), seam resize + both clamps with no drift, and the inverted right seam
L3 layout Dock ✅ Landed (2026-11-02) The center pane tree, controlled: DockState holds the Pane { tabs, active } / Split { axis, ratio, a, b } tree plus a registry of PanelDescriptors (id + title + optional icon + a view builder called once per open; the built view is retained, so panel state survives tab switches). open / activate / close / close_others / close_pane / split / move are plain state ops keyed by panel id — opening an open panel re-activates it, one instance per descriptor for now, and active_panel() reports what the active pane shows. Any op emptying a pane collapses it: the pane detaches and single-child splits are replaced by the surviving subtree, so only the root can sit empty. Split seams are ResizeHandles (arrow keys move them for free); the ratio lives in the state and the seam turns pixel deltas into fractions against a per-split span that a canvas captures into dock-namespaced keyed state each frame — double-click resets to 0.5, both sides clamp at 10% of the split. Panes render a strip_height header row composing the data crate's Tabs (revised in L4 — see below), TabList/Tab/TabPanel roles, and a core ContextMenu on the header — Close / Close Others / Close Pane; the active tab's view fills the pane. Feature dock pulls core button + context_menu and re-uses resize_handle; 7 headless tests in crates/layout/tests/dock.rs (open/activate, tab clicks, neighbor-on-close, split + collapse, seam drag/reset/clamp with a 1 px flex-rounding tolerance, merge-by-move, close-others/close-pane + reopen); the gallery's shell center hosts a dock with demo panels and open/split buttons
L4 layout Dock drag-to-split ✅ Landed (2026-11-02) Dragging a tab uses gpui's own drag system: the dock attaches on_drag through data Tabs' Tab::wrap seam with a PanelDrag { panel } payload (ghost = a small bordered title card). While App::has_active_drag(), each pane's content mounts five absolute drop zones — four edge bands that call the new DockState::split_panel_at(panel, anchor, direction) (a split beside the target pane, not the dragged tab's own; the empty source still collapses) and a center zone that calls move_panel. Zones paint a selection wash via drag_over::<PanelDrag> while hovered and are mounted only during a drag, so content clicks are never blocked; gpui cancels the drag on a mouse-up over nothing, which unmounts them. Dropped tabs land active in their new pane. Same pass: pane content renders through AnyView::cached (StyleRefinement absolute + size full, the gpui-kit pattern) so an un-notified panel never re-renders on dock frames; pane headers moved from hand-rolled tabs to data Tabs (Pill variant — renamed from Card and reshaped to signed's exact tab: leading element via Tab::lead, wide title-to-controls gap, Tab::busy loading slot standing in with a quiet ring until the Spinner control lands, a ghost core Button close control (the first data → core dependency edge, feature-gated: tabs pulls core/button), and the pill radius is the button radius, not full; the pill bar is chrome-less because the dock's tab strip doubles as the L4.5 titlebar), which now ships the two designs (default stays Underline); tab strips carry {tabs-id}-tab-{label} / {tabs-id}-close-{label} debug selectors for headless tests. 10 headless tests in crates/layout/tests/dock.rs — the new ones cover a real simulated drag (mouse down → threshold move → drag starts → drop on a right edge band → split) and split_panel_at's target-side placement; the rest carried over with the new tab selectors
L2.5 layout window frame ✅ Landed (2026-11-01) window_frame::frame(child, window, cx) — the client-side window frame for windows the OS does not decorate: under Decorations::Server (macOS, Windows, KDE, X11) it passes the child through untouched; under Decorations::Client (Wayland/GNOME) it insets the content, draws the hairline border and Radius::overlay corners (tiled sides stay square and lose their band), casts the dialog elevation's shadow dimmed to 0.7× when the window is inactive, and turns a 4 px band inside each live edge into resize hits (start_window_resize, edge cursors; a maximized window counts as flush everywhere). Requires a transparent window background, and must be the window's root — the bands hit-test against Window::window_bounds. The Shell applies it by default, so the module ships ungated — no separate feature. Adapted from bezel's window::frame + gpui-kit's WindowBorder (D15/D20), simplified to one inset band and theme-owned tokens
L4.5 layout Titlebar + window controls The strips across the shell's top become the window's titlebar: they drag the window (WindowControlArea::Drag on the strip) and host the controls where the platform puts them. A WindowControls element — minimize, maximize/restore, close, capability-gated by Window::window_controls() — renders per platform: macOS draws nothing where the system traffic lights sit (they are system-drawn; TitlebarOptions.appears_transparent + traffic_light_position is app window config), and the shell reserves the pad in the left sidebar's header leading edge — or across the center's strip when the left sidebar is hidden; Windows draws native-glyph buttons (ChromeMinimize/ChromeMaximize/ChromeClose, Segoe E921/E922/E8BB verified against the MDL2 table) marked WindowControlArea::Min/Max/Close so Windows handles the clicks and snap layouts natively, maximize flipping to Restore via is_maximized; Linux draws the same ops as click handlers (minimize_window / zoom_window / remove_window) in the platform's own dress — Adwaita's small gray circle wrappers around symbolic glyphs, resolved through the freedesktop cascade with the bundled fallback (Close already leads with window-close-symbolic; new WindowMinimize/WindowMaximize roles land in the icons crate). Placement follows the shell's shape: Windows/Linux put the controls at the trailing end of the right-hand strip — the right sidebar's strip when visible, else the center region's titlebar strip, which is now the dock's Pill tab bar (the pill strip ships chrome-less for exactly this — in signed the tab bar doubles as the titlebar, 44 px, with 34 px-wide controls and a danger hover on close, which the pill tab already matches apart from the metrics). Reference: signed's crates/dock/src/window_controls.rs
L5 layout Sidebar auto-open Panels declare needs_right_sidebar on their descriptor; activating such a panel opens the right sidebar; closing it leaves visibility to the user. The gallery gains a workspace-style shell page — the bootstrap example apps copy.

Deferred until a real need: layout persistence/serialization, pane zoom (maximize one pane), a bottom dock, collapse-to-edge docks, floating windows, drag between windows.

7. Milestones

Phase Scope Acceptance
A — scaffold ✅ Workspace per §4.1: theme stub, icons crate (IconRole + Lucide fallback), core crate with feature scaffolding (zero components), facade, gallery shell, CI incl. feature checks gallery opens a window reading the theme global; core builds at default / no-default / all / each-feature-alone
B — theme engine core ✅ Token model + resolve + presets (neutral fallback, tokyo-night) + macOS source + ThemeWatcher; gallery theme inspector. Done: resolve overlay chain (base → system → preset → mode override → high-contrast) is pure and tested; OKLCH accent-ramp engine; NSColor/NSWorkspace adapter; distributed-notification watchers; inspector with live mode/preset switching. (The "Button + Checkbox painted from live tokens" proof moved to Phase C — those components landed there; the gallery inspector proves the live pipeline meanwhile.) ✅ Landed (§12 item 2)
C — controls ✅ One review unit at a time (§6, units 1–9), each: API per §4.3 + tests + gallery page → review gate. macOS SF Symbols resolver behind the icon seam lands with the first unit that shows it matters Per unit: activation, controlled round-trip, and disabled inertness tested; gallery page shows the live control against the theme; your approval closes the unit
D — input ✅ Single-line Input (unit 10) + the §6.2 completion pass; NumberInput stays deferred (multiline is §6.3 M7) Modifiers, undo/redo, read-only, actions + key context, caret blink, paint polish all landed
E — overlays ✅ Popover (11), ScrollArea (11.5), Menu (12), Tooltip + Dialog/AlertDialog + Toast/Alert (11+ row) Escape/dismiss semantics and focus behavior verified per overlay
E+ — motion Unit 14 (§6): per-component passes, each a few lines on gpui's own primitives — forwarding, Switch thumb, Toast slide, Dialog scrim, Progress cap ✅ Landed (2026-10-30), each pass reviewed in the gallery; reduce-motion snaps gpui-side with the flag forwarded
F — platforms + data ✅ Landed (2026-10-30): crates/data (List, Tree, Tabs, Sidebar), the Linux theme sources (Omarchy/KDE/GNOME), the Windows source, the Segoe Fluent + freedesktop icon resolvers, plus per-platform elevation stacks (D24, 2026-11-01) Remaining acceptance: side-by-side gallery screenshots — a Linux run (Omarchy/KDE/GNOME) and the first Windows hardware build
G — migration additions (§6.3) Waves M1–M2: Badge, Select, Segmented, Context menu, Empty state, password + multiline input, Avatar image, then M9+ → 0.1 Each unit through the review gate; each wave unblocks a real signed screen
H — layout crate (§6.4) ResizeHandle, Shell bootstrap (left sidebar on, right sidebar off, center dock) + the client-side window frame + titlebar and window controls, Dock with drag-to-split, panel-declared sidebar auto-open Each unit through the review gate; the gallery workspace page doubles as the bootstrap example apps copy

Gate for every phase: cargo fmt --check, cargo clippy --workspace --all-targets -- -D warnings, cargo test, gallery smoke run.

8. Testing

Pure tests for resolve/overlay chain, payload parsing (fixtures), icon resolution tables, and metrics; context tests for the theme global lifecycle; headless UI tests for activation, controlled round-trips, focus, disabled, and dock/sidebar state transitions (split, close, collapse, auto-open); gallery smoke run. GPUI executor timers in tests. Only meaningful tests — state semantics, a11y mapping, invariants (AGENTS.md Tests rule).

9. Licensing

  • All code in this repo is original: Apache-2.0 (D6).
  • gpui: Apache-2.0 dependency, nothing copied.
  • bezel and gpuikit: API-design references (D20); inspiration only, no code copied. gpui-kit is no longer consulted (D20). If that ever changes, respect their licenses.
  • Lucide icons: ISC — bundled subset with attribution in NOTICE.
  • SF Symbols and Segoe Fluent Icons: used through the operating system at runtime only; never extracted or redistributed.
  • native-theme, gpui-omarchy, gpuikit, bezel: inspiration only; no code copied. If that ever changes, respect their licenses.

10. Risks

Risk Mitigation
zed main churns gpui APIs Pinned rev; bumps are deliberate (re-verify, fix, note in commit message); CI catches drift early
Writing our own input/editor is the biggest lift Single-line first; no LSP/highlighting/composition until needed; selection stays private to the field
The dock is the most interaction-heavy surface (drag, split, drop zones) Small opinionated API (D26): a controlled pane tree with edge drop zones only; serialization, zoom, and floating windows stay out; each unit through the review gate
Per-component features multiply configurations Features gate whole modules, additive only; CI checks default / none / all / each-alone, not the full matrix
Platform icon lookups vary (missing fonts, themes) Resolver cascade always ends in the bundled Lucide fallback; missing glyph logs once and falls back
Per-OS readers are a maintenance burden Thin readers over parseable payloads + fixture tests; graceful fallback chain (system → preset → fallback theme)
"Native feel" is judgment Named native reference per component; per-platform screenshot review each phase
Omarchy colors.toml key set evolves Parse tolerantly (unknown keys ignored, missing keys fall back); track the theme-set script each release cycle
Portal accent-color absent on older desktops Treat as unset (spec-defined); fall back to gsettings/KDE accent, then ramp from a neutral accent

11. Decisions

# Decision
D1 Custom theme engine inspired by native-theme; no native-theme dependency
D3 gpui from zed's repository only, pinned rev; no gpui-pre, no forks, no gpui-unofficial
D4 Desktop only; no WASM
D6 Apache-2.0 for this repo's code
D7 gpui-native-kit stays the thin facade package name; it only re-exports crates behind features. Applications may depend on gpui-native-core directly
D8 macOS first, then Windows 11 (WinUI), then GNOME → KDE → Omarchy; fallback theme from day one
D11 Bundled presets: neutral fallback, tokyo-night, per-platform presets for testing readers; Omarchy files load as presets too
D12 Editor deferred; crates/editor is created when the editor units start — file viewing (§6.3 M15–M17) is the trigger
D13 Accent shades hybrid: stated platform ramps where available (WinUI, KDE), OKLCH derivation elsewhere
D14 Custom-theme live reload via notify
D15 No wholesale porting, no UI-toolkit dependencies. Component APIs follow bezel and gpuikit conventions where they fit (D20), always simplified — never mirrored 1:1; small utilities may be adapted with attribution
D16 No separate behavior crate; behavior lives in each component; shared helpers promoted at the third use
D17 UPSTREAM.md removed; provenance = §3 pins + git history
D18 Crate split for consumption: core (every-app components) → editor, data, … (advanced) → thin gpui-native-kit facade; one cargo feature per component, additive, module-gated
D19 System icons via semantic IconRoles with per-platform resolvers (SF Symbols / Segoe Fluent Icons / freedesktop) and a bundled Lucide fallback; platform glyph sets are used at runtime only, never bundled
D20 Components land one element at a time behind a review gate: API sketch → implementation → tests → gallery page → review → next. bezel and gpuikit are the API-design references for these units; conventions like controlled state, one activation path, semantic states, and a11y labels are adopted, simplified. gpui-kit is no longer consulted — judged over-engineered for our needs
D21 Motion is per component and inline: each component uses gpui's own animation/spring primitives (with_animation, with_spring, the gpui easings, with_max_fps) directly in its file; the theme owns only the durations components actually share (today Motion.overlay), growing per the role-minimal rule. No shared motion module, no exit ghosts, no hover-wash machinery — exits and washes stay out until a genuinely simple approach exists. Reduce motion is gpui-side: forward the OS flag once in reapply and every primitive snaps. Supersedes the reverted 2026-10-30 framework attempt (which proved the substrate but over-built the plumbing)
D22 The color set does not expand along gpuikit's Themeable shape (evaluated 2026-10-31): their per-component token layers (button_*, input_*) are passthroughs of their own primitives — a drift-prone parallel layer our role-plus-ramp model replaces with OKLCH-derived hover/pressed shades; depth variants (surface_secondary), the badge palette, and fg_disabled wait for real consumers (Badge, nested elevation, a theme that can't express disabled by opacity). The one adoption: info as the fourth severity — the Alert's Info severity had a live consumer painting from the primary ramp, which collides with Danger under warm accents. Domain palettes (editor diff colors) may revisit gpuikit's ThemeExtension idea when crates/editor lands
D23 The old Toast split into Alert + Toast (2026-10-31): one component can't be both a persistent page callout and a fleeting bottom-center nudge — different lifetime (persists until acted on vs auto-dismisses), different placement (inline in flow vs floating bottom center), different content shape (title + message + severity tint vs one line + icon + action). Alert is fully controlled — the app renders it while the condition holds and its close button reports intent; Toast keeps the store + timers. Toast drops severity and titles: Adwaita toasts are single-line nudges, and severity belongs to the banners that stay on the page
D24 Shadows follow the platform's design language (2026-11-01): in-app shadows are painted by gpui, so "native" means each platform's shadow values — Elevation is a two-layer key + ambient stack and resolve picks it per SourceKind (macOS soft and layered at 12% alpha, Windows tight with more offset, Linux compact and darker; one shared dark stack where borders do the separation work). Static design constants, never OS-read (no platform API states shadow values); replaces the Alert's light-mode alpha hack, which was a single-profile workaround
D25 Components fetch, never scale (2026-11-01): every derived metric a component kept re-deriving from root metrics — dense label text (×0.8/0.85), dense inline icons (×0.75), dense horizontal padding (×0.5/0.75/2), plate insets (0.125/0.25/0.375 rem), strip band heights (×1/1.15/1.35) — moved into the per-size-class ControlMetrics table (caption_size, dense_icon_size, dense_padding_x, plate_inset, strip_height, plus indicator_size for checkboxes, radios, and switch knobs), computed once in with_root_font_size. The table stays role-based — one value per concept per size class, shared by every component — not gpuikit's per-component token layers (D22). Component-unique geometry that has no second consumer keeps its ratios over root metrics (switch track length, slider knob, avatar initials, tree indent). Two icon roles are fetch-only: content icons (alert info, segmented options) take icon_size — never smaller than the label — and indicator glyphs (checks, chevrons, close buttons) take dense_icon_size
D26 Application layout is a crate, not app-side code (2026-11-01): crates/layout bootstraps the shell — left sidebar (on by default), right sidebar (off), and a center dock whose panes drag-to-split — reversing the old "dock stays app-side" deferral. Prompted by signed's crates/dock (a gpui-component DockArea behind a custom skin): it proves the demand and marks the complexity we skip — no renderer/skin seam (one theme-baked appearance), no serialization, no zoom/collapse modes, no floating windows, no JS host. The API stays smaller than gpui-kit's dock and zed's editor dock (both D15/D20 references, read only): an application-owned LayoutState, a controlled pane tree, registered panel descriptors, and a handful of ops (split / close / activate / toggle sidebar); a panel declares needs_right_sidebar and the layout opens it on activation

Dropped from rev 3: D2, D5, D9 (port-era decisions); the review gate returns in a lighter form as D20. D10 (no icon bundling) is superseded by D19.

12. Immediate next steps

  1. Phase A scaffold ✅ — workspace, toolchain pin, CI, theme seed, icons crate, core feature scaffolding, facade, gallery shell.
  2. Phase B theme engine core ✅ — resolve pipeline, OKLCH ramps, presets, macOS source + watchers, gallery theme inspector.
  3. Phase C controls ✅ — units 1–9 (Button → Avatar), each through the review gate; sizing API (§6.1).
  4. Phase D input ✅ — single-line Input (unit 10) + the §6.2 completion pass (modifiers, undo/redo, read-only, actions + key context, caret blink, paint polish). NumberInput stays deferred; multiline returns as §6.3 M7.
  5. Phase E overlays ✅ — Popover (unit 11), ScrollArea (unit 11.5), Menu (unit 12), Tooltip + Dialog/AlertDialog + Toast (unit 11+ row; Sheet dropped). Phase gate held: escape/dismiss semantics and focus behavior per overlay.
  6. Unit 14 — motion, per component (§6 row 14, D21) ✅ Landed in five passes (2026-10-30): (a) theme reduce_motion → cx.set_reduce_motion in reapply; (b) Switch thumb with_spring; (c) Toast 8 px slide-in; (d) Dialog scrim enters with the panel; (e) Progress 30 fps cap. Exits and hover washes stay deferred (§6 deferred list).
  7. Phase F, data first ✅ data complete (2026-10-30) — crates/data opened with List, then Tree, Tabs, Sidebar (the dead sheet feature dropped from core along the way; Table dropped from the roadmap). Also extracted crates/utils (2026-10-30): h_flex/v_flex (core's common re-exports them), focus_handle (the namespaced keyed handle, 8 call sites), focus_ring (7 sites), adjacent_selectable + nav_key (4 sites), and the selectable-row paint helpers selectable_label_color/paint_selectable_row (3 sites, reading &Theme directly — no intermediate color struct), plus the StyledExt trait (refine_style — adapted from gpui-kit base, D15 — replacing the per-component style().refine pattern; 5 sites) — core and data depend on it; the facade doesn't re-export it. Linux theme sources landed (2026-10-30): Omarchy (colors.toml parse + state-dir detection + inotify on current), KDE (kdeglobals [Colors:*] + AccentColor + ~/.config inotify), GNOME/generic (portal ReadAll + SettingChanged via zbus 5 blocking on its own thread, gsettings fallback with the libadwaita accent table) — SourceKind::Omarchy/Kde/Gnome, detection cascade Omarchy → KDE → portal → fallback, all parsers fixture-tested on every OS and the Linux build compile-checked via cargo check --target x86_64-unknown-linux-gnu. Windows source + icon resolvers landed (2026-10-30): SourceKind::Windows (WinRT UISettings + Personalize fallback + ColorValuesChanged/HighContrastChanged, §5.3), the Segoe Fluent codepoint renderer, and the freedesktop theme walker (§4.4); the WinRT surface was verified against the real windows 0.62 bindings via a standalone cross-check (gpui's own manifest build needs llvm-rc from macOS). Remaining Phase F: side-by-side gallery screenshots per platform — Omarchy/KDE/GNOME need a Linux run, Windows needs its first hardware build. NumberInput stays deferred until needed; multiline landed as §6.3 M7 (2026-11-01).
  8. Then — Phase G is the migration additions (§6.3): Badge ✅, Select ✅, Segmented control ✅, Context menu ✅, Empty state ✅, password input ✅, multiline textarea ✅, then Avatar image (M8) — each unblocks a concrete signed screen. Pickers (Calendar/DatePicker, TimeField, ColorPicker) stay decided-at-unit-time. crates/editor starts when file viewing (M15) is reached.
  9. Then — the layout crate (§6.4): ResizeHandle ✅, Shell ✅, window frame ✅, Dock ✅, drag-to-split ✅, titlebar + window controls, and panel-declared sidebar auto-open — each through the review gate, with signed's crates/dock as the usage reference and the gallery workspace page as the example. Order against M9+ is decided when the crate starts.

Batched polish candidates between units: scrollbar auto-hide + horizontal thumb, Undo/Redo menu events.