T00 foundation: theme, motion, assets, icon, buttons, gallery

Theme tokens in light and dark with high contrast, a palette cross-fade,
motion curves and a damped spring, embedded Lucide icons with Inter and
JetBrains Mono, Icon, Button, IconButton, and a gallery that captures
its own window. Reviewed by codex gpt-6-astra (max): PASS in round 3.
This commit is contained in:
ZacharyZhang-NY committed 2026-09-24 23:59:55 -04:00
commit abf4cbcfc6
258 files changed
+16908

No files matched your search

+136
View File
@@ -0,0 +1,136 @@
mod palette;
mod tokens;
use std::time::{Duration, Instant};
use gpui::{App, Global, SharedString, WindowAppearance};
use crate::motion;
pub use palette::{Mix, Palette, Syntax};
pub use tokens::{ControlSize, Density, Elevation, IconSize, Radius, TextSize};
const FRAME: Duration = Duration::from_millis(8);
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Mode {
#[default]
Light,
Dark,
}
impl From<WindowAppearance> for Mode {
fn from(appearance: WindowAppearance) -> Self {
match appearance {
WindowAppearance::Dark | WindowAppearance::VibrantDark => Mode::Dark,
WindowAppearance::Light | WindowAppearance::VibrantLight => Mode::Light,
}
}
}
/// Design tokens. One global per app.
pub struct Theme {
mode: Mode,
high_contrast: bool,
transition: u64,
pub colors: Palette,
pub density: Density,
pub radius_scale: f32,
pub font_scale: f32,
pub font_family: SharedString,
pub mono_family: SharedString,
pub reduced_motion: bool,
}
impl Global for Theme {}
pub trait ActiveTheme {
fn theme(&self) -> &Theme;
}
impl ActiveTheme for App {
fn theme(&self) -> &Theme {
self.global::<Theme>()
}
}
impl Theme {
pub(crate) fn init(cx: &mut App) {
cx.set_global(Theme {
mode: Mode::Light,
high_contrast: false,
transition: 0,
colors: Palette::light(false),
density: Density::Standard,
radius_scale: 1.0,
font_scale: 1.0,
font_family: "Inter".into(),
mono_family: "JetBrains Mono".into(),
reduced_motion: false,
});
}
pub fn mode(&self) -> Mode {
self.mode
}
pub fn high_contrast(&self) -> bool {
self.high_contrast
}
pub fn is_dark(&self) -> bool {
self.mode == Mode::Dark
}
/// Cross-fades the palette to `mode`.
pub fn set_mode(mode: Mode, cx: &mut App) {
log::info!("theme: mode -> {mode:?}");
cx.global_mut::<Theme>().mode = mode;
Self::fade_to_target(cx);
}
pub fn set_high_contrast(on: bool, cx: &mut App) {
log::info!("theme: high contrast -> {on}");
cx.global_mut::<Theme>().high_contrast = on;
Self::fade_to_target(cx);
}
/// Edits non-color tokens, then repaints.
pub fn update(cx: &mut App, edit: impl FnOnce(&mut Theme)) {
edit(cx.global_mut::<Theme>());
cx.refresh_windows();
}
fn target(&self) -> Palette {
match self.mode {
Mode::Light => Palette::light(self.high_contrast),
Mode::Dark => Palette::dark(self.high_contrast),
}
}
fn fade_to_target(cx: &mut App) {
let duration = motion::duration(motion::THEME, cx);
let theme = cx.global_mut::<Theme>();
theme.transition += 1;
let (id, from, to) = (theme.transition, theme.colors.clone(), theme.target());
let start = Instant::now();
cx.spawn(async move |cx| {
loop {
cx.background_executor().timer(FRAME).await;
let t = (start.elapsed().as_secs_f32() / duration.as_secs_f32()).min(1.0);
let running = cx.update(|cx| {
let theme = cx.global_mut::<Theme>();
if theme.transition != id {
return false;
}
theme.colors = from.mix(&to, motion::ease_in_out_cubic(t));
cx.refresh_windows();
t < 1.0
})?;
if !running {
return anyhow::Ok(());
}
}
})
.detach_and_log_err(cx);
}
}
+265
View File
@@ -0,0 +1,265 @@
use gpui::{Hsla, Rgba, rgb, rgba};
pub trait Mix {
fn mix(&self, to: &Self, t: f32) -> Self;
}
impl Mix for Hsla {
fn mix(&self, to: &Self, t: f32) -> Self {
if t <= 0.0 {
return *self;
}
if t >= 1.0 {
return *to;
}
let (a, b) = (self.to_rgb(), to.to_rgb());
let lerp = |x: f32, y: f32| x + (y - x) * t;
Rgba {
r: lerp(a.r, b.r),
g: lerp(a.g, b.g),
b: lerp(a.b, b.b),
a: lerp(a.a, b.a),
}
.into()
}
}
impl<const N: usize> Mix for [Hsla; N] {
fn mix(&self, to: &Self, t: f32) -> Self {
std::array::from_fn(|i| self[i].mix(&to[i], t))
}
}
macro_rules! mixable {
($(#[$meta:meta])* $name:ident { $($field:ident: $ty:ty),* $(,)? }) => {
$(#[$meta])*
#[derive(Clone, Debug, PartialEq)]
pub struct $name {
$(pub $field: $ty,)*
}
impl Mix for $name {
fn mix(&self, to: &Self, t: f32) -> Self {
Self { $($field: self.$field.mix(&to.$field, t),)* }
}
}
};
}
mixable!(
/// Code token colors.
Syntax {
keyword: Hsla,
string: Hsla,
number: Hsla,
comment: Hsla,
function: Hsla,
type_name: Hsla,
constant: Hsla,
property: Hsla,
tag: Hsla,
attribute: Hsla,
operator: Hsla,
punctuation: Hsla,
variable: Hsla,
}
);
mixable!(
/// Semantic colors. Components read these, never literals.
Palette {
bg: Hsla,
surface: Hsla,
sunken: Hsla,
overlay: Hsla,
hover: Hsla,
active: Hsla,
border: Hsla,
border_strong: Hsla,
fg: Hsla,
fg_muted: Hsla,
fg_subtle: Hsla,
fg_disabled: Hsla,
accent: Hsla,
accent_hover: Hsla,
on_accent: Hsla,
focus: Hsla,
link: Hsla,
selection: Hsla,
success: Hsla,
warning: Hsla,
danger: Hsla,
info: Hsla,
success_subtle: Hsla,
warning_subtle: Hsla,
danger_subtle: Hsla,
info_subtle: Hsla,
backdrop: Hsla,
shadow: Hsla,
chart: [Hsla; 8],
ansi: [Hsla; 16],
syntax: Syntax,
}
);
fn c(hex: u32) -> Hsla {
rgb(hex).into()
}
fn c8<const N: usize>(hex: [u32; N]) -> [Hsla; N] {
hex.map(c)
}
impl Palette {
pub fn light(high_contrast: bool) -> Self {
let mut palette = Self {
bg: c(0xfcfaf7),
surface: c(0xffffff),
sunken: c(0xf6f4f1),
overlay: c(0xffffff),
hover: c(0xf2f0ed),
active: c(0xe9e7e5),
border: c(0xe1dfdd),
border_strong: c(0xcccac8),
fg: c(0x181613),
fg_muted: c(0x605d5a),
fg_subtle: c(0x82807d),
fg_disabled: c(0xacaaa8),
accent: c(0x181613),
accent_hover: c(0x302d2b),
on_accent: c(0xfcfaf7),
focus: c(0x3772bb),
link: c(0x2863ab),
selection: rgba(0x3772bb33).into(),
success: c(0x267b4c),
warning: c(0xa25f12),
danger: c(0xba3e38),
info: c(0x2e69b2),
success_subtle: c(0xe7f9ec),
warning_subtle: c(0xfef2dd),
danger_subtle: c(0xffefec),
info_subtle: c(0xebf4ff),
backdrop: rgba(0x18161352).into(),
shadow: c(0x181613),
chart: c8([
0x5182c1, 0x009589, 0xaa732b, 0xb8636b, 0x8572bb, 0x539156, 0xb66946, 0x138db1,
]),
ansi: c8([
0x181613, 0xa34942, 0x287c42, 0x8c5f00, 0x346aac, 0x875093, 0x007c84, 0xacaaa8,
0x605d5a, 0xbd6158, 0x439458, 0xa57710, 0x4c82c6, 0xa067ac, 0x00959c, 0xcccac8,
]),
syntax: Syntax {
keyword: c(0x714ca6),
string: c(0x317a45),
number: c(0xb3621e),
comment: c(0x82807d),
function: c(0x2863ab),
type_name: c(0x1e7777),
constant: c(0xb1582b),
property: c(0xa14759),
tag: c(0xa14759),
attribute: c(0xa25f12),
operator: c(0x605d5a),
punctuation: c(0x605d5a),
variable: c(0x181613),
},
};
if high_contrast {
palette.fg_muted = c(0x44423f);
palette.fg_subtle = c(0x575552);
palette.border = c(0xa6a4a2);
palette.border_strong = c(0x82807d);
}
palette
}
pub fn dark(high_contrast: bool) -> Self {
let mut palette = Self {
bg: c(0x131110),
surface: c(0x1b1917),
sunken: c(0x0e0d0b),
overlay: c(0x201f1d),
hover: c(0x252422),
active: c(0x2c2b29),
border: c(0x2c2b29),
border_strong: c(0x413f3d),
fg: c(0xf3f1f0),
fg_muted: c(0xafadab),
fg_subtle: c(0x858380),
fg_disabled: c(0x5c5a58),
accent: c(0xf3f1f0),
accent_hover: c(0xd9d7d5),
on_accent: c(0x131110),
focus: c(0x69a1e8),
link: c(0x7eb1f3),
selection: rgba(0x69a1e84d).into(),
success: c(0x6bbc89),
warning: c(0xe8b45e),
danger: c(0xe5756e),
info: c(0x77abec),
success_subtle: c(0x182c1f),
warning_subtle: c(0x342611),
danger_subtle: c(0x3a1d1b),
info_subtle: c(0x19273a),
backdrop: rgba(0x00000080).into(),
shadow: c(0x000000),
chart: c8([
0x79a7e2, 0x4eb9ad, 0xcd995c, 0xdc8a90, 0xa998dd, 0x7cb57d, 0xd9906f, 0x56b2d4,
]),
ansi: c8([
0x2c2b29, 0xe1897f, 0x6fb880, 0xc99d4e, 0x74a7e8, 0xc38ecf, 0x35b9c0, 0xafadab,
0x5c5a58, 0xfda297, 0x89d298, 0xe3b667, 0x8dc1ff, 0xdda7ea, 0x56d3da, 0xf3f1f0,
]),
syntax: Syntax {
keyword: c(0xbba3e8),
string: c(0x8ac596),
number: c(0xeaab78),
comment: c(0x858380),
function: c(0x85b6e9),
type_name: c(0x84c9c8),
constant: c(0xeda382),
property: c(0xe29fa9),
tag: c(0xe29fa9),
attribute: c(0xe1b671),
operator: c(0xafadab),
punctuation: c(0xafadab),
variable: c(0xf3f1f0),
},
};
if high_contrast {
palette.fg_muted = c(0xcfcdcb);
palette.fg_subtle = c(0xafadab);
palette.border = c(0x6a6966);
palette.border_strong = c(0x858380);
}
palette
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mix_hits_both_ends_and_the_middle() {
let (black, white) = (c(0x000000), c(0xffffff));
assert_eq!(black.mix(&white, 0.0).to_rgb().r, 0.0);
assert_eq!(black.mix(&white, 1.0).to_rgb().r, 1.0);
assert!((black.mix(&white, 0.5).to_rgb().g - 0.5).abs() < 1e-4);
}
#[test]
fn palette_mix_moves_every_field() {
let (light, dark) = (Palette::light(false), Palette::dark(false));
assert_eq!(light.mix(&dark, 1.0), dark);
assert_eq!(light.mix(&dark, 0.0), light);
}
#[test]
fn high_contrast_strengthens_borders() {
let (base, hc) = (Palette::light(false), Palette::light(true));
assert!(hc.border.l < base.border.l);
let (base, hc) = (Palette::dark(false), Palette::dark(true));
assert!(hc.border.l > base.border.l);
}
}
+161
View File
@@ -0,0 +1,161 @@
use gpui::{BoxShadow, Hsla, Rems, point, px, rems};
use super::{Mode, Theme};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Density {
Compact,
#[default]
Standard,
Comfortable,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum ControlSize {
Sm,
#[default]
Md,
Lg,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum IconSize {
Xs,
Sm,
#[default]
Md,
Lg,
Xl,
Xxl,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TextSize {
Xs,
Sm,
Base,
Md,
Lg,
Xl,
Xxl,
Display,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Radius {
Sm,
Md,
Lg,
Xl,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Elevation {
Raised,
Floating,
Modal,
}
fn px_to_rems(value: f32) -> Rems {
rems(value / 16.0)
}
impl Theme {
pub fn control_height(&self, size: ControlSize) -> Rems {
let base = match size {
ControlSize::Sm => 24.0,
ControlSize::Md => 28.0,
ControlSize::Lg => 32.0,
};
let delta = match self.density {
Density::Compact => -4.0,
Density::Standard => 0.0,
Density::Comfortable => 4.0,
};
px_to_rems(base + delta)
}
pub fn control_padding(&self, size: ControlSize) -> Rems {
let base = match size {
ControlSize::Sm => 8.0,
ControlSize::Md => 12.0,
ControlSize::Lg => 16.0,
};
let delta = match self.density {
Density::Compact => -2.0,
Density::Standard => 0.0,
Density::Comfortable => 2.0,
};
px_to_rems(base + delta)
}
pub fn icon_size(&self, size: IconSize) -> Rems {
let base = match size {
IconSize::Xs => 12.0,
IconSize::Sm => 14.0,
IconSize::Md => 16.0,
IconSize::Lg => 20.0,
IconSize::Xl => 24.0,
IconSize::Xxl => 32.0,
};
px_to_rems(base * self.font_scale)
}
pub fn text_size(&self, size: TextSize) -> Rems {
let base = match size {
TextSize::Xs => 11.0,
TextSize::Sm => 12.0,
TextSize::Base => 13.0,
TextSize::Md => 14.0,
TextSize::Lg => 16.0,
TextSize::Xl => 20.0,
TextSize::Xxl => 24.0,
TextSize::Display => 32.0,
};
px_to_rems(base * self.font_scale)
}
pub fn radius(&self, size: Radius) -> Rems {
let base = match size {
Radius::Sm => 4.0,
Radius::Md => 6.0,
Radius::Lg => 8.0,
Radius::Xl => 12.0,
};
px_to_rems(base * self.radius_scale)
}
pub fn elevation(&self, level: Elevation) -> Vec<BoxShadow> {
let dark = self.mode == Mode::Dark;
let layers: &[(f32, f32, f32)] = match (level, dark) {
(Elevation::Raised, false) => &[(1.0, 2.0, 0.06)],
(Elevation::Floating, false) => &[(1.0, 2.0, 0.04), (6.0, 16.0, 0.08)],
(Elevation::Modal, false) => &[(2.0, 4.0, 0.04), (16.0, 40.0, 0.14)],
(Elevation::Raised, true) => &[(1.0, 2.0, 0.4)],
(Elevation::Floating, true) => &[(2.0, 4.0, 0.3), (8.0, 24.0, 0.45)],
(Elevation::Modal, true) => &[(4.0, 8.0, 0.3), (20.0, 48.0, 0.55)],
};
layers
.iter()
.map(|&(y, blur, alpha)| shadow(self.colors.shadow.alpha(alpha), y, blur))
.collect()
}
pub fn focus_ring(&self) -> Vec<BoxShadow> {
vec![BoxShadow {
color: self.colors.focus.alpha(0.45),
offset: point(px(0.0), px(0.0)),
blur_radius: px(0.0),
spread_radius: px(2.0),
}]
}
}
fn shadow(color: Hsla, y: f32, blur: f32) -> BoxShadow {
BoxShadow {
color,
offset: point(px(0.0), px(y)),
blur_radius: px(blur),
spread_radius: px(0.0),
}
}