# gpui-native-kit — Project Plan Rev 9 (2026-11-03). Changes from rev 8: the layout crate (§6.4) is complete through L5 — dock with drag-to-split, titlebar + window controls, panel-declared sidebar auto-open — and the gallery is now the component browser: the app shell with the component catalog in its sidebar, every component a dock panel. Titlebar lessons landed in the theme and layout: `Theme.titlebar_height` (§5.5), and app-owned window dragging so a dragged tab never drags the window with it (L4.5). The `Sidebar` scrolls internally. The rev 8 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`](https://github.com/longbridge/gpui-kit/tree/main/crates/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 (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: ```toml gpui = { git = "https://github.com/zed-industries/zed", rev = "ec86047d67acd6fddd81392f44ed6ebcc6e7f531" } gpui_platform = { git = "https://github.com/zed-industries/zed", rev = "ec86047d67acd6fddd81392f44ed6ebcc6e7f531", features = ["font-kit"] } reqwest_client = { git = "https://github.com/zed-industries/zed", rev = "ec86047d67acd6fddd81392f44ed6ebcc6e7f531" } ``` `font-kit` is required on macOS for glyph rasterization; Linux needs `wayland` and/or `x11`; Windows needs nothing. Apps that show remote images install a real client at startup — `application().with_http_client(Arc::new(reqwest_client::ReqwestClient::new()))` — because gpui defaults to a `NullHttpClient` and `img(http…)` silently loads nothing without one. 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) ```text 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/ # the component browser: the app shell with the # catalog in its sidebar, every component a dock panel ``` 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. ```mermaid 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` 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 `ElementId`s. - **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 ```mermaid 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. Landed additions, each with its consumer: - **Overlay scrim + elevation + motion tokens** (unit 11, 2026-10-29): `colors.overlay`, `Radius.overlay`, `Elevations.popover`/`dialog`, `Motion.overlay` (200 ms) — with per-platform two-layer shadow stacks picked per `SourceKind` (D24, 2026-11-01). - **Scrollbar thumb token + metrics** (ScrollArea, 2026-10-29): `colors.scrollbar_thumb`, `Theme.scrollbar` (`width` 8 pt, `inset` 2 pt, `min_thumb` 24 pt). - **`success` / `warning` / `info` severities** (Toast 2026-10-29; `info` 2026-10-31, D22): tokens + foregrounds + OKLCH ramps, no platform states them, TOML-overridable — so a warm platform accent never makes Info read as Danger. - **`colors.muted_hover`** (dock tabs, 2026-11-02): one step past `muted`; the ghost Button's hover used to paint the same `muted` a selected pill fills with. - **`Theme.titlebar_height`** (2026-11-03, §5.5): the height the window's titlebar strips draw at, stated per platform where the OS declares one. Resolved without tokens: Slider rides the Switch's borrowed roles (no knob role — no third consumer); motion stays exactly `Motion.overlay` (D21), with the reduce-motion forwarding fix landed 2026-10-30 (`reapply` calls `cx.set_reduce_motion`). 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). **`titlebar_height` landed (2026-11-03)**: `Theme.titlebar_height` — the height the window's titlebar strips draw at. Windows states 32 px (the Win32 caption at 100% scaling, via `SystemTheme.titlebar_height`); macOS and Linux derive `max(medium control_height, 34 px)` — the height custom titlebars draw at there, per the pinned zed reference. The dock's pane bar centers its tabs in it. 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 ` 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..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` + `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 `TreeItem`s (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). **Follow-up (2026-11-03):** the Sidebar scrolls internally — entries sit in a `ScrollArea` whose handle lives in element state (the overlay thumb paints; the offset survives re-renders); the gallery's 29-entry catalog outgrew the fixed region. `sidebar` pulls `core/scroll_area` | | 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)` + `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. An `XSmall` class was tried (2026-11-03) and removed — three classes cover the components so far; explicit sides are a caller style refinement (`Styled::size`). 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` fields — `Default` is platform defaults (cmd/alt on macOS, ctrl elsewhere), `empty()` starts bare, any field replaceable/unbindable; `bind_input_keys(cx, None | table)` registers at startup (gallery + tests call it). Verified gpui semantics first: matched bindings consume the keystroke before `on_key_down`, and actions claim by default in bubble (`cx.propagate()` yields — Copy does when the selection is empty). macOS jump extras (`cmd-left/right/up/down`, `cmd-shift-left/right`) are appended in `into_bindings`, not field-configurable | | 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](https://git.reya.info/reya/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)` + `on_change(Option)`, 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 ✅ Landed (2026-11-03) | profile pictures in rows and panel tabs | Lifts the deferred image slot — with the pixel-art fallback the row originally left to signed (the initials placeholder never shipped). `Avatar::new(id).name(..).picture(Option)`: the picture renders through gpui `img` on a `secondary` container, `Role::Image` with the aria label from name-or-id; a failed download paints the container blank — the pixel art is the **no-picture** fallback, matching signed. The fallback adapts signed's pixel avatar (D15/D20): FNV-1a of the id seeds an xorshift PRNG that draws a mirror-symmetric 8×8 grid (1-cell margin, 42% fill, 25% shade cells, ≥5 left-half cells); the hue rotates the accent's OKLCH hue per seed, the shade cells lighten in dark mode and darken in light, the tray fills at 16% alpha — deterministic across runs and platforms, colored only from theme-derived OKLCH. Sizes ride the size table's `avatar_size` (`Sizable`). 4 pattern tests (mirror symmetry, empty outer ring, determinism + minimum fill, hash stability); the gallery panel shows a loaded image, the seeded fallback, and both sizes. The gallery also installs zed's `reqwest_client` at startup (`with_http_client`) — without it gpui defaults to a `NullHttpClient` and remote pictures never load (§3). **Follow-up (2026-11-03):** `.fully_rounded(bool)` renders the avatar circular (`Radius.full` instead of the control radius — threaded through the picture, the pixel grid, and the tray) | 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 a right-sidebar **view**; activating it points the sidebar at that view and the dock asks the app to open the sidebar. [signed](https://git.reya.info/reya/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 `IconRole`s (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 `PanelDescriptor`s (`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 `ResizeHandle`s (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::` 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 ✅ Landed (2026-11-02) | The strips are the window's titlebar: every strip (shell sidebars, dock pill bar) marks itself `WindowControlArea::Drag` and double-click-maximizes (`titlebar_double_click` on macOS, `zoom_window` on Linux; Windows does it natively through the caption hit test). New `window_controls` module (feature `window_controls`, pulled by `shell` and `dock`): a `WindowControls` element rendered at the trailing end of the right-hand strip — the right sidebar's strip when visible, else the dock pill bar via `Dock::window_controls(bool)`. **Windows**: caption buttons (36 px wide, Segoe codepoints through the icons crate — new `WindowMinimize`/`WindowMaximize`/`WindowRestore` roles, E921/E922/E923 verified against zed's own table) marked `WindowControlArea::Min/Max/Close` so the OS owns the clicks and snap layouts; maximize flips to restore via `is_maximized`; capability-gated by `Window::window_controls()` + `is_minimizable`/`is_resizable`; close hovers the platform red (a platform convention const, like the Segoe codepoints). **Linux**: Adwaita circular buttons (the platform dress, hand-rolled like zed's) under `Decorations::Client` only, clicking `minimize_window`/`zoom_window`/`remove_window`. **macOS** draws nothing — system traffic lights; the shell reserves `TRAFFIC_LIGHT_PAD` (78 px, Tahoe SDK) in the left sidebar strip's leading edge, and `Dock::traffic_light_pad(bool)` pads the pill bar when no left sidebar sits over the lights. The gpui dispatch fact that makes this safe: Windows forwards `WM_NCLBUTTONDOWN` into gpui's normal event stream, so buttons inside a Drag-marked strip still get their clicks, and only unconsumed presses fall through to native caption behavior. Adapted from zed's `platform_title_bar` (D15/D20); the gallery wires both dock flags from `LayoutState`. 2 platform-gated headless tests (macOS pad shifts the pill bar's tab by the pad width; controls render only under the flag on their platforms); CI feature loop gained `window_controls`. **Amended (2026-11-03, app-owned dragging):** the gallery hit the macOS trap — with `appears_transparent` alone, AppKit still owns the native titlebar band and dragged the window from a tab press (the `Drag` marking is a no-op on macOS at this rev; `on_hit_test_window_control` is unimplemented there, unlike Windows). Windows now open with `app_owns_titlebar_drag: true` and the new `titlebar_strip` helper carries the strip behavior — drag-to-move (guarded by `has_active_drag`, so a dragged tab's session always wins), double-click maximize, and the `Drag` area — applied to the sidebar strips and to the dock bar's dead-zone span after the tabs, never to the tabs themselves (signed's pattern). The pane bar's height reads `Theme.titlebar_height` (§5.5), tabs centered in it | | L5 | layout | Sidebar auto-open ✅ Landed (2026-11-02) | Panels declare a right-sidebar **view**, not a flag: `PanelDescriptor::right_sidebar(builder)` — the view builds once when the panel opens (retained alongside the panel view, so its state survives too) and lives on the open tab. Activating a panel that has one points the sidebar at that view and runs the app-registered `DockState::on_open_right_sidebar(handler)` so the app opens it — visibility stays on the shell's `LayoutState`, the dock only asks. Panels without a view leave the sidebar showing the last view (sticky — no yanking on tab switches); closing the displayed panel's tab falls back to the first remaining tab's view, else blank. The sidebar renders through a new `DockSidebar` element (the cached-view pattern), which the app places in `Shell::right_sidebar`. Gallery: Overview and Inbox declare details views, Settings doesn't — switching to Settings keeps the sidebar, closing a details tab falls back. 2 headless tests (activation fires the callback and renders the panel's view; sticky-keep on view-less panels + close-fallback) | 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) | M1 ✅ (2026-11-01): Badge, Select, Segmented, Context menu, Empty state, password + multiline input. M8 ✅ (2026-11-03): Avatar image. Next: Wave M2 (M9–M14, M18) → 0.1 | Each unit through the review gate; each wave unblocks a real signed screen | | H — layout crate (§6.4) ✅ Landed (2026-11-01 → 11-03) | ResizeHandle, Shell bootstrap + client-side window frame, Dock with drag-to-split, titlebar + window controls (app-owned dragging), panel-declared sidebar auto-open | Each unit through the review gate; the gallery is the bootstrap example — the shell with the catalog sidebar and a dock of component panels | 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 `IconRole`s 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 a right-sidebar view and the dock asks the app to open the sidebar 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. Next steps Complete, in landing order: Phase A scaffold · B theme engine (resolve pipeline, OKLCH ramps, presets, macOS source + watchers, gallery inspector) · C controls, units 1–9 + the sizing API (§6.1) · D input (§6.2) · E overlays · E+ motion (D21) · F platforms + data (`crates/data`; `crates/utils` extracted; Linux theme sources; Windows source; Segoe Fluent + freedesktop icon resolvers; per-platform elevation stacks) · G waves M1 + M8 (Badge, Select, Segmented, Context menu, Empty state, password input, multiline textarea, Avatar image) · H the layout crate end to end (§6.4: ResizeHandle, Shell, window frame, Dock, drag-to-split, titlebar + window controls, sidebar auto-open). Landed notes live in §4–§7; nothing below is started. Next, in order: 1. **Wave M2** (§6.3): Field wrapper (M9, the settings-dialog label/description/error wrapper), Tag (M10), Status dot (M11), Combobox (M12), Command palette (M13), VirtualList (M14). The Spinner (M18) lands with its next consumer — before signed wires repo loading into dock tabs (`Tab::busy` holds a placeholder ring today). Each unit through the review gate; order follows signed's needs. 2. **Wave M3 opens `crates/editor`** (D12): code view (M15), diff view (M16), Markdown view (M17) when a consumer confirms. 3. **Phase F tail:** side-by-side gallery screenshots — a Linux run (Omarchy/KDE/GNOME) and the first Windows hardware build. Pickers (Calendar/DatePicker, TimeField, ColorPicker) stay decided-at-unit-time. Deferred layout work (persistence, pane zoom, bottom dock, collapse-to-edge, floating windows) waits for a real consumer. Batched polish candidates between units: scrollbar auto-hide + horizontal thumb, `Undo`/`Redo` menu events.