theme: derive elevations from the platform's native shadows

This commit is contained in:
2026-11-01 09:54:35 +07:00
parent 5a32401e25
commit d107f35084
4 changed files with 127 additions and 37 deletions
+4 -3
View File
@@ -154,7 +154,7 @@ Omarchy `colors.toml` maps almost 1:1: `lighter_background` → `surface`, `dark
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` (transcribed NSPopover-class shadows, builder → `Vec<BoxShadow>`), and `Motion.overlay` (200 ms enter transition). Popover consumes elevation + motion; the scrim waits for Dialog.
- ~~**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.
@@ -181,7 +181,7 @@ Every target supplies one accent color (or a stated ramp). Where the platform st
### 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), popup elevation and menu metrics. 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).
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
@@ -219,7 +219,7 @@ Each row is one review unit: build it, show it in the gallery, review, then move
| 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 popover shadow, halved in light mode so it doesn't pop on white), 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 |
| 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 `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) |
| then | pickers or data | Calendar/DatePicker, TimeField, ColorPicker | Decide at unit time. |
@@ -316,6 +316,7 @@ Pure tests for resolve/overlay chain, payload parsing (fixtures), icon resolutio
| 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 |
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.