Skip to main content

gpui_cn/theme/
tokens.rs

1use std::ops::Range;
2
3use gpui_kit::{
4    FontWeight, Hsla, Pixels, SharedString, Size,
5    base::{
6        ColorTokens, RadiusTokens, SemanticThemeTokens, ShadowTokens, SpacingTokens,
7        TextStyleToken, ThemeAppearance, TypographyTokens,
8    },
9    px,
10};
11
12use super::{ThemeConfig, color::mix};
13
14/// The base font size the typography scale is authored at.
15const BASE_FONT_SIZE: f32 = 16.;
16
17/// Everything a component reads: base's semantic tokens plus the few
18/// roles gpui-cn components need that base does not define. Derived from a
19/// [`ThemeConfig`] when the theme resolves. It is never edited by hand
20/// except through [`Theme::update`](super::Theme::update).
21///
22/// Roles are added here only when a component reads them. The skill
23/// color stays on the config until then.
24#[derive(Clone, Debug, PartialEq)]
25#[non_exhaustive]
26pub struct ThemeTokens {
27    /// Light or dark.
28    pub appearance: ThemeAppearance,
29    /// Whether a finger is the pointer. Components show no hover state
30    /// and keep their row actions visible when it is.
31    pub touch: bool,
32    /// Colors, radius, spacing, typography, and shadow shared with base.
33    pub base: SemanticThemeTokens,
34    /// The persistent selected surface: a sidebar row, a segmented choice,
35    /// a pressed toggle. Stronger than the hover surface (`accent`).
36    pub selected: Hsla,
37    /// Link text. The accent, lifted toward the ink on a dark surface so
38    /// it reads as text. The focus ring (`ring`) keeps the accent itself.
39    pub link: Hsla,
40    /// The tooltip pill: a step above the window surface on dark, the ink
41    /// on light.
42    pub tooltip: Hsla,
43    /// Text on a tooltip.
44    pub tooltip_foreground: Hsla,
45    /// The dim over the content behind a sheet, such as the sidebar on a
46    /// phone: black at half opacity in both appearances, shadcn's
47    /// `bg-black/50` sheet overlay.
48    pub scrim: Hsla,
49    /// The dim behind a dialog: black at 25% alpha in both appearances.
50    /// Measured from the reference app on dark: the backdrop is #121212
51    /// over the #181818 window, which is 25% black. The light value is
52    /// derived from the same alpha and was not sampled.
53    pub dialog_overlay: Hsla,
54    /// The sidebar surface: a step above the window on dark, a hair below
55    /// it on light, so the rail reads as a different plane.
56    pub sidebar: Hsla,
57    /// The hairline between the sidebar and the content beside it.
58    pub sidebar_border: Hsla,
59    /// The hover surface of a row on the sidebar: a step above the
60    /// sidebar, short of the selected row, so a hovered row beside the
61    /// selected one reads as two rows. The reference app paints both the
62    /// same.
63    pub sidebar_accent: Hsla,
64    /// The selected row on the sidebar.
65    pub sidebar_selected: Hsla,
66    /// Group labels and other quiet text on the sidebar.
67    pub sidebar_muted_foreground: Hsla,
68    /// The text of controls and rows: 13px at the default font size, the
69    /// control size of the reference app, between base's `xs` and `sm`.
70    pub text_control: TextStyleToken,
71    /// A section heading or a sidebar title: 15px medium.
72    pub text_heading: TextStyleToken,
73    /// A page title: 24px semibold.
74    pub text_title: TextStyleToken,
75    /// The sizes of controls, rows, and window chrome.
76    pub metrics: MetricTokens,
77    /// The strength a disabled control keeps: 55%, measured from the
78    /// reference app's disabled button fill. A button applies it to its
79    /// fill and a card to the whole. The reference app's disabled switch
80    /// measures 60%; gpui-cn keeps one strength for every control.
81    pub disabled_opacity: f32,
82    /// The strength of a terminal's block cursor over the cell it covers:
83    /// 55%, chosen so the glyph under the cursor stays
84    /// readable without a second color.
85    pub terminal_cursor_opacity: f32,
86    /// The color of a switch that is on and a radio that is chosen: the
87    /// accent, as the reference app paints a switch that is on (#539af8 on
88    /// dark). Only the switch and the radio read it, as macOS paints both
89    /// in `controlAccentColor`; no other component paints in the accent.
90    pub switch_track_on: Hsla,
91    /// The track of a switch that is off.
92    pub switch_track_off: Hsla,
93    /// The thumb of a switch: the lighter of the surface and the ink, so
94    /// it is white in both appearances of the built-in themes, as the
95    /// reference app paints it.
96    pub switch_thumb: Hsla,
97    /// Positive status, such as a success tag. The config's `success`.
98    pub success: Hsla,
99    /// Caution status, such as a warning tag. The config's `warning`.
100    pub warning: Hsla,
101    /// Neutral information, such as an info tag. The accent, so it is the
102    /// theme's own blue.
103    pub info: Hsla,
104    /// The solid fill of a destructive mark, such as an alert count: the
105    /// destructive color stepped toward the darker end of surface and ink
106    /// until `solid_foreground` on it reaches 4.5:1, WCAG AA for text.
107    pub destructive_solid: Hsla,
108    /// The solid fill of a success mark, derived as `destructive_solid`.
109    pub success_solid: Hsla,
110    /// The solid fill of a warning mark, derived as `destructive_solid`.
111    pub warning_solid: Hsla,
112    /// The solid fill of an info mark, derived as `destructive_solid`.
113    pub info_solid: Hsla,
114    /// Text on a solid status fill: the lighter of surface and ink.
115    pub solid_foreground: Hsla,
116    /// The fill of a destructive status, such as a danger tag: one step
117    /// from the surface toward the destructive color, scaled by contrast.
118    pub destructive_tint: Hsla,
119    /// The fill of a success status: the `destructive_tint` step toward
120    /// `success`.
121    pub success_tint: Hsla,
122    /// The fill of a warning status: the `destructive_tint` step toward
123    /// `warning`.
124    pub warning_tint: Hsla,
125    /// The fill of an info status: the `destructive_tint` step toward
126    /// `info`.
127    pub info_tint: Hsla,
128    /// The track of a progress bar: `primary` at 20%, shadcn's
129    /// `bg-primary/20`. The indicator is `primary` itself.
130    pub progress_track: Hsla,
131    /// The surface of a menu or popover: #2d2d2d on dark, sampled from the
132    /// reference app's select menus, a larger step above the window than
133    /// a card takes. The window surface on light, as shadcn's `popover`.
134    pub popover: Hsla,
135    /// Text on a popover: the ink itself. The reference app paints menu
136    /// rows white on dark where the window's text is #dfdfdf.
137    pub popover_foreground: Hsla,
138    /// The hairline around a popover: #444444 on dark, sampled from the
139    /// reference app's menu edge. The `border` token on light.
140    pub popover_border: Hsla,
141    /// The highlighted row of a menu: #3d3d3d on dark, sampled from the
142    /// reference app's hovered menu row. On light the same step as a
143    /// hovered sidebar row.
144    pub popover_accent: Hsla,
145    /// A separator inside a popover: #3e3e3e on dark, sampled from the
146    /// reference app's menus. The `border` step on light.
147    pub popover_separator: Hsla,
148    /// A description under a menu row's label: #b5b5b5 on dark, sampled
149    /// from the reference app, a step brighter than `muted_foreground`.
150    /// The `muted_foreground` step on light.
151    pub popover_muted_foreground: Hsla,
152    /// The check beside a selected menu row and the chevron on a select
153    /// trigger: #cacaca on dark, sampled from the reference app, the ink
154    /// a step toward the surface.
155    pub select_indicator: Hsla,
156    /// The fill of a select trigger: #2a2a2a on dark as sampled from the
157    /// reference app on the window surface (it is #343434 on a #232323
158    /// card, the same step). The derivation gives #292929, one step off
159    /// the sample, and the test asserts the derived value. The window surface on light, as shadcn's
160    /// `bg-transparent` trigger.
161    pub select_trigger: Hsla,
162    /// The hairline around a select trigger: #3b3b3b on dark as sampled
163    /// from the reference app. The derivation gives #3a3a3a, one step off
164    /// the sample, and the test asserts the derived value. The `input` token on light, as shadcn's
165    /// `border-input`.
166    pub select_trigger_border: Hsla,
167    /// The hairline between two unselected tabs: the muted text at 60%,
168    /// which the tab reference app paints #707070 over its #181818 bar.
169    pub tab_separator: Hsla,
170    /// The fill of a text field: #2c2c2c on dark, sampled from the
171    /// reference app's settings textarea at 2x on the #181818 window. The
172    /// window surface on light, as shadcn's `bg-transparent` input.
173    pub field: Hsla,
174    /// The hairline around a text field at rest: #3b3b3b on dark, sampled
175    /// from the reference app's select trigger, the same family. The
176    /// `input` token on light, as shadcn's `border-input`.
177    pub field_border: Hsla,
178    /// The hairline around a text field while the caret is inside: #799ec8
179    /// on dark, sampled from the reference app's focused textarea at 2x,
180    /// the link color most of the way over the fill. One pixel, no spread.
181    /// The ring itself on light, as shadcn's `border-ring`.
182    pub field_focus_border: Hsla,
183    /// The text of a multi-line field: 13px on an 18.5px line, the pitch
184    /// measured from the reference app's settings textarea at 2x (37px),
185    /// looser than `text_control` so lines of prose read apart.
186    pub text_textarea: TextStyleToken,
187    /// The slot a lifted pane of a dock leaves and the place it will land:
188    /// the accent at 30%, the drop overlay of Ghostty's splits
189    /// (`Color.accentColor.opacity(0.3)` in
190    /// `macos/Sources/Features/Splits/TerminalSplitTreeView.swift`).
191    pub dock_drop: Hsla,
192    /// The ellipsis on a pane's grab handle at rest: the text color at
193    /// 30%, Ghostty's `.primary.opacity(0.3)` in
194    /// `macos/Sources/Ghostty/Surface View/SurfaceGrabHandle.swift`.
195    pub dock_handle: Hsla,
196    /// The ellipsis on a hovered grab handle: the text color at 80%,
197    /// Ghostty's `.primary.opacity(0.8)` in the same file.
198    pub dock_handle_hover: Hsla,
199    /// The fill over a pane of a split that does not have focus: the
200    /// background at 30%, Ghostty's default `unfocused-split-opacity` of 0.7
201    /// with `unfocused-split-fill` left to the background
202    /// (`src/config/Config.zig`).
203    pub dock_unfocused: Hsla,
204}
205
206/// The sizes gpui-cn components are built from.
207///
208/// Control and row sizes are on the rem scale, so they follow the UI font
209/// size. Window chrome and layout widths are fixed pixels: the platform
210/// draws its window controls at one size, and a sidebar's width is a
211/// layout decision, not a text one.
212#[derive(Clone, Debug, PartialEq)]
213#[non_exhaustive]
214pub struct MetricTokens {
215    /// Height of an `Xs` control: 20px.
216    pub control_xs: Pixels,
217    /// Height of an `Sm` control: 24px.
218    pub control_sm: Pixels,
219    /// Height of a `Default` control: 28px, the common height in the reference app.
220    pub control_md: Pixels,
221    /// Height of an `Lg` control: 32px, the largest in the reference app.
222    pub control_lg: Pixels,
223    /// Side padding of an `Xs` control: 6px.
224    pub control_padding_xs: Pixels,
225    /// Side padding of an `Sm` control: 8px.
226    pub control_padding_sm: Pixels,
227    /// Side padding of a `Default` control: 10px.
228    pub control_padding_md: Pixels,
229    /// Side padding of an `Lg` control: 18px.
230    pub control_padding_lg: Pixels,
231    /// Height of a small list row: 28px.
232    pub row_sm: Pixels,
233    /// Height of a list row, a tooltip, a sidebar row: 30px.
234    pub row: Pixels,
235    /// Height of a large row with an avatar or two lines: 48px.
236    pub row_lg: Pixels,
237    /// Height of a title bar: 38px, measured from the tab reference app.
238    /// The sidebar reference app uses 46px.
239    pub title_bar: Pixels,
240    /// The gap between a title bar's top and the line its content centers
241    /// on: 2px, measured from the tab reference app, which keeps its bar
242    /// content level with the window controls below the window's rounded
243    /// top edge.
244    pub title_bar_content_offset: Pixels,
245    /// Where the macOS window controls sit: 14px from the top and left,
246    /// measured from the tab reference app, which centers them on the
247    /// title bar's content line.
248    pub window_controls_position: Pixels,
249    /// The room the macOS window controls take at a title bar's left edge,
250    /// including the gap after them: 88px.
251    pub window_controls_inset: Pixels,
252    /// The width of one Windows or Linux window control: 46px.
253    pub window_control_width: Pixels,
254    /// The width a sidebar opens at: 300px.
255    pub sidebar_width: Pixels,
256    /// The widths a sidebar can be dragged to: 220px to 480px.
257    pub sidebar_width_range: Range<Pixels>,
258    /// The width of a sidebar collapsed to icons: 48px.
259    pub icon_sidebar_width: Pixels,
260    /// The window width under which a sidebar floats over the content as
261    /// a sheet instead of sitting beside it: 768px, shadcn's mobile
262    /// breakpoint.
263    pub sidebar_sheet_breakpoint: Pixels,
264    /// The width of a sidebar shown as a sheet: 288px, shadcn's
265    /// `SIDEBAR_WIDTH_MOBILE` of 18rem.
266    pub sidebar_sheet_width: Pixels,
267    /// The pointer target of a resize handle, centered on the edge: 8px.
268    pub resize_handle: Pixels,
269    /// The spread of the keyboard focus ring outside a control: 3px.
270    pub focus_ring: Pixels,
271    /// The height of a single-line text field: 32px, measured from the
272    /// reference app's search and settings text fields at 2x, a step
273    /// taller than its 28px buttons and select triggers, so `control_lg`.
274    /// On touch it is `control_md`, the 44pt hit target.
275    pub field_height: Pixels,
276    /// The side padding of a single-line text field: 12px, measured from
277    /// the reference app's search field at 2x. A multi-line field keeps
278    /// `control_padding_md`, the 10px its settings textarea measures.
279    pub field_padding_x: Pixels,
280    /// The padding above and below the lines of a multi-line field: 8px,
281    /// measured from the reference app's settings textarea at 2x, the
282    /// same step as `control_padding_sm`.
283    pub field_padding_y: Pixels,
284    /// The width of a scrollbar's thumb at rest: 7px, the knob of a macOS
285    /// overlay scroller, which the reference app's web view shows.
286    pub scrollbar_thumb: Pixels,
287    /// The width of the thumb while the pointer is on the bar or drags
288    /// it: 11px, the expanded knob of a macOS overlay scroller.
289    pub scrollbar_thumb_active: Pixels,
290    /// The gap between the thumb and the edge of the scroll region: 2px.
291    pub scrollbar_inset: Pixels,
292    /// The width of a switch's track: 32px, measured from the reference
293    /// app at 2x. On touch it is the 51px of a UISwitch.
294    pub switch_track_width: Pixels,
295    /// The height of a switch's track: 20px from the reference app, 31px
296    /// of a UISwitch on touch.
297    pub switch_track_height: Pixels,
298    /// The diameter of a switch's thumb: 16px from the reference app, 27px
299    /// of a UISwitch on touch.
300    pub switch_thumb_size: Pixels,
301    /// The gap between the thumb and the track's edge: 2px on both. On
302    /// touch it is fixed with the track, so the thumb always fits.
303    pub switch_thumb_inset: Pixels,
304    /// The height of a slider's track: 4px, the thin rail of a macOS slider.
305    pub slider_track: Pixels,
306    /// The diameter of a slider's thumb: 16px, shadcn's `size-4`.
307    pub slider_thumb: Pixels,
308    /// The diameter of a tick on a slider with stops: 4px, as thick as the
309    /// track, so a dot on the rail reads as a stop and not a bump.
310    pub slider_tick: Pixels,
311    /// The diameter of a radio's ring: 16px, shadcn's `size-4`.
312    pub radio_size: Pixels,
313    /// The diameter of a radio's dot: 6px, three quarters of shadcn's
314    /// `size-2`, which sits better in the filled disc this radio paints.
315    pub radio_dot: Pixels,
316    /// The diameter of a progress ring: 16px, the context meter of the
317    /// composer's status tab. Not measured from the reference app.
318    pub ring_size: Pixels,
319    /// The stroke of a progress ring: 2px, the same source.
320    pub ring_stroke: Pixels,
321    /// The side of an attachment tile: 56px (composer design spec; not
322    /// measured from the reference app).
323    pub attachment_tile: Pixels,
324    /// The width of the model picker's panel: 361px, measured from the
325    /// reference app's picker at 2x (722px from edge to edge).
326    pub model_picker_width: Pixels,
327    /// The height of the model picker's panel: 346px, the same screenshot
328    /// (693px). The panel never changes height: a short result leaves room,
329    /// an empty one centers its message in the list, and a long one scrolls
330    /// inside it.
331    pub model_picker_height: Pixels,
332    /// The width of the provider rail at the picker's left: 44px, the same
333    /// screenshot (89px).
334    pub model_picker_rail: Pixels,
335    /// The height of a two-line model row: 47px, the same screenshot, where
336    /// rows are 94px apart.
337    pub model_row: Pixels,
338    /// The height of the picker's search row, from the panel's top edge to
339    /// the underline: 42px, the same screenshot (83px).
340    pub model_header: Pixels,
341    /// The height of a composer's status tab: 38px, measured from the reference
342    /// composer screenshots at 2x (76px from the top of the tab to the top
343    /// of the card).
344    pub status_tab_height: Pixels,
345    /// The gap between the items of a status tab: 28px, the same
346    /// screenshots (56px between two items).
347    pub status_tab_gap: Pixels,
348    /// The gap between a status item's icon and its text: 8px, the same
349    /// screenshots (15px, rounded up to the step).
350    pub status_label_gap: Pixels,
351    /// How far the status tab is inset from each side of the card: 14px,
352    /// the same screenshots (27px).
353    pub status_tab_inset: Pixels,
354    /// The gap between a select trigger and its menu: 2px, measured from
355    /// the reference app at 2x.
356    pub menu_gap: Pixels,
357    /// The narrowest a menu opens: 128px, shadcn's `min-w-[8rem]`. A menu
358    /// is never narrower than its trigger either.
359    pub menu_min_width: Pixels,
360    /// The narrowest a menu with a search field opens: 240px, the width
361    /// of the reference app's language list, so the field has room for
362    /// its placeholder.
363    pub menu_search_min_width: Pixels,
364    /// The widest a menu grows to fit its rows: 360px, the width the
365    /// reference app wraps a row's description at.
366    pub menu_max_width: Pixels,
367    /// The tallest a menu grows before it scrolls: 390px, the reference
368    /// app's language list.
369    pub menu_max_height: Pixels,
370    /// The width of a dialog: 480px. Measured from the reference app's
371    /// folder dialog at 2.2x, where the 32px field is 71px and the dialog
372    /// is 1040px wide (473px), rounded to shadcn's usual step.
373    pub dialog_width: Pixels,
374    /// The padding around a dialog's content: 20px. Measured from the same
375    /// screenshot (about 18.5px), rounded to the 20px step.
376    pub dialog_padding: Pixels,
377    /// The width of a command dialog: 512px, shadcn's `max-w-lg` (32rem).
378    /// This value comes from shadcn, not from the reference app.
379    pub command_dialog_width: Pixels,
380    /// How far a command dialog sits below the top of the window: 120px,
381    /// so it rests in the upper third as Spotlight does, and does not move
382    /// when its content changes height. This is gpui-cn's own choice: only
383    /// the width comes from shadcn, and neither comes from the reference
384    /// app.
385    pub command_dialog_top: Pixels,
386    /// The height of the folder picker's list: 6.5 rows, so the last row
387    /// peeks over the edge and shows that the list scrolls. Measured from
388    /// the reference app's folder dialog: the list is 181px tall at 2.2x
389    /// beside 28px rows.
390    pub folder_list_height: Pixels,
391    /// The gap between the top of a tab item and its surface: 4px,
392    /// measured from the tab reference app. On touch the tab fills its bar.
393    pub tab_surface_inset: Pixels,
394    /// The width a tab prefers: 160px, measured from the tab reference app.
395    pub tab_width: Pixels,
396    /// The width a tab shrinks to before the strip scrolls: 112px, measured
397    /// from the tab reference app.
398    pub tab_min_width: Pixels,
399    /// The curve at each bottom corner of the selected tab, where it merges
400    /// into the content below: 7px, measured from the tab reference app.
401    pub tab_shoulder: Pixels,
402    /// The radius of a tab surface's top corners: 7px, measured from the tab
403    /// reference app.
404    pub tab_radius: Pixels,
405    /// The gutter at an unselected tab's right edge that holds the separator:
406    /// 7px, measured from the tab reference app.
407    pub tab_separator_gutter: Pixels,
408    /// The inset of the 1px separator from the tab item's top and bottom:
409    /// 11px, measured from the tab reference app.
410    pub tab_separator_inset: Pixels,
411    /// The box of a tab's icon: 13px, measured from the tab reference app.
412    pub tab_icon: Pixels,
413    /// The gap between a tab's icon and its label: 6px, measured from the
414    /// tab reference app.
415    pub tab_content_gap: Pixels,
416    /// The slot that holds the new-tab and scroll controls of a tab strip:
417    /// the height of an `Sm` control, which the controls are. 24px at the
418    /// default font size, measured from the tab reference app.
419    pub tab_control: Pixels,
420    /// The box of a tab's close control: the height of an `Xs` control,
421    /// which the control is. 20px at the default font size, measured from
422    /// the tab reference app.
423    pub tab_close: Pixels,
424    /// The height of a tag: 20px, shadcn's badge `h-5`.
425    pub tag_height: Pixels,
426    /// The size of an icon in a tag: 12px, shadcn's badge `size-3`.
427    pub tag_icon: Pixels,
428    /// The height and least width of a badge count: 16px, gpui-kit's
429    /// medium badge.
430    pub badge_count: Pixels,
431    /// The text of a badge count: 10px, gpui-kit's badge text.
432    pub badge_count_text: Pixels,
433    /// How far a badge count sits past the corner of its element: 4px,
434    /// so it overlaps the corner as gpui-kit's does.
435    pub badge_count_offset: Pixels,
436    /// The diameter of a badge dot: 6px, gpui-kit's dot.
437    pub badge_dot: Pixels,
438    /// The circle of a badge icon: 16px, gpui-kit's medium badge.
439    pub badge_icon: Pixels,
440    /// The diameter of a `Sm` avatar: 24px, shadcn's `size-6`.
441    pub avatar_sm: Pixels,
442    /// The diameter of a `Default` avatar: 32px, shadcn's `size-8`.
443    pub avatar_md: Pixels,
444    /// The diameter of a `Lg` avatar: 40px, shadcn's `size-10`.
445    pub avatar_lg: Pixels,
446    /// The initials size in a `Sm` avatar: 10px, so two capitals fit the
447    /// 24px circle and stay clear of an overlapping neighbor.
448    pub avatar_initials_sm: Pixels,
449    /// The initials size in a `Default` avatar: 12px, shadcn's `text-xs`.
450    pub avatar_initials_md: Pixels,
451    /// The initials size in a `Lg` avatar: 14px, shadcn's `text-sm`.
452    pub avatar_initials_lg: Pixels,
453    /// How far each avatar in a group covers the one before it: 8px,
454    /// shadcn's `-space-x-2`.
455    pub avatar_group_overlap: Pixels,
456    /// The ring that parts overlapped avatars: 2px, shadcn's `ring-2`.
457    pub avatar_group_ring: Pixels,
458    /// The room around a terminal's grid: 2px, Ghostty's default
459    /// `window-padding-x` and `window-padding-y`, from
460    /// `src/config/Config.zig` and `ghostty +show-config --default`. The
461    /// room is painted in the terminal's background, as Ghostty's default
462    /// `window-padding-color = background` does. Fixed, because the grid
463    /// follows the code font size, not the UI font size.
464    pub terminal_padding: Pixels,
465    /// The width of a bar cursor and the height of an underline cursor in
466    /// a terminal: 2px, chosen to stay visible at every code font size.
467    pub terminal_cursor_thickness: Pixels,
468    /// The width of the grab handle at the top center of a dock pane:
469    /// 80px, `handleSize` in Ghostty's
470    /// `macos/Sources/Ghostty/Surface View/SurfaceGrabHandle.swift`.
471    pub dock_handle_width: Pixels,
472    /// The height of the grab handle: 12px, Ghostty's `handleSize`. On
473    /// touch it is the 44pt hit target.
474    pub dock_handle_height: Pixels,
475    /// The ellipsis in the grab handle: 12px, the handle's own height.
476    pub dock_handle_icon: Pixels,
477    /// The share of a pane's height, from its top edge, in which the
478    /// pointer reveals the grab handle: 20%, Ghostty's
479    /// `hoverHeightFactor`.
480    pub dock_handle_reveal: f32,
481    /// How far the pointer moves from a press on the grab handle before
482    /// the pane lifts: 6px, the drag threshold of Trellis (`Tv=6` in its
483    /// bundle).
484    pub dock_drag_threshold: Pixels,
485    /// The floating card a lifted pane becomes, clamped to the pane:
486    /// 380x260, the compact drag size of Trellis (`ou={w:380,h:260}` in
487    /// its bundle).
488    pub dock_card: Size<Pixels>,
489}
490
491impl MetricTokens {
492    /// The metrics at `ui_font_size`. The rem-scale sizes are scaled from
493    /// the 16px defaults. With `touch`, controls and rows grow to the 44pt
494    /// minimum hit target of Apple's Human Interface Guidelines, the
495    /// default control a step past it, and the title bar takes the 44pt
496    /// height of an iOS navigation bar.
497    fn derive(ui_font_size: Pixels, touch: bool) -> Self {
498        let factor = f32::from(ui_font_size) / BASE_FONT_SIZE;
499        let scaled = |value: f32| px(value * factor);
500        let (control_xs, control_sm, control_md, control_lg) = if touch {
501            (px(36.), px(40.), px(44.), px(48.))
502        } else {
503            (scaled(20.), scaled(24.), scaled(28.), scaled(32.))
504        };
505        let (row_sm, row, row_lg) = if touch {
506            (px(40.), px(44.), px(56.))
507        } else {
508            (scaled(28.), scaled(30.), scaled(48.))
509        };
510        let title_bar = px(if touch { 44. } else { 38. });
511        let control_padding_sm = scaled(8.);
512        Self {
513            control_xs,
514            control_sm,
515            control_md,
516            control_lg,
517            control_padding_xs: scaled(6.),
518            control_padding_sm,
519            control_padding_md: scaled(if touch { 14. } else { 10. }),
520            control_padding_lg: scaled(18.),
521            row_sm,
522            row,
523            row_lg,
524            title_bar,
525            title_bar_content_offset: px(if touch { 0. } else { 2. }),
526            window_controls_position: px(14.),
527            window_controls_inset: px(88.),
528            window_control_width: px(46.),
529            sidebar_width: px(300.),
530            sidebar_width_range: px(220.)..px(480.),
531            icon_sidebar_width: px(48.),
532            sidebar_sheet_breakpoint: px(768.),
533            sidebar_sheet_width: px(288.),
534            resize_handle: px(8.),
535            focus_ring: px(3.),
536            field_height: if touch { control_md } else { control_lg },
537            field_padding_x: scaled(12.),
538            field_padding_y: control_padding_sm,
539            scrollbar_thumb: px(7.),
540            scrollbar_thumb_active: px(11.),
541            scrollbar_inset: px(2.),
542            switch_track_width: if touch { px(51.) } else { scaled(32.) },
543            switch_track_height: if touch { px(31.) } else { scaled(20.) },
544            switch_thumb_size: if touch { px(27.) } else { scaled(16.) },
545            switch_thumb_inset: if touch { px(2.) } else { scaled(2.) },
546            slider_track: scaled(4.),
547            slider_thumb: scaled(16.),
548            slider_tick: scaled(4.),
549            radio_size: scaled(16.),
550            radio_dot: scaled(6.),
551            ring_size: scaled(16.),
552            ring_stroke: scaled(2.),
553            attachment_tile: scaled(56.),
554            model_picker_width: scaled(361.),
555            model_picker_height: scaled(346.),
556            model_picker_rail: scaled(44.),
557            model_row: scaled(47.),
558            model_header: scaled(42.),
559            status_tab_height: scaled(38.),
560            status_tab_inset: scaled(14.),
561            status_tab_gap: scaled(28.),
562            status_label_gap: scaled(8.),
563            menu_gap: scaled(2.),
564            menu_min_width: scaled(128.),
565            menu_search_min_width: scaled(240.),
566            menu_max_width: scaled(360.),
567            menu_max_height: scaled(390.),
568            dialog_width: scaled(480.),
569            dialog_padding: scaled(20.),
570            command_dialog_width: scaled(512.),
571            command_dialog_top: scaled(120.),
572            folder_list_height: row_sm * 6.5,
573            tab_surface_inset: px(if touch { 0. } else { 4. }),
574            tab_width: px(160.),
575            tab_min_width: px(112.),
576            tab_shoulder: px(7.),
577            tab_radius: px(7.),
578            tab_separator_gutter: px(7.),
579            tab_separator_inset: px(11.),
580            tab_icon: px(13.),
581            tab_content_gap: px(6.),
582            tab_control: control_sm,
583            tab_close: control_xs,
584            tag_height: scaled(20.),
585            tag_icon: scaled(12.),
586            badge_count: scaled(16.),
587            badge_count_text: scaled(10.),
588            badge_count_offset: scaled(4.),
589            badge_dot: scaled(6.),
590            badge_icon: scaled(16.),
591            avatar_sm: scaled(24.),
592            avatar_md: scaled(32.),
593            avatar_lg: scaled(40.),
594            avatar_initials_sm: scaled(10.),
595            avatar_initials_md: scaled(12.),
596            avatar_initials_lg: scaled(14.),
597            avatar_group_overlap: scaled(8.),
598            avatar_group_ring: px(2.),
599            terminal_padding: px(2.),
600            terminal_cursor_thickness: px(2.),
601            dock_handle_width: px(80.),
602            dock_handle_height: if touch { control_md } else { px(12.) },
603            dock_handle_icon: px(12.),
604            dock_handle_reveal: 0.2,
605            dock_drag_threshold: px(6.),
606            dock_card: Size::new(px(380.), px(260.)),
607        }
608    }
609}
610
611/// Sizes that do not come from the config but still shape the tokens.
612#[derive(Clone, Copy, Debug, PartialEq)]
613pub(super) struct Metrics {
614    pub radius: Pixels,
615    pub ui_font_size: Pixels,
616    pub code_font_size: Pixels,
617    /// Whether a finger is the pointer.
618    pub touch: bool,
619}
620
621impl ThemeTokens {
622    /// Derives the full token set from a config.
623    ///
624    /// `contrast` is read as a factor around 50: at 50 the ratios match the
625    /// shadcn neutral palette. The built-in 45 (light) and 60 (dark) sit
626    /// close to it. A dark surface gets a slightly lifted card and popover because a
627    /// hairline alone does not separate them from the window.
628    pub(super) fn derive(
629        config: &ThemeConfig,
630        appearance: ThemeAppearance,
631        metrics: Metrics,
632    ) -> Self {
633        let dark = appearance == ThemeAppearance::Dark;
634        let c = f32::from(config.contrast) / 50.;
635        let surface = config.surface;
636        let ink = config.ink;
637        // OKLab steps vanish near black: 7% from #000 toward white is
638        // #010101. The steps start from a surface at least as light as the
639        // reference app's #181818, so a black surface keeps visible
640        // separators and fills. The surface itself stays the background.
641        let floor = super::color::lightness(super::color::hex("#181818"));
642        let base = {
643            let (from, to) = (
644                super::color::lightness(surface),
645                super::color::lightness(ink),
646            );
647            if dark && from < floor && to > floor {
648                mix(surface, ink, (floor - from) / (to - from))
649            } else {
650                surface
651            }
652        };
653        let toward_ink = |amount: f32| mix(base, ink, amount);
654
655        // Every fill is one OKLab step from the surface toward the ink,
656        // scaled by contrast. The constants reproduce the values sampled
657        // from the reference app at its own contrast settings (45
658        // light, 60 dark). Separators and control borders keep one
659        // distance in both appearances. On a dark surface the text roles
660        // step a little toward the surface, as the reference app does
661        // (its default text is #dfdfdf on #181818), and the elevated
662        // surfaces lift because a hairline alone does not separate them.
663        let soft = toward_ink(0.044 * c);
664        let selected = toward_ink(if dark { 0.0867 } else { 0.10 } * c);
665        let border = toward_ink(0.07);
666        let input = toward_ink(0.10);
667        let foreground = if dark { mix(ink, surface, 0.12) } else { ink };
668        let muted_foreground = mix(ink, surface, if dark { 0.412 } else { 0.38 });
669        let card = if dark {
670            toward_ink(0.0475 * c)
671        } else {
672            surface
673        };
674        // The reference app paints links #91c2fa on dark, paler than its
675        // #539af8 accent, and the accent itself on light.
676        let link = if dark {
677            mix(config.accent, ink, 0.35)
678        } else {
679            config.accent
680        };
681        // The tooltip in the reference app is #1b1b1b with white text on the dark
682        // surface: a step so small it reads as the surface itself.
683        let (tooltip, tooltip_foreground) = if dark {
684            (toward_ink(0.016), ink)
685        } else {
686            (ink, surface)
687        };
688        // The reference app draws its primary button a step short of the ink,
689        // with the label a step short of the surface (#dfdfdf on #2d2d2d).
690        let primary = mix(ink, surface, 0.12);
691        let primary_foreground = mix(surface, ink, 0.11);
692        // The sidebar in the reference app is #222222 beside #181818 on dark, the same
693        // step as the soft fill, and #fdfdfd beside #ffffff on light. Its
694        // rows and hairline step from the sidebar, not from the window, so
695        // they stay visible on the lifted surface.
696        let sidebar = if dark { soft } else { toward_ink(0.008) };
697        let sidebar_step = |amount: f32| mix(sidebar, ink, amount);
698        let sidebar_accent = sidebar_step(if dark { 0.055 } else { 0.025 });
699        let sidebar_selected = sidebar_step(if dark { 0.095 } else { 0.05 });
700        let sidebar_border = sidebar_step(if dark { 0.10 } else { 0.07 });
701        let sidebar_muted_foreground = mix(ink, sidebar, if dark { 0.59 } else { 0.665 });
702        let destructive_foreground = readable_on(config.semantic.destructive, surface, ink);
703        // The reference app's switch: the accent when on, and when off a
704        // gray a few steps from the surface. The thumb is white on both.
705        let switch_track_on = config.accent;
706        let switch_track_off = toward_ink(0.20);
707        let switch_thumb = lighter_of(surface, ink);
708        let progress_track = primary.opacity(0.2);
709        // A status fill is a step toward its color, as `selected` is a step
710        // toward the ink. The constants give the 20% (dark) and 10%
711        // (light) tints of the destructive button at the built-in
712        // contrasts, so a tag and a button of one color read as one.
713        let solid_foreground = lighter_of(surface, ink);
714        let darker = if solid_foreground == surface {
715            ink
716        } else {
717            surface
718        };
719        let solid = |color: Hsla| {
720            (0..=20)
721                .map(|step| mix(color, darker, step as f32 * 0.04))
722                .find(|fill| super::color::contrast_ratio(*fill, solid_foreground) >= 4.5)
723                .unwrap_or(darker)
724        };
725        let tint = |color: Hsla| mix(surface, color, if dark { 0.1667 } else { 0.111 } * c);
726        let popover = if dark { toward_ink(0.109) } else { surface };
727        let popover_step = |amount: f32| mix(popover, ink, amount);
728        let popover_border = if dark { popover_step(0.13) } else { border };
729        let popover_accent = popover_step(if dark { 0.09 } else { 0.033 });
730        let popover_separator = if dark { popover_step(0.095) } else { border };
731        let popover_muted_foreground = mix(ink, popover, if dark { 0.32 } else { 0.38 });
732        let select_indicator = mix(ink, popover, if dark { 0.227 } else { 0.38 });
733        let select_trigger = if dark { toward_ink(0.09) } else { surface };
734        let select_trigger_border = if dark { toward_ink(0.178) } else { input };
735        let tab_separator = muted_foreground.opacity(0.6);
736        let field = if dark { toward_ink(0.104) } else { surface };
737        let field_border = if dark { toward_ink(0.181) } else { input };
738        let field_focus_border = if dark {
739            mix(field, link, 0.78)
740        } else {
741            config.accent
742        };
743
744        let colors = ColorTokens {
745            background: surface,
746            foreground,
747            surface: card,
748            surface_foreground: foreground,
749            primary,
750            primary_foreground,
751            secondary: soft,
752            secondary_foreground: foreground,
753            muted: soft,
754            muted_foreground,
755            accent: soft,
756            accent_foreground: foreground,
757            destructive: config.semantic.destructive,
758            destructive_foreground,
759            border,
760            input,
761            ring: config.accent,
762            selection: config.accent.alpha(0.3),
763        };
764
765        let typography = typography(config, metrics);
766        let factor = f32::from(metrics.ui_font_size) / BASE_FONT_SIZE;
767        // On touch the text takes the sizes of Apple's Human Interface
768        // Guidelines: body 17pt for controls and rows, headline 17pt
769        // semibold, and title 1 at 28pt.
770        let text_control = TextStyleToken {
771            size: px(if metrics.touch { 17. } else { 13. } * factor),
772            line_height: px(if metrics.touch { 22. } else { 16. } * factor),
773            weight: FontWeight::NORMAL,
774        };
775        let text_textarea = TextStyleToken {
776            line_height: px(if metrics.touch { 22. } else { 18.5 } * factor),
777            ..text_control
778        };
779        let text_heading = TextStyleToken {
780            size: px(if metrics.touch { 17. } else { 15. } * factor),
781            line_height: px(22. * factor),
782            weight: if metrics.touch {
783                FontWeight::SEMIBOLD
784            } else {
785                FontWeight::MEDIUM
786            },
787        };
788        let text_title = TextStyleToken {
789            size: px(if metrics.touch { 28. } else { 24. } * factor),
790            line_height: px(if metrics.touch { 34. } else { 32. } * factor),
791            weight: FontWeight::SEMIBOLD,
792        };
793        let shadow = ShadowTokens::elevations(if dark {
794            gpui_kit::black().alpha(0.4)
795        } else {
796            ink.alpha(0.1)
797        });
798
799        Self {
800            appearance,
801            touch: metrics.touch,
802            base: SemanticThemeTokens {
803                colors,
804                radius: radius_scale(metrics.radius),
805                spacing: SpacingTokens::default(),
806                typography,
807                shadow,
808            },
809            selected,
810            link,
811            tooltip,
812            tooltip_foreground,
813            scrim: gpui_kit::hsla(0., 0., 0., 0.5),
814            dialog_overlay: gpui_kit::hsla(0., 0., 0., 0.25),
815            sidebar,
816            sidebar_border,
817            sidebar_accent,
818            sidebar_selected,
819            sidebar_muted_foreground,
820            text_control,
821            text_heading,
822            text_title,
823            metrics: MetricTokens::derive(metrics.ui_font_size, metrics.touch),
824            disabled_opacity: 0.55,
825            terminal_cursor_opacity: 0.55,
826            switch_track_on,
827            switch_track_off,
828            switch_thumb,
829            success: config.semantic.success,
830            warning: config.semantic.warning,
831            info: config.accent,
832            destructive_solid: solid(config.semantic.destructive),
833            success_solid: solid(config.semantic.success),
834            warning_solid: solid(config.semantic.warning),
835            info_solid: solid(config.accent),
836            solid_foreground,
837            destructive_tint: tint(config.semantic.destructive),
838            success_tint: tint(config.semantic.success),
839            warning_tint: tint(config.semantic.warning),
840            info_tint: tint(config.accent),
841            progress_track,
842            popover,
843            popover_foreground: ink,
844            popover_border,
845            popover_accent,
846            popover_separator,
847            popover_muted_foreground,
848            select_indicator,
849            select_trigger,
850            select_trigger_border,
851            tab_separator,
852            field,
853            field_border,
854            field_focus_border,
855            text_textarea,
856            dock_drop: config.accent.opacity(0.3),
857            dock_handle: foreground.opacity(0.3),
858            dock_handle_hover: foreground.opacity(0.8),
859            dock_unfocused: surface.opacity(0.3),
860        }
861    }
862
863    /// Whether the tokens are the dark set.
864    pub fn is_dark(&self) -> bool {
865        self.appearance == ThemeAppearance::Dark
866    }
867
868    /// The color tokens shared with base.
869    pub fn colors(&self) -> &ColorTokens {
870        &self.base.colors
871    }
872
873    /// The window background.
874    pub fn background(&self) -> Hsla {
875        self.base.colors.background
876    }
877
878    /// Default text.
879    pub fn foreground(&self) -> Hsla {
880        self.base.colors.foreground
881    }
882
883    /// High-emphasis actions: the default button.
884    pub fn primary(&self) -> Hsla {
885        self.base.colors.primary
886    }
887
888    /// Text on a primary surface.
889    pub fn primary_foreground(&self) -> Hsla {
890        self.base.colors.primary_foreground
891    }
892
893    /// Lower-emphasis filled actions and supporting surfaces.
894    pub fn secondary(&self) -> Hsla {
895        self.base.colors.secondary
896    }
897
898    /// Text on a secondary surface.
899    pub fn secondary_foreground(&self) -> Hsla {
900        self.base.colors.secondary_foreground
901    }
902
903    /// Subtle surfaces.
904    pub fn muted(&self) -> Hsla {
905        self.base.colors.muted
906    }
907
908    /// Descriptions, placeholders, helper text.
909    pub fn muted_foreground(&self) -> Hsla {
910        self.base.colors.muted_foreground
911    }
912
913    /// Hover, focus, and active surfaces.
914    pub fn accent(&self) -> Hsla {
915        self.base.colors.accent
916    }
917
918    /// Text on an accent surface.
919    pub fn accent_foreground(&self) -> Hsla {
920        self.base.colors.accent_foreground
921    }
922
923    /// Destructive actions and error emphasis.
924    pub fn destructive(&self) -> Hsla {
925        self.base.colors.destructive
926    }
927
928    /// Text on a destructive surface.
929    pub fn destructive_foreground(&self) -> Hsla {
930        self.base.colors.destructive_foreground
931    }
932
933    /// Default borders and separators.
934    pub fn border(&self) -> Hsla {
935        self.base.colors.border
936    }
937
938    /// Form control borders.
939    pub fn input(&self) -> Hsla {
940        self.base.colors.input
941    }
942
943    /// Focus rings.
944    pub fn ring(&self) -> Hsla {
945        self.base.colors.ring
946    }
947
948    /// The keyboard focus ring a control paints outside its box: the ring
949    /// color at half strength, shadcn's `ring/50`.
950    pub fn focus_ring(&self) -> Hsla {
951        self.ring().opacity(0.5)
952    }
953
954    /// Selected text background.
955    pub fn selection(&self) -> Hsla {
956        self.base.colors.selection
957    }
958
959    /// Interface font family.
960    pub fn font_family(&self) -> &SharedString {
961        &self.base.typography.sans
962    }
963
964    /// Code font family.
965    pub fn mono_font_family(&self) -> &SharedString {
966        &self.base.typography.mono
967    }
968
969    /// Radius for small controls: badges, checkboxes, menu items.
970    pub fn radius_sm(&self) -> Pixels {
971        self.base.radius.sm
972    }
973
974    /// Radius for buttons, inputs, and menu rows.
975    pub fn radius_md(&self) -> Pixels {
976        self.base.radius.md
977    }
978
979    /// The base radius: cards, popovers, dialogs.
980    pub fn radius_lg(&self) -> Pixels {
981        self.base.radius.lg
982    }
983
984    /// Radius for large surfaces: sheets and window panels.
985    pub fn radius_xl(&self) -> Pixels {
986        self.base.radius.xl
987    }
988
989    /// As round as the shape allows: circles and pills.
990    pub fn radius_full(&self) -> Pixels {
991        self.base.radius.full
992    }
993}
994
995/// The shadcn radius scale from one base value.
996fn radius_scale(radius: Pixels) -> RadiusTokens {
997    RadiusTokens {
998        none: px(0.),
999        sm: radius * 0.6,
1000        md: radius * 0.8,
1001        lg: radius,
1002        xl: radius * 1.4,
1003        full: px(9999.),
1004    }
1005}
1006
1007/// Base's typography scale, moved to the configured sizes and families.
1008fn typography(config: &ThemeConfig, metrics: Metrics) -> TypographyTokens {
1009    let defaults = TypographyTokens::default();
1010    let factor = f32::from(metrics.ui_font_size) / BASE_FONT_SIZE;
1011    let scale = |token: TextStyleToken| TextStyleToken {
1012        size: token.size * factor,
1013        line_height: token.line_height * factor,
1014        weight: token.weight,
1015    };
1016    let code_factor = f32::from(metrics.code_font_size) / f32::from(defaults.mono_md.size);
1017    TypographyTokens {
1018        sans: config.fonts.ui.clone().unwrap_or(defaults.sans),
1019        mono: config.fonts.code.clone().unwrap_or(defaults.mono),
1020        xs: scale(defaults.xs),
1021        sm: scale(defaults.sm),
1022        md: scale(defaults.md),
1023        lg: scale(defaults.lg),
1024        xl: scale(defaults.xl),
1025        mono_md: TextStyleToken {
1026            size: metrics.code_font_size,
1027            line_height: defaults.mono_md.line_height * code_factor,
1028            weight: defaults.mono_md.weight,
1029        },
1030    }
1031}
1032
1033/// The lighter of two colors.
1034fn lighter_of(a: Hsla, b: Hsla) -> Hsla {
1035    use super::color::lightness;
1036    if lightness(a) >= lightness(b) { a } else { b }
1037}
1038
1039/// Picks `surface` or `ink` as text on a colored `background`.
1040///
1041/// Light text on a saturated mid-tone (the accent blue, a shadcn red) is the
1042/// convention even where a contrast formula would pick dark text, so the
1043/// lighter of the two is used unless the background is itself light.
1044fn readable_on(background: Hsla, surface: Hsla, ink: Hsla) -> Hsla {
1045    use super::color::lightness;
1046    const LIGHT_BACKGROUND: f32 = 0.72;
1047    let lighter = lighter_of(surface, ink);
1048    let darker = if lighter == surface { ink } else { surface };
1049    if lightness(background) > LIGHT_BACKGROUND {
1050        darker
1051    } else {
1052        lighter
1053    }
1054}
1055
1056#[cfg(test)]
1057mod tests {
1058    use super::*;
1059    use crate::theme::color::{hex, lightness, to_hex};
1060
1061    fn metrics() -> Metrics {
1062        Metrics {
1063            radius: px(10.),
1064            ui_font_size: px(16.),
1065            code_font_size: px(13.),
1066            touch: false,
1067        }
1068    }
1069
1070    fn light() -> ThemeTokens {
1071        ThemeTokens::derive(&ThemeConfig::light(), ThemeAppearance::Light, metrics())
1072    }
1073
1074    #[test]
1075    fn a_black_surface_keeps_visible_separators() {
1076        let mut config = ThemeConfig::dark();
1077        config.surface = super::super::color::hex("#000000");
1078        let tokens = ThemeTokens::derive(&config, ThemeAppearance::Dark, metrics());
1079        let lightness = super::super::color::lightness;
1080        assert_eq!(tokens.background(), config.surface);
1081        assert!(
1082            lightness(tokens.border()) > lightness(super::super::color::hex("#181818")),
1083            "border {}",
1084            super::super::color::to_hex(tokens.border())
1085        );
1086    }
1087
1088    #[test]
1089    fn touch_grows_controls_to_the_hig_hit_target_and_text_to_body_size() {
1090        let touch = ThemeTokens::derive(
1091            &ThemeConfig::light(),
1092            ThemeAppearance::Light,
1093            Metrics {
1094                touch: true,
1095                ..metrics()
1096            },
1097        );
1098        assert!(touch.touch);
1099        assert_eq!(touch.metrics.control_md, px(44.), "the HIG minimum");
1100        assert_eq!(touch.metrics.row, px(44.));
1101        assert_eq!(touch.metrics.control_xs, px(36.));
1102        assert_eq!(touch.metrics.title_bar, px(44.), "an iOS navigation bar");
1103        assert_eq!(touch.text_control.size, px(17.), "HIG body");
1104        assert_eq!(touch.text_title.size, px(28.), "HIG title 1");
1105        // The chrome that is not touched stays.
1106        assert_eq!(touch.metrics.sidebar_sheet_width, px(288.));
1107        // The switch is a UISwitch, fixed like the chrome, so the thumb
1108        // fits at every font size.
1109        assert_eq!(touch.metrics.switch_track_width, px(51.));
1110        assert_eq!(touch.metrics.switch_track_height, px(31.));
1111        assert_eq!(touch.metrics.switch_thumb_size, px(27.));
1112        assert_eq!(touch.metrics.switch_thumb_inset, px(2.));
1113        let large_touch = ThemeTokens::derive(
1114            &ThemeConfig::light(),
1115            ThemeAppearance::Light,
1116            Metrics {
1117                touch: true,
1118                ui_font_size: px(20.),
1119                ..metrics()
1120            },
1121        );
1122        assert_eq!(large_touch.metrics.switch_thumb_inset, px(2.));
1123        let mouse = light();
1124        assert!(!mouse.touch);
1125        assert_eq!(mouse.metrics.control_md, px(28.));
1126        assert_eq!(mouse.metrics.row, px(30.));
1127        assert_eq!(mouse.text_control.size, px(13.));
1128    }
1129
1130    fn dark() -> ThemeTokens {
1131        ThemeTokens::derive(&ThemeConfig::dark(), ThemeAppearance::Dark, metrics())
1132    }
1133
1134    #[test]
1135    fn surfaces_step_away_from_the_background_in_both_appearances() {
1136        let light = light();
1137        let bg = lightness(light.background());
1138        assert!(lightness(light.secondary()) < bg);
1139        assert!(lightness(light.border()) < lightness(light.secondary()));
1140        assert!(lightness(light.input()) < lightness(light.border()));
1141        assert!(lightness(light.selected) < lightness(light.secondary()));
1142        assert_eq!(
1143            light.base.colors.surface,
1144            light.background(),
1145            "light cards sit flat"
1146        );
1147        assert!(lightness(light.muted_foreground()) > lightness(light.foreground()));
1148
1149        let dark = dark();
1150        let bg = lightness(dark.background());
1151        assert!(lightness(dark.secondary()) > bg);
1152        assert!(lightness(dark.border()) > bg);
1153        assert!(lightness(dark.input()) > lightness(dark.border()));
1154        assert!(
1155            lightness(dark.base.colors.surface) > bg,
1156            "dark cards are lifted"
1157        );
1158        assert!(lightness(dark.foreground()) < lightness(dark.primary()) + 0.001);
1159        assert!(lightness(dark.muted_foreground()) < lightness(dark.foreground()));
1160    }
1161
1162    #[test]
1163    fn tokens_reproduce_the_reference_values() {
1164        // Sampled from the reference app at contrast 45 (light) and
1165        // 60 (dark).
1166        let light = light();
1167        assert_eq!(to_hex(light.secondary()), "#f5f5f5");
1168        assert_eq!(to_hex(light.selected), "#e8e8e8");
1169        assert_eq!(to_hex(light.border()), "#ededed");
1170        assert_eq!(to_hex(light.input()), "#e5e5e6");
1171        assert_eq!(to_hex(light.muted_foreground()), "#67696b");
1172        let dark = dark();
1173        assert_eq!(to_hex(dark.secondary()), "#222222");
1174        assert_eq!(to_hex(dark.base.colors.surface), "#232323");
1175        assert_eq!(to_hex(dark.selected), "#2c2c2c");
1176        assert_eq!(to_hex(dark.border()), "#252525");
1177        assert_eq!(to_hex(dark.foreground()), "#dfdfdf");
1178        assert_eq!(to_hex(dark.muted_foreground()), "#969696");
1179        assert_eq!(to_hex(dark.primary()), "#dfdfdf");
1180        assert_eq!(to_hex(dark.primary_foreground()), "#2d2d2d");
1181        assert_eq!(to_hex(dark.tooltip), "#1b1b1b");
1182        assert_eq!(to_hex(dark.tooltip_foreground), "#ffffff");
1183        assert_eq!(to_hex(light.tooltip), "#1a1c1f");
1184        assert_eq!(to_hex(light.foreground()), "#1a1c1f");
1185    }
1186
1187    #[test]
1188    fn sidebar_tokens_reproduce_the_reference_values() {
1189        // Sampled from the reference app's sidebar and the hairline
1190        // between it and the content.
1191        let dark = dark();
1192        assert_eq!(to_hex(dark.sidebar), "#222222");
1193        assert_eq!(to_hex(dark.sidebar_selected), "#333333");
1194        assert_eq!(to_hex(dark.sidebar_accent), "#2c2c2c");
1195        assert!(lightness(dark.sidebar_accent) > lightness(dark.sidebar));
1196        assert!(lightness(dark.sidebar_accent) < lightness(dark.sidebar_selected));
1197        assert_eq!(to_hex(dark.sidebar_border), "#343434");
1198        assert_eq!(to_hex(dark.sidebar_muted_foreground), "#747474");
1199        let light = light();
1200        assert_eq!(to_hex(light.sidebar), "#fdfdfd");
1201        assert_eq!(to_hex(light.sidebar_selected), "#f0f0f0");
1202        assert_eq!(to_hex(light.sidebar_accent), "#f6f6f7");
1203        assert!(lightness(light.sidebar_accent) < lightness(light.sidebar));
1204        assert!(lightness(light.sidebar_accent) > lightness(light.sidebar_selected));
1205        assert_eq!(to_hex(light.sidebar_border), "#ebebeb");
1206        // The sample is antialiased 13px text; one step off is inside its noise.
1207        assert_eq!(to_hex(light.sidebar_muted_foreground), "#a9aaab");
1208    }
1209
1210    #[test]
1211    fn contrast_scales_the_distance() {
1212        let mut low = ThemeConfig::light();
1213        low.contrast = 20;
1214        let mut high = ThemeConfig::light();
1215        high.contrast = 80;
1216        let low = ThemeTokens::derive(&low, ThemeAppearance::Light, metrics());
1217        let high = ThemeTokens::derive(&high, ThemeAppearance::Light, metrics());
1218        assert!(lightness(low.secondary()) > lightness(high.secondary()));
1219        assert!(lightness(low.selected) > lightness(high.selected));
1220        assert_eq!(
1221            low.border(),
1222            high.border(),
1223            "separators do not move with contrast"
1224        );
1225    }
1226
1227    #[test]
1228    fn status_tints_step_toward_their_color() {
1229        let dark = ThemeTokens::derive(&ThemeConfig::dark(), ThemeAppearance::Dark, metrics());
1230        let light = ThemeTokens::derive(&ThemeConfig::light(), ThemeAppearance::Light, metrics());
1231        assert_eq!(dark.info, hex("#539af8"), "info is the accent");
1232        for theme in [&dark, &light] {
1233            for solid in [
1234                theme.destructive_solid,
1235                theme.success_solid,
1236                theme.warning_solid,
1237                theme.info_solid,
1238            ] {
1239                let ratio = crate::theme::contrast_ratio(solid, theme.solid_foreground);
1240                assert!(ratio >= 4.5, "{} is {ratio}", to_hex(solid));
1241            }
1242        }
1243        assert_eq!(to_hex(dark.destructive_solid), "#d23f39");
1244        assert_eq!(to_hex(dark.success_tint), "#25372a");
1245        assert_eq!(to_hex(dark.destructive_tint), "#412421");
1246        assert_eq!(to_hex(light.success_tint), "#ebf6ec");
1247        assert_eq!(to_hex(light.destructive_tint), "#fbebe8");
1248    }
1249
1250    #[test]
1251    fn the_progress_track_is_the_primary_at_a_fifth() {
1252        let dark = dark();
1253        assert_eq!(to_hex(dark.primary()), "#dfdfdf");
1254        assert_eq!(to_hex(dark.progress_track), "#dfdfdf33");
1255        let light = light();
1256        assert_eq!(to_hex(light.progress_track), "#31333533");
1257    }
1258
1259    #[test]
1260    fn switch_colors_reproduce_the_reference_values() {
1261        // The on track and the thumb are sampled from the reference app's
1262        // settings toggles at 2x on dark.
1263        let dark = dark();
1264        assert_eq!(to_hex(dark.switch_track_on), "#539af8");
1265        assert_eq!(to_hex(dark.switch_thumb), "#ffffff");
1266        assert_eq!(dark.switch_track_on, dark.ring());
1267        assert!(lightness(dark.switch_track_off) > lightness(dark.background()));
1268        assert!(lightness(dark.switch_track_off) < lightness(dark.switch_thumb));
1269        let light = light();
1270        assert_eq!(to_hex(light.switch_thumb), "#ffffff");
1271        assert_eq!(light.switch_track_on, light.ring());
1272        assert!(lightness(light.switch_track_off) < lightness(light.background()));
1273        assert_eq!(to_hex(light.focus_ring()), "#339cff80");
1274        assert_eq!(light.focus_ring().a, 0.5);
1275    }
1276
1277    #[test]
1278    fn menu_colors_reproduce_the_reference_values() {
1279        let dark = dark();
1280        assert_eq!(to_hex(dark.popover), "#2d2d2d");
1281        assert_eq!(to_hex(dark.popover_border), "#444444");
1282        assert_eq!(to_hex(dark.popover_accent), "#3d3d3d");
1283        assert_eq!(to_hex(dark.popover_separator), "#3e3e3e");
1284        assert_eq!(to_hex(dark.popover_foreground), "#ffffff");
1285        assert_eq!(to_hex(dark.popover_muted_foreground), "#b5b5b5");
1286        assert_eq!(to_hex(dark.select_indicator), "#cacaca");
1287        assert_eq!(to_hex(dark.select_trigger), "#292929");
1288        assert_eq!(to_hex(dark.select_trigger_border), "#3a3a3a");
1289        assert!(lightness(dark.popover) > lightness(dark.base.colors.surface));
1290        let light = light();
1291        assert_eq!(light.popover, light.background());
1292        assert_eq!(light.popover_border, light.border());
1293        assert_eq!(light.popover_separator, light.border());
1294        assert_eq!(light.popover_foreground, light.foreground());
1295        assert_eq!(light.popover_muted_foreground, light.muted_foreground());
1296        assert_eq!(light.select_trigger, light.background());
1297        assert_eq!(light.select_trigger_border, light.input());
1298        assert_eq!(to_hex(light.popover_accent), "#f6f6f7", "a hovered row");
1299        assert!(lightness(light.popover_accent) < lightness(light.popover));
1300    }
1301
1302    #[test]
1303    fn menu_metrics_scale_with_the_font_size() {
1304        let default = light();
1305        assert_eq!(default.metrics.menu_gap, px(2.));
1306        assert_eq!(default.metrics.menu_min_width, px(128.));
1307        assert_eq!(default.metrics.menu_search_min_width, px(240.));
1308        assert_eq!(default.metrics.menu_max_width, px(360.));
1309        assert_eq!(default.metrics.menu_max_height, px(390.));
1310        let large = ThemeTokens::derive(
1311            &ThemeConfig::light(),
1312            ThemeAppearance::Light,
1313            Metrics {
1314                radius: px(10.),
1315                ui_font_size: px(20.),
1316                code_font_size: px(15.),
1317                touch: false,
1318            },
1319        );
1320        assert_eq!(large.metrics.menu_max_height, px(487.5));
1321        assert_eq!(large.metrics.menu_gap, px(2.5));
1322    }
1323
1324    #[test]
1325    fn dialog_tokens_reproduce_the_reference_values() {
1326        // The backdrop is #121212 over the #181818 dark window: black at
1327        // 25% alpha.
1328        let dark = dark();
1329        assert_eq!(to_hex(dark.dialog_overlay), "#00000040");
1330        assert_eq!(to_hex(dark.background()), "#181818");
1331        let over = (f32::from(0x18u8) * (1. - dark.dialog_overlay.a)).round() as u8;
1332        assert_eq!(over, 0x12);
1333        assert_eq!(to_hex(light().dialog_overlay), "#00000040");
1334        let default = light();
1335        assert_eq!(default.metrics.dialog_width, px(480.));
1336        assert_eq!(default.metrics.dialog_padding, px(20.));
1337        assert_eq!(default.metrics.command_dialog_width, px(512.));
1338        assert_eq!(default.metrics.command_dialog_top, px(120.));
1339        assert_eq!(default.metrics.folder_list_height, px(182.));
1340    }
1341
1342    #[test]
1343    fn field_colors_reproduce_the_reference_values() {
1344        // The fill is sampled from the reference app's settings textarea
1345        // and the rest hairline from its select trigger, both at 2x on the
1346        // dark window surface.
1347        let dark = dark();
1348        assert_eq!(to_hex(dark.field), "#2c2c2c");
1349        assert_eq!(to_hex(dark.field_border), "#3b3b3b");
1350        assert_eq!(
1351            to_hex(dark.field_focus_border),
1352            "#799cca",
1353            "two steps from the #799ec8 sample, inside its noise"
1354        );
1355        assert_eq!(dark.text_textarea.line_height, px(18.5));
1356        assert_eq!(dark.text_textarea.size, px(13.));
1357        assert!(lightness(dark.field_border) > lightness(dark.field));
1358        assert!(lightness(dark.field) > lightness(dark.background()));
1359        let light = light();
1360        assert_eq!(light.field, light.background());
1361        assert_eq!(light.field_border, light.input());
1362        assert_eq!(light.field_focus_border, light.ring());
1363    }
1364
1365    #[test]
1366    fn accent_and_semantic_colors_pass_through() {
1367        let light = light();
1368        assert_eq!(light.ring(), hex("#339cff"));
1369        assert_eq!(light.destructive(), hex("#ba2623"));
1370        assert_eq!(light.destructive_foreground(), hex("#ffffff"));
1371        assert_eq!(light.selection().a, 0.3);
1372        let dark = dark();
1373        assert_eq!(dark.ring(), hex("#539af8"));
1374        assert_eq!(
1375            to_hex(dark.link),
1376            "#91bffd",
1377            "links are paler than the ring on dark"
1378        );
1379        assert_eq!(light.link, light.ring(), "and the accent itself on light");
1380        assert_eq!(dark.destructive_foreground(), hex("#ffffff"));
1381        let mut pale = ThemeConfig::light();
1382        pale.semantic.destructive = hex("#ffe066");
1383        let pale = ThemeTokens::derive(&pale, ThemeAppearance::Light, metrics());
1384        assert_eq!(
1385            pale.destructive_foreground(),
1386            hex("#1a1c1f"),
1387            "ink reads on pale yellow"
1388        );
1389    }
1390
1391    #[test]
1392    fn radius_scale_derives_from_one_value() {
1393        let tokens = light();
1394        assert_eq!(tokens.radius_sm(), px(6.));
1395        assert_eq!(tokens.radius_md(), px(8.));
1396        assert_eq!(tokens.radius_lg(), px(10.));
1397        assert_eq!(tokens.radius_xl(), px(14.));
1398        assert_eq!(tokens.radius_full(), px(9999.));
1399    }
1400
1401    #[test]
1402    fn control_text_and_metrics_scale_with_the_font_size_but_chrome_does_not() {
1403        let default = light();
1404        assert_eq!(default.text_control.size, px(13.));
1405        assert_eq!(default.text_control.line_height, px(16.));
1406        assert_eq!(default.text_heading.size, px(15.));
1407        assert_eq!(default.text_title.size, px(24.));
1408        assert_eq!(default.text_title.weight, FontWeight::SEMIBOLD);
1409        assert_eq!(default.metrics.control_md, px(28.));
1410        assert_eq!(default.metrics.row, px(30.));
1411        assert_eq!(default.metrics.title_bar, px(38.));
1412        assert_eq!(default.metrics.title_bar_content_offset, px(2.));
1413        assert_eq!(default.metrics.window_controls_position, px(14.));
1414        let large = ThemeTokens::derive(
1415            &ThemeConfig::light(),
1416            ThemeAppearance::Light,
1417            Metrics {
1418                radius: px(10.),
1419                ui_font_size: px(20.),
1420                code_font_size: px(15.),
1421                touch: false,
1422            },
1423        );
1424        assert_eq!(large.text_control.size, px(16.25));
1425        assert_eq!(large.metrics.control_md, px(35.));
1426        assert_eq!(large.metrics.row, px(37.5));
1427        assert_eq!(large.metrics.title_bar, px(38.), "window chrome stays");
1428        assert_eq!(large.metrics.sidebar_width, px(300.), "layout stays");
1429        assert_eq!(default.metrics.switch_track_width, px(32.));
1430        assert_eq!(default.metrics.switch_track_height, px(20.));
1431        assert_eq!(default.metrics.switch_thumb_size, px(16.));
1432        assert_eq!(default.metrics.switch_thumb_inset, px(2.));
1433        assert_eq!(large.metrics.switch_track_width, px(40.));
1434        assert_eq!(large.metrics.switch_thumb_size, px(20.));
1435        assert_eq!(large.metrics.switch_thumb_inset, px(2.5));
1436        assert_eq!(default.metrics.avatar_md, px(32.));
1437        assert_eq!(default.metrics.tag_height, px(20.));
1438        assert_eq!(large.metrics.tag_height, px(25.));
1439        assert_eq!(default.metrics.badge_count, px(16.));
1440        assert_eq!(default.metrics.badge_dot, px(6.));
1441        assert_eq!(large.metrics.avatar_md, px(40.));
1442        assert_eq!(large.metrics.avatar_group_ring, px(2.), "a hairline stays");
1443        assert_eq!(default.metrics.terminal_padding, px(2.));
1444        assert_eq!(
1445            large.metrics.terminal_padding,
1446            px(2.),
1447            "the grid's room stays"
1448        );
1449        assert_eq!(large.metrics.terminal_cursor_thickness, px(2.));
1450        assert_eq!(large.terminal_cursor_opacity, 0.55);
1451    }
1452
1453    #[test]
1454    fn the_tab_separator_is_the_muted_text_at_sixty_percent() {
1455        let dark = crate::theme::test_tokens(&ThemeConfig::dark(), ThemeAppearance::Dark);
1456        assert_eq!(dark.tab_separator.a, 0.6);
1457        assert_eq!(
1458            to_hex(dark.tab_separator.alpha(1.)),
1459            to_hex(dark.muted_foreground())
1460        );
1461        let light = crate::theme::test_tokens(&ThemeConfig::light(), ThemeAppearance::Light);
1462        assert_eq!(light.tab_separator.a, 0.6);
1463    }
1464
1465    #[test]
1466    fn tab_metrics_are_the_reference_values_and_grow_on_touch() {
1467        // Measured from the tab reference app at 2x: a 38px bar whose tab
1468        // surface starts 4px down, 160px items, 7px shoulders and corners.
1469        let default = light();
1470        let tab = &default.metrics;
1471        assert_eq!(tab.tab_surface_inset, px(4.));
1472        assert_eq!(tab.tab_width, px(160.));
1473        assert_eq!(tab.tab_min_width, px(112.));
1474        assert_eq!(tab.tab_shoulder, px(7.));
1475        assert_eq!(tab.tab_radius, px(7.));
1476        assert_eq!(tab.tab_separator_gutter, px(7.));
1477        assert_eq!(tab.tab_separator_inset, px(11.));
1478        assert_eq!(tab.tab_icon, px(13.));
1479        assert_eq!(tab.tab_content_gap, px(6.));
1480        assert_eq!(tab.tab_control, px(24.));
1481        assert_eq!(tab.tab_close, px(20.));
1482        let large = ThemeTokens::derive(
1483            &ThemeConfig::light(),
1484            ThemeAppearance::Light,
1485            Metrics {
1486                ui_font_size: px(20.),
1487                ..metrics()
1488            },
1489        );
1490        assert_eq!(large.metrics.tab_surface_inset, px(4.), "chrome stays");
1491        assert_eq!(large.metrics.tab_control, px(30.), "holds an Sm control");
1492        assert_eq!(large.metrics.tab_close, px(25.), "holds an Xs control");
1493        assert_eq!(large.metrics.tab_width, px(160.));
1494        let touch = ThemeTokens::derive(
1495            &ThemeConfig::light(),
1496            ThemeAppearance::Light,
1497            Metrics {
1498                touch: true,
1499                ..metrics()
1500            },
1501        );
1502        assert_eq!(touch.metrics.tab_surface_inset, px(0.), "fills the bar");
1503        assert_eq!(touch.metrics.tab_control, touch.metrics.control_sm);
1504        assert_eq!(touch.metrics.tab_close, touch.metrics.control_xs);
1505        assert_eq!(touch.metrics.tab_width, px(160.));
1506    }
1507
1508    #[test]
1509    fn typography_follows_the_font_sizes_and_families() {
1510        let mut config = ThemeConfig::light();
1511        config.fonts.ui = Some("Inter".into());
1512        config.fonts.code = Some("JetBrains Mono".into());
1513        let tokens = ThemeTokens::derive(
1514            &config,
1515            ThemeAppearance::Light,
1516            Metrics {
1517                radius: px(10.),
1518                ui_font_size: px(20.),
1519                code_font_size: px(15.),
1520                touch: false,
1521            },
1522        );
1523        assert_eq!(tokens.font_family(), &SharedString::from("Inter"));
1524        assert_eq!(
1525            tokens.mono_font_family(),
1526            &SharedString::from("JetBrains Mono")
1527        );
1528        assert_eq!(tokens.base.typography.md.size, px(20.));
1529        assert_eq!(tokens.base.typography.sm.size, px(17.5));
1530        assert_eq!(tokens.base.typography.md.line_height, px(30.));
1531        assert_eq!(tokens.base.typography.mono_md.size, px(15.));
1532        let defaults =
1533            ThemeTokens::derive(&ThemeConfig::light(), ThemeAppearance::Light, metrics());
1534        assert_eq!(defaults.font_family(), &TypographyTokens::default().sans);
1535        assert_eq!(defaults.base.typography.md.size, px(16.));
1536        assert_eq!(defaults.base.typography.mono_md.size, px(13.));
1537    }
1538
1539    #[test]
1540    fn slider_metrics_are_the_shadcn_values_and_scale_with_the_font_size() {
1541        let default = light();
1542        assert_eq!(default.metrics.slider_track, px(4.), "a thin rail");
1543        assert_eq!(default.metrics.slider_thumb, px(16.), "shadcn size-4");
1544        assert_eq!(default.metrics.slider_tick, px(4.));
1545        let large = ThemeTokens::derive(
1546            &ThemeConfig::light(),
1547            ThemeAppearance::Light,
1548            Metrics {
1549                ui_font_size: px(20.),
1550                ..metrics()
1551            },
1552        );
1553        assert_eq!(large.metrics.slider_thumb, px(20.));
1554    }
1555
1556    #[test]
1557    fn a_chosen_radio_and_an_on_switch_share_the_accent() {
1558        for (tokens, hex) in [(dark(), "#539af8"), (light(), "#339cff")] {
1559            assert_eq!(to_hex(tokens.switch_track_on), hex);
1560            assert_eq!(tokens.switch_track_on, tokens.ring());
1561        }
1562    }
1563
1564    #[test]
1565    fn radio_metrics_are_the_shadcn_values() {
1566        let default = light();
1567        assert_eq!(default.metrics.radio_size, px(16.), "shadcn size-4");
1568        assert_eq!(
1569            default.metrics.radio_dot,
1570            px(6.),
1571            "three quarters of size-2"
1572        );
1573    }
1574
1575    #[test]
1576    fn ring_metrics_scale_with_the_font_size() {
1577        let default = light();
1578        assert_eq!(default.metrics.ring_size, px(16.));
1579        assert_eq!(default.metrics.ring_stroke, px(2.));
1580    }
1581
1582    #[test]
1583    fn the_attachment_tile_metric_and_the_model_picker_metrics_are_set() {
1584        let dark = dark();
1585        assert_eq!(dark.metrics.attachment_tile, px(56.));
1586        assert_eq!(dark.metrics.model_picker_width, px(361.));
1587        assert_eq!(dark.metrics.model_picker_height, px(346.));
1588        assert_eq!(dark.metrics.model_picker_rail, px(44.));
1589        assert_eq!(dark.metrics.model_row, px(47.));
1590        assert_eq!(dark.metrics.model_header, px(42.));
1591    }
1592
1593    #[test]
1594    fn the_status_tab_metrics_are_the_reference_samples() {
1595        let m = &dark().metrics;
1596        assert_eq!(m.status_tab_height, px(38.));
1597        assert_eq!(m.status_tab_inset, px(14.));
1598        assert_eq!(m.status_tab_gap, px(28.));
1599        assert_eq!(m.status_label_gap, px(8.));
1600    }
1601
1602    #[test]
1603    fn the_composer_reads_text_at_least_4_5_to_1_on_the_surfaces_it_uses() {
1604        use crate::theme::color::contrast_ratio;
1605        for tokens in [dark(), light()] {
1606            let muted = contrast_ratio(tokens.muted_foreground(), tokens.field);
1607            assert!(muted >= 4.5, "muted text on the card: {muted}");
1608            let warning = contrast_ratio(tokens.warning, tokens.field);
1609            assert!(warning >= 4.5, "the risky mode on the card: {warning}");
1610            let tab = contrast_ratio(tokens.foreground(), tokens.sidebar);
1611            assert!(tab >= 4.5, "text on the status tab: {tab}");
1612            let hairline = contrast_ratio(tokens.field_border, tokens.sidebar);
1613            assert!(
1614                hairline > 1.05,
1615                "the card's hairline reads on the tab: {hairline}"
1616            );
1617        }
1618    }
1619
1620    #[test]
1621    fn dock_panes_take_ghosttys_handle_and_trellis_drag_sizes() {
1622        let dark = dark();
1623        assert_eq!(to_hex(dark.dock_drop.alpha(1.)), to_hex(dark.ring()));
1624        assert_eq!(dark.dock_drop.a, 0.3, "Ghostty's drop overlay");
1625        assert_eq!(dark.dock_handle.a, 0.3);
1626        assert_eq!(dark.dock_handle_hover.a, 0.8);
1627        assert_eq!(to_hex(dark.dock_unfocused.alpha(1.)), "#181818");
1628        assert_eq!(dark.dock_unfocused.a, 0.3, "Ghostty's unfocused split");
1629        assert_eq!(to_hex(dark.dock_handle.alpha(1.)), "#dfdfdf");
1630        let m = &dark.metrics;
1631        assert_eq!(m.dock_handle_width, px(80.));
1632        assert_eq!(m.dock_handle_height, px(12.));
1633        assert_eq!(m.dock_handle_reveal, 0.2);
1634        assert_eq!(m.dock_drag_threshold, px(6.));
1635        assert_eq!(m.dock_card, Size::new(px(380.), px(260.)));
1636        let touch = ThemeTokens::derive(
1637            &ThemeConfig::dark(),
1638            ThemeAppearance::Dark,
1639            Metrics {
1640                touch: true,
1641                ..metrics()
1642            },
1643        );
1644        assert_eq!(touch.metrics.dock_handle_height, px(44.), "the HIG target");
1645        assert_eq!(touch.metrics.dock_handle_width, px(80.));
1646    }
1647}