diff --git a/crates/core/src/alert.rs b/crates/core/src/alert.rs index a8084e5..5bf85f1 100644 --- a/crates/core/src/alert.rs +++ b/crates/core/src/alert.rs @@ -7,7 +7,7 @@ use gpui::{ Styled as _, Window, deferred, div, ease_out_quint, px, }; use gpui_native_icons::{IconRole, icon}; -use gpui_native_theme::ActiveTheme as _; +use gpui_native_theme::{ActiveTheme as _, Size}; use crate::common::DismissHandler; use crate::common::v_flex; @@ -128,8 +128,10 @@ impl RenderOnce for Alert { let surface = theme.colors.surface; let border = theme.colors.border; let shadows = theme.elevations.popover.shadows(); - let text_size = theme.sizes.small.text_size; - let icon_size = theme.sizes.small.icon_size; + let metrics = theme.sizes.resolve(Size::Medium); + let icon_size = metrics.icon_size; + let title_size = metrics.text_size; + let caption_size = metrics.caption_size; div() .w_full() @@ -160,7 +162,7 @@ impl RenderOnce for Alert { .when_some(self.title.clone(), |alert, title| { alert.child( div() - .text_size(text_size) + .text_size(title_size) .text_color(foreground) .when(self.message.is_some(), |title| { title.font_weight(gpui::FontWeight::SEMIBOLD) @@ -169,13 +171,17 @@ impl RenderOnce for Alert { ) }) .when_some(self.message.clone(), |alert, message| { - // With no title, the message is the primary line. let color = if self.title.is_some() { muted_foreground } else { foreground }; - alert.child(div().text_size(text_size).text_color(color).child(message)) + alert.child( + div() + .text_size(caption_size) + .text_color(color) + .child(message), + ) }), ) .when_some(self.on_dismiss, |alert, on_dismiss| { @@ -189,7 +195,7 @@ impl RenderOnce for Alert { .hover(|style| style.bg(muted)) .child( icon(IconRole::Close) - .size(theme.sizes.small.icon_size * 0.75) + .size(theme.sizes.small.dense_icon_size) .color(foreground.alpha(0.55)), ) .on_click(move |_, _window, cx| on_dismiss(cx)), diff --git a/crates/core/src/badge.rs b/crates/core/src/badge.rs index 7cc04fc..8773802 100644 --- a/crates/core/src/badge.rs +++ b/crates/core/src/badge.rs @@ -83,11 +83,9 @@ impl RenderOnce for Badge { fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { let theme = cx.theme(); let metrics = theme.sizes.resolve(self.size); + let label_size = metrics.caption_size; let (background, foreground) = self.variant.tokens(theme); - // Counts read smaller than control labels, like every native badge. - let label_size = metrics.text_size * 0.8; - h_flex() .flex_none() .justify_center() @@ -95,8 +93,7 @@ impl RenderOnce for Badge { .bg(background) .text_color(foreground) .text_size(label_size) - .line_height(label_size * 1.25) - .px(metrics.padding_x * 0.5) + .px(metrics.dense_padding_x) .py_0p5() .child(self.label) .refine_style(&self.style) diff --git a/crates/core/src/checkbox.rs b/crates/core/src/checkbox.rs index 68650ad..c81c5bc 100644 --- a/crates/core/src/checkbox.rs +++ b/crates/core/src/checkbox.rs @@ -121,7 +121,7 @@ impl RenderOnce for Checkbox { h_flex() .justify_center() .flex_none() - .size(metrics.icon_size) + .size(metrics.indicator_size) .rounded(theme.radius.sm) .border_1() .when(self.checked, |indicator| { @@ -129,7 +129,11 @@ impl RenderOnce for Checkbox { .bg(ramps.primary.base) .border_color(ramps.primary.base) .hover(|style| style.bg(ramps.primary.hover)) - .child(icon(IconRole::Check).color(colors.primary_foreground)) + .child( + icon(IconRole::Check) + .size(metrics.dense_icon_size) + .color(colors.primary_foreground), + ) }) .when(!self.checked, |indicator| { indicator diff --git a/crates/core/src/input.rs b/crates/core/src/input.rs index c8d7559..47c01a6 100644 --- a/crates/core/src/input.rs +++ b/crates/core/src/input.rs @@ -1551,7 +1551,7 @@ impl RenderOnce for Input { .overflow_hidden() .w_full() .h(metrics.input_height) - .px(metrics.padding_x / 2.0) + .px(metrics.dense_padding_x) .rounded(radius.control) .border_1() .border_color(colors.border) diff --git a/crates/core/src/menu.rs b/crates/core/src/menu.rs index 0f83caf..c5f18c1 100644 --- a/crates/core/src/menu.rs +++ b/crates/core/src/menu.rs @@ -136,6 +136,7 @@ struct MenuChrome { radius_overlay: Pixels, radius_sm: Pixels, icon_size: Pixels, + dense_icon_size: Pixels, shadow: Vec, } @@ -234,6 +235,7 @@ impl RenderOnce for Menu { radius_overlay: theme.radius.overlay, radius_sm: theme.radius.sm, icon_size: theme.sizes.medium.icon_size, + dense_icon_size: theme.sizes.medium.dense_icon_size, shadow: theme.elevations.popover.shadows(), }, theme.motion.overlay, @@ -348,7 +350,7 @@ impl RenderOnce for Menu { .role(Role::Menu) .aria_label(label) .mt_1() - .min_w_40() + .min_w_56() .p_1() .bg(chrome.surface) .text_color(chrome.foreground) @@ -522,7 +524,7 @@ fn render_item( .when(checked == Some(true), |row| { row.child( icon_glyph(IconRole::Check) - .size(chrome.icon_size * 0.75) + .size(chrome.dense_icon_size) .color(label_color), ) }) diff --git a/crates/core/src/radio.rs b/crates/core/src/radio.rs index 7d95735..716111b 100644 --- a/crates/core/src/radio.rs +++ b/crates/core/src/radio.rs @@ -189,7 +189,7 @@ where h_flex() .justify_center() .flex_none() - .size(metrics.icon_size) + .size(metrics.indicator_size) .rounded(radius.full) .border_1() .when(is_selected, |indicator| { @@ -199,7 +199,7 @@ where .hover(|style| style.bg(ramps.primary.hover)) .child( div() - .size(metrics.icon_size / 2.0) + .size(metrics.indicator_size / 2.0) .rounded(radius.full) .bg(colors.primary_foreground), ) diff --git a/crates/core/src/segmented.rs b/crates/core/src/segmented.rs index dc818f0..5142e9d 100644 --- a/crates/core/src/segmented.rs +++ b/crates/core/src/segmented.rs @@ -5,7 +5,7 @@ use gpui::{ AnimationExt as _, App, Bounds, ElementId, Entity, InteractiveElement as _, IntoElement, KeyDownEvent, ParentElement as _, Point, RenderOnce, Role, SharedString, SpringAnimation, SpringConfig, StatefulInteractiveElement as _, StyleRefinement, Styled as _, Toggled, Window, - canvas, div, rems, + canvas, div, }; use gpui_native_icons::{IconRole, icon}; use gpui_native_theme::{ActiveTheme as _, Size}; @@ -152,29 +152,12 @@ where let ring = theme.colors.ring; let radius = theme.radius.full; let plate_shadow = theme.elevations.control.shadows(); - let icon_size = metrics.icon_size * 0.85; - let track_height = match self.size { - Size::Small => metrics.control_height, - Size::Medium => metrics.control_height * 1.15, - Size::Large => metrics.control_height * 1.35, - }; - - let track_inset = match self.size { - Size::Small => rems(0.125), - Size::Medium => rems(0.25), - Size::Large => rems(0.375), - }; - - let horizontal_padding = match self.size { - Size::Small => metrics.padding_x * 0.5, - Size::Medium | Size::Large => metrics.padding_x * 0.75, - }; - - let text_size = match self.size { - Size::Small => metrics.text_size * 0.85, - Size::Medium | Size::Large => metrics.text_size, - }; + let track_height = metrics.strip_height; + let track_inset = metrics.plate_inset; + let horizontal_padding = metrics.dense_padding_x; + let text_size = metrics.caption_size; + let icon_size = metrics.icon_size; let selected_index = self .options diff --git a/crates/core/src/select.rs b/crates/core/src/select.rs index b70c20c..132bb35 100644 --- a/crates/core/src/select.rs +++ b/crates/core/src/select.rs @@ -168,7 +168,7 @@ where let foreground = theme.colors.foreground; let muted_foreground = theme.colors.muted_foreground; let radius = theme.radius.control; - let icon_size = metrics.icon_size * 0.75; + let icon_size = metrics.dense_icon_size; let label = self .a11y_label @@ -214,7 +214,7 @@ where .gap_2() .w_full() .h(metrics.input_height) - .px(metrics.padding_x / 2.0) + .px(metrics.dense_padding_x) .rounded(radius) .border_1() .border_color(if is_open { ring } else { border }) diff --git a/crates/core/src/switch.rs b/crates/core/src/switch.rs index 5608254..611b28a 100644 --- a/crates/core/src/switch.rs +++ b/crates/core/src/switch.rs @@ -96,11 +96,11 @@ impl RenderOnce for Switch { let ramps = theme.ramps; let metrics = theme.sizes.resolve(self.size); - let track_height = metrics.icon_size * 1.25; - let track_width = metrics.icon_size * 2.25; + let track_height = metrics.control_height; + let track_width = metrics.control_height * 1.65; - let knob = metrics.icon_size; - let inset = metrics.icon_size * 0.125; + let knob = metrics.indicator_size; + let inset = metrics.plate_inset; let travel = track_width - knob - inset * 2.; let next = !self.checked; diff --git a/crates/core/src/toast.rs b/crates/core/src/toast.rs index 6993bb6..01087fd 100644 --- a/crates/core/src/toast.rs +++ b/crates/core/src/toast.rs @@ -266,6 +266,7 @@ fn pill(entry: &ToastEntry, toasts: &Entity, cx: &App) -> gpui::Stateful let radius = theme.radius.full; let shadows = theme.elevations.popover.shadows(); let icon_size = theme.sizes.small.icon_size; + let dense_icon_size = theme.sizes.small.dense_icon_size; let text_size = theme.sizes.small.text_size; let toast = &entry.toast; let toasts = toasts.clone(); @@ -336,7 +337,7 @@ fn pill(entry: &ToastEntry, toasts: &Entity, cx: &App) -> gpui::Stateful .hover(|style| style.bg(muted)) .child( icon(IconRole::Close) - .size(icon_size * 0.75) + .size(dense_icon_size) .color(foreground.alpha(0.55)), ) .on_click(move |_, _window, cx| { diff --git a/crates/core/src/toggle.rs b/crates/core/src/toggle.rs index 5aa8c28..05fdbd4 100644 --- a/crates/core/src/toggle.rs +++ b/crates/core/src/toggle.rs @@ -100,7 +100,7 @@ impl RenderOnce for Toggle { .id(self.id) .justify_center() .h(metrics.control_height) - .px(metrics.padding_x * 0.75) + .px(metrics.dense_padding_x) .rounded(theme.radius.control) .text_size(metrics.text_size) .text_color(colors.foreground) diff --git a/crates/data/src/tabs.rs b/crates/data/src/tabs.rs index 71b626a..f895ed2 100644 --- a/crates/data/src/tabs.rs +++ b/crates/data/src/tabs.rs @@ -225,7 +225,7 @@ fn render_tab( .when_some(tab.icon, |strip_tab, role| { strip_tab.child( icon(role) - .size(theme.sizes.medium.icon_size * 0.75) + .size(theme.sizes.medium.dense_icon_size) .color(label_color), ) }), diff --git a/crates/data/src/tree.rs b/crates/data/src/tree.rs index 884ac5b..1444a05 100644 --- a/crates/data/src/tree.rs +++ b/crates/data/src/tree.rs @@ -327,7 +327,7 @@ fn render_disclosure( } else { IconRole::ChevronRight }) - .size(theme.sizes.medium.icon_size * 0.75) + .size(theme.sizes.medium.dense_icon_size) .color(glyph_color), ); diff --git a/crates/theme/src/lib.rs b/crates/theme/src/lib.rs index aa4a7e2..42d8a22 100644 --- a/crates/theme/src/lib.rs +++ b/crates/theme/src/lib.rs @@ -267,8 +267,20 @@ pub struct ControlMetrics { pub text_size: Pixels, /// Icons and on/off indicators. pub icon_size: Pixels, + /// Icons inline with dense content — menu rows, badges, chevrons. + pub dense_icon_size: Pixels, + /// Selection indicators — checkboxes, radios, and switch knobs. + pub indicator_size: Pixels, /// Avatars. pub avatar_size: Pixels, + /// Dense label text. + pub caption_size: Pixels, + /// Tight horizontal padding inside pills, segments, and fields. + pub dense_padding_x: Pixels, + /// The inset between a control's edge and a raised inner plate. + pub plate_inset: Pixels, + /// The band height of multi-segment strips. + pub strip_height: Pixels, } /// The control metrics for every size class. @@ -280,20 +292,72 @@ pub struct SizeTable { } impl SizeTable { + /// Every metric derives from the root font size; components fetch, and + /// never scale these themselves. pub fn with_root_font_size(rem_size: Pixels) -> Self { - let class = |height: f32, padding: f32, text: f32, icon: f32, avatar: f32| ControlMetrics { - control_height: rem_size * height, - input_height: rem_size * (height + 0.125), - padding_x: rem_size * padding, - text_size: rem_size * text, - icon_size: rem_size * icon, - avatar_size: rem_size * avatar, - }; - Self { - small: class(1.5, 0.75, 0.85, 0.875, 1.5), - medium: class(1.75, 1.0, 1.0, 1.0, 2.0), - large: class(2.25, 1.25, 1.15, 1.25, 3.0), + // One row per size class: control height, padding, label text, icon, + // avatar, strip band, and plate inset — all as root-size multiples. + struct ClassFactors { + height: f32, + padding: f32, + text: f32, + icon: f32, + avatar: f32, + strip: f32, + inset: f32, } + let small = ClassFactors { + height: 1.5, + padding: 0.75, + text: 0.85, + icon: 0.875, + avatar: 1.5, + strip: 1.5, + inset: 0.125, + }; + let medium = ClassFactors { + height: 1.75, + padding: 1.0, + text: 1.0, + icon: 1.0, + avatar: 2.0, + strip: 2.0, + inset: 0.25, + }; + let large = ClassFactors { + height: 2.25, + padding: 1.25, + text: 1.15, + icon: 1.25, + avatar: 3.0, + strip: 3.0, + inset: 0.375, + }; + let metrics = |f: &ClassFactors| ControlMetrics { + control_height: rem_size * f.height, + input_height: rem_size * (f.height + 0.125), + padding_x: rem_size * f.padding, + text_size: rem_size * f.text, + icon_size: rem_size * f.icon, + dense_icon_size: rem_size * f.icon * 0.75, + indicator_size: rem_size * f.height * 0.7, + avatar_size: rem_size * f.avatar, + caption_size: rem_size * f.text, + dense_padding_x: rem_size * f.padding * 0.75, + plate_inset: rem_size * f.inset, + strip_height: rem_size * f.strip, + }; + + let mut table = Self { + small: metrics(&small), + medium: metrics(&medium), + large: metrics(&large), + }; + // Dense text stops growing with the large class, and small's is + // one step below its label. + table.small.caption_size = table.small.text_size * 0.85; + table.large.caption_size = table.medium.text_size; + table } /// The metrics for `size`. diff --git a/crates/theme/src/resolve.rs b/crates/theme/src/resolve.rs index b4aae21..1ee77b1 100644 --- a/crates/theme/src/resolve.rs +++ b/crates/theme/src/resolve.rs @@ -292,6 +292,12 @@ mod tests { assert_eq!(resolved.sizes.medium.input_height, px(30.)); assert_eq!(resolved.sizes.medium.text_size, px(16.)); assert_eq!(resolved.sizes.large.avatar_size, px(48.)); + assert_eq!(resolved.sizes.small.caption_size, px(13.6 * 0.85)); + assert_eq!(resolved.sizes.medium.caption_size, px(16.)); + assert_eq!(resolved.sizes.large.caption_size, px(16.)); + assert_eq!(resolved.sizes.medium.dense_padding_x, px(16. * 0.75)); + assert_eq!(resolved.sizes.medium.plate_inset, px(4.)); + assert_eq!(resolved.sizes.medium.strip_height, px(32.)); } } diff --git a/docs/PLAN.md b/docs/PLAN.md index f45d29a..ee81533 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -342,6 +342,7 @@ Pure tests for resolve/overlay chain, payload parsing (fixtures), icon resolutio | 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` | 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.