808fd7924e
Чекпойнт: Rust-меню, настройки, Play, пауза, ресурспаки, кейбинды, RSX-протокол, шрифты и иконки. Перед финальным пушем история будет разбита на логические коммиты.
803 lines
27 KiB
Rust
803 lines
27 KiB
Rust
//! Horizon's design tokens, the one source of truth for the launcher (`launcher/src/theme.rs`),
|
|
//! the game's on-screen keyboard (`horizon-client`'s `ui_theme`) and the website (`tokens.css` in this crate,
|
|
//! generated from [`css`] by `cargo run -p xtask -- tokens`, which the site's build reads from main). Change a value here and all three
|
|
//! follow. The system is described in `docs/design-system.md`.
|
|
//!
|
|
//! Cool neutrals in two appearances and one violet accent; Geist and Geist Mono only, bundled
|
|
//! here under the SIL Open Font License (`fonts/OFL-Geist.txt`). Colours are `0xRRGGBB`; sizes
|
|
//! are in logical pixels.
|
|
use std::fmt::Write as _;
|
|
use std::time::Duration;
|
|
|
|
pub const FONT: &str = "Geist";
|
|
pub const MONO: &str = "Geist Mono";
|
|
/// The launcher's and the website's display face (headings, navigation, buttons, badges only):
|
|
/// Pixelify Sans, bundled under the SIL Open Font License (`fonts/OFL-PixelifySans.txt`).
|
|
pub const PIXEL: &str = "Pixelify Sans";
|
|
|
|
/// The bundled font files.
|
|
pub mod fonts {
|
|
pub const GEIST_REGULAR: &[u8] = include_bytes!("../fonts/Geist-Regular.ttf");
|
|
pub const GEIST_MEDIUM: &[u8] = include_bytes!("../fonts/Geist-Medium.ttf");
|
|
pub const GEIST_SEMIBOLD: &[u8] = include_bytes!("../fonts/Geist-SemiBold.ttf");
|
|
pub const GEIST_MONO: &[u8] = include_bytes!("../fonts/GeistMono-Regular.ttf");
|
|
pub const PIXEL_MEDIUM: &[u8] = include_bytes!("../fonts/PixelifySans-Medium.ttf");
|
|
pub const PIXEL_SEMIBOLD: &[u8] = include_bytes!("../fonts/PixelifySans-SemiBold.ttf");
|
|
pub const PIXEL_BOLD: &[u8] = include_bytes!("../fonts/PixelifySans-Bold.ttf");
|
|
/// The Rust-menu brand face (Permanent Marker, Apache 2.0 —
|
|
/// `fonts/Apache-PermanentMarker.txt`); Latin only, so it draws brand words
|
|
/// ("Horizon", "RustNotMC", "DEVBLOG") and never localized menu strings.
|
|
pub const MARKER: &[u8] = include_bytes!("../fonts/PermanentMarker-Regular.ttf");
|
|
/// The Rust-menu's body face (Roboto, Apache 2.0 — `fonts/Roboto-LICENSE.txt`),
|
|
/// Cyrillic included: every localized label of the menu draws with it.
|
|
pub const ROBOTO_REGULAR: &[u8] = include_bytes!("../fonts/Roboto-Regular.ttf");
|
|
pub const ROBOTO_MEDIUM: &[u8] = include_bytes!("../fonts/Roboto-Medium.ttf");
|
|
/// The Rust menu's display face (Oswald, OFL — `fonts/OFL-Oswald.txt`):
|
|
/// condensed caps for the big headings.
|
|
pub const OSWALD_SEMIBOLD: &[u8] = include_bytes!("../fonts/Oswald-SemiBold.ttf");
|
|
/// The Rust menu's interface face (Roboto Condensed, OFL —
|
|
/// `fonts/OFL-RobotoCondensed.txt`); every localized label draws with it.
|
|
pub const ROBOTO_CONDENSED_REGULAR: &[u8] =
|
|
include_bytes!("../fonts/RobotoCondensed-Regular.ttf");
|
|
pub const ROBOTO_CONDENSED_MEDIUM: &[u8] =
|
|
include_bytes!("../fonts/RobotoCondensed-Medium.ttf");
|
|
}
|
|
|
|
/// The launcher's pixel icons: Pixelarticons (MIT, `icons/pixel/LICENSE-pixelarticons.txt`), 24
|
|
/// unit SVGs filled with `currentColor`, by name (`"home"`, `"play"`…).
|
|
pub mod icons {
|
|
macro_rules! icons {
|
|
($($name:literal),* $(,)?) => {
|
|
/// Every icon, `(name, svg)`.
|
|
pub const ALL: &[(&str, &[u8])] = &[
|
|
$(($name, include_bytes!(concat!("../icons/pixel/", $name, ".svg")))),*
|
|
];
|
|
};
|
|
}
|
|
icons!(
|
|
"archive",
|
|
"arrow-left",
|
|
"book-open",
|
|
"box",
|
|
"bug",
|
|
"check",
|
|
"chevron-down",
|
|
"chevron-right",
|
|
"clock",
|
|
"close",
|
|
"download",
|
|
"external-link",
|
|
"folder",
|
|
"gamepad",
|
|
"globe",
|
|
"heart",
|
|
"home",
|
|
"image",
|
|
"info-box",
|
|
"message",
|
|
"monitor",
|
|
"notes",
|
|
"package",
|
|
"play",
|
|
"plus",
|
|
"reload",
|
|
"search",
|
|
"server",
|
|
"sliders",
|
|
// Not Pixelarticons': drawn for Horizon's ratings, in the same 2-unit grid.
|
|
"star",
|
|
"terminal",
|
|
"trash",
|
|
"user",
|
|
"zap",
|
|
);
|
|
|
|
/// An icon's SVG by name.
|
|
pub fn get(name: &str) -> Option<&'static [u8]> {
|
|
ALL.iter().find(|(n, _)| *n == name).map(|(_, svg)| *svg)
|
|
}
|
|
}
|
|
|
|
/// Horizon's icon at 64 px (PNG), for window title bars and task bars at run time. Rendered from
|
|
/// `docs/media/brand/icon.svg` by `cargo run -p xtask -- icons`.
|
|
pub const ICON_PNG: &[u8] = include_bytes!("../icons/horizon-64.png");
|
|
|
|
/// How a type step sizes on the web.
|
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
|
pub enum Web {
|
|
/// The app size, fixed.
|
|
Fixed,
|
|
/// From `min` px on a 375 px phone to `max` px at 1440 px and wider.
|
|
Fluid(f32, f32),
|
|
/// A share of the viewport width, in `vw`.
|
|
Viewport(f32),
|
|
}
|
|
|
|
/// One step of the type scale.
|
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
|
pub struct Step {
|
|
pub name: &'static str,
|
|
/// The size in the apps (launcher and game menus).
|
|
pub px: f32,
|
|
pub web: Web,
|
|
/// Line height as a multiple of the size.
|
|
pub line: f32,
|
|
/// Letter spacing in em.
|
|
pub tracking: f32,
|
|
pub weight: u16,
|
|
pub mono: bool,
|
|
}
|
|
|
|
impl Step {
|
|
/// The line height in pixels at the app size.
|
|
pub fn line_px(&self) -> f32 {
|
|
(self.px * self.line).round()
|
|
}
|
|
}
|
|
|
|
const fn step(
|
|
name: &'static str,
|
|
px: f32,
|
|
web: Web,
|
|
line: f32,
|
|
tracking: f32,
|
|
weight: u16,
|
|
mono: bool,
|
|
) -> Step {
|
|
Step {
|
|
name,
|
|
px,
|
|
web,
|
|
line,
|
|
tracking,
|
|
weight,
|
|
mono,
|
|
}
|
|
}
|
|
|
|
/// Indices (`01`), counts and timestamps, in mono.
|
|
pub const MICRO: Step = step("micro", 11., Web::Fixed, 1.5, 0.04, 400, true);
|
|
/// Footnotes and help under a control.
|
|
pub const CAPTION: Step = step("caption", 12., Web::Fixed, 1.5, 0., 400, false);
|
|
/// Row labels, navigation and buttons.
|
|
pub const LABEL: Step = step("label", 13., Web::Fixed, 1.5, 0., 400, false);
|
|
/// Body text in the apps.
|
|
pub const UI: Step = step("ui", 14., Web::Fixed, 1.5, 0., 400, false);
|
|
/// Paragraphs.
|
|
pub const BODY: Step = step("body", 15., Web::Fluid(15., 17.), 1.6, 0., 400, false);
|
|
/// Section heads.
|
|
pub const HEADING: Step = step("heading", 20., Web::Fluid(20., 24.), 1.3, -0.01, 500, false);
|
|
/// The names in an index list.
|
|
pub const TITLE: Step = step("title", 28., Web::Fluid(32., 48.), 1.05, -0.02, 500, false);
|
|
/// Page titles.
|
|
pub const DISPLAY: Step = step(
|
|
"display",
|
|
44.,
|
|
Web::Fluid(34., 64.),
|
|
1.05,
|
|
-0.025,
|
|
500,
|
|
false,
|
|
);
|
|
/// The giant faint signature.
|
|
pub const WORDMARK: Step = step("wordmark", 220., Web::Viewport(18.), 0.8, -0.04, 500, false);
|
|
|
|
pub const TYPE: [Step; 9] = [
|
|
MICRO, CAPTION, LABEL, UI, BODY, HEADING, TITLE, DISPLAY, WORDMARK,
|
|
];
|
|
|
|
/// The spacing scale.
|
|
pub const SPACE: [f32; 9] = [4., 8., 12., 16., 24., 32., 48., 64., 96.];
|
|
/// Page margins: wide windows and pages, and phones.
|
|
pub const GUTTER: f32 = 64.;
|
|
pub const GUTTER_NARROW: f32 = 16.;
|
|
/// The longest paragraph line, in characters.
|
|
pub const MEASURE: f32 = 56.;
|
|
|
|
/// Corner radii: fields and small controls; plates (hover rows, console, dialogs); pills
|
|
/// (buttons, segmented controls, switches).
|
|
pub const RADIUS: f32 = 8.;
|
|
pub const RADIUS_PLATE: f32 = 12.;
|
|
pub const RADIUS_PILL: f32 = 999.;
|
|
/// Rules between rows are one pixel; there are no other borders.
|
|
pub const RULE: f32 = 1.;
|
|
|
|
/// Heights: buttons, the one large action, a segment, a field, an index row in the apps and
|
|
/// on the web.
|
|
pub const CONTROL: f32 = 32.;
|
|
pub const CONTROL_LARGE: f32 = 40.;
|
|
pub const SEGMENT: f32 = 28.;
|
|
pub const FIELD: f32 = 36.;
|
|
pub const ROW: f32 = 64.;
|
|
pub const ROW_WEB: f32 = 86.;
|
|
/// The index column of a list (where `01` sits) and how far the hover plate reaches past a row.
|
|
pub const INDEX: f32 = 40.;
|
|
pub const PLATE_BLEED: f32 = 16.;
|
|
/// A pressed primary button's opacity, and disabled controls'.
|
|
pub const PRESSED: f32 = 0.85;
|
|
pub const DISABLED: f32 = 0.4;
|
|
|
|
/// A duration and its easing curve (CSS `cubic-bezier` control points).
|
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
|
pub struct Motion {
|
|
pub name: &'static str,
|
|
pub ms: u64,
|
|
pub curve: [f32; 4],
|
|
}
|
|
|
|
impl Motion {
|
|
pub const fn duration(&self) -> Duration {
|
|
Duration::from_millis(self.ms)
|
|
}
|
|
pub fn secs(&self) -> f32 {
|
|
self.ms as f32 / 1000.
|
|
}
|
|
/// The eased progress for a linear `t` in 0..1.
|
|
pub fn ease(&self, t: f32) -> f32 {
|
|
bezier(self.curve, t)
|
|
}
|
|
}
|
|
|
|
/// Standard ease, for hovers, presses and colour.
|
|
pub const QUICK: Motion = Motion {
|
|
name: "quick",
|
|
ms: 150,
|
|
curve: [0.4, 0., 0.2, 1.],
|
|
};
|
|
/// Ease-out with a long tail, for selection, switches and page changes.
|
|
pub const MOVE: Motion = Motion {
|
|
name: "move",
|
|
ms: 240,
|
|
curve: [0.23, 1., 0.32, 1.],
|
|
};
|
|
/// Content arriving: a fade and a [`RISE`].
|
|
pub const ENTER: Motion = Motion {
|
|
name: "enter",
|
|
ms: 600,
|
|
curve: [0.23, 1., 0.32, 1.],
|
|
};
|
|
pub const MOTION: [Motion; 3] = [QUICK, MOVE, ENTER];
|
|
/// The delay between list rows arriving, and how far content rises as it arrives.
|
|
pub const STAGGER: Duration = Duration::from_millis(40);
|
|
pub const RISE: f32 = 8.;
|
|
|
|
/// The launcher's opening: the wordmark holds, then the interface arrives.
|
|
pub const OPENING_HOLD: Duration = Duration::from_millis(1400);
|
|
pub const OPENING_MAX: Duration = Duration::from_millis(2500);
|
|
pub const OPENING_REVEAL: Duration = Duration::from_millis(800);
|
|
|
|
/// A CSS `cubic-bezier(x1, y1, x2, y2)` at `t`: solves x for the curve parameter, returns y.
|
|
pub fn bezier([x1, y1, x2, y2]: [f32; 4], t: f32) -> f32 {
|
|
let t = t.clamp(0., 1.);
|
|
if t == 0. || t == 1. {
|
|
return t;
|
|
}
|
|
let at = |a: f32, b: f32, s: f32| {
|
|
let u = 1. - s;
|
|
3. * u * u * s * a + 3. * u * s * s * b + s * s * s
|
|
};
|
|
let (mut lo, mut hi) = (0f32, 1f32);
|
|
for _ in 0..24 {
|
|
let mid = (lo + hi) / 2.;
|
|
if at(x1, x2, mid) < t {
|
|
lo = mid;
|
|
} else {
|
|
hi = mid;
|
|
}
|
|
}
|
|
at(y1, y2, (lo + hi) / 2.)
|
|
}
|
|
|
|
pub fn ease_out_quint(t: f32) -> f32 {
|
|
1. - (1. - t.clamp(0., 1.)).powi(5)
|
|
}
|
|
pub fn ease_out_cubic(t: f32) -> f32 {
|
|
1. - (1. - t.clamp(0., 1.)).powi(3)
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub struct Palette {
|
|
/// The background.
|
|
pub page: u32,
|
|
/// The hover plate, fields, the segmented track, the console.
|
|
pub plate_soft: u32,
|
|
/// Pressed plates, a switch that is off, the faint wordmark.
|
|
pub plate: u32,
|
|
/// Dialogs and menus above the page.
|
|
pub surface: u32,
|
|
/// One-pixel rules between rows, slider and progress tracks.
|
|
pub rule: u32,
|
|
/// Text, selection, the slider's fill.
|
|
pub ink: u32,
|
|
/// Secondary text and values.
|
|
pub ink_soft: u32,
|
|
/// Labels, descriptions, indices.
|
|
pub muted: u32,
|
|
/// The main action, links, focus, progress.
|
|
pub accent: u32,
|
|
pub on_accent: u32,
|
|
pub danger: u32,
|
|
pub success: u32,
|
|
pub dark: bool,
|
|
}
|
|
|
|
pub const LIGHT: Palette = Palette {
|
|
page: 0xfafafc,
|
|
plate_soft: 0xf3f3f8,
|
|
plate: 0xebecf4,
|
|
surface: 0xffffff,
|
|
rule: 0xe2e3ea,
|
|
ink: 0x16181f,
|
|
ink_soft: 0x3b3e49,
|
|
muted: 0x646875,
|
|
accent: 0x5b3fd6,
|
|
on_accent: 0xffffff,
|
|
danger: 0xb4372a,
|
|
success: 0x1d7a48,
|
|
dark: false,
|
|
};
|
|
|
|
pub const DARK: Palette = Palette {
|
|
page: 0x0d0e13,
|
|
plate_soft: 0x181a22,
|
|
plate: 0x20222c,
|
|
surface: 0x16181f,
|
|
rule: 0x24262f,
|
|
ink: 0xecedf3,
|
|
ink_soft: 0xc5c7d1,
|
|
muted: 0x8b8f9c,
|
|
accent: 0x9a86ff,
|
|
on_accent: 0x0d0e13,
|
|
danger: 0xf07a68,
|
|
success: 0x45c27a,
|
|
dark: true,
|
|
};
|
|
|
|
/// The launcher's and the website's blocky look ("Blocks" in `docs/design-system.md`): stone-dark
|
|
/// (or lavender-light) neutrals with a violet cast, panels with a dark outline, a light bevel on top
|
|
/// and a darker lip below, and the violet `face` of the one big action. The game's menus keep
|
|
/// [`LIGHT`] and [`DARK`].
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub struct Blocks {
|
|
/// The window behind everything, tiled with a faint stone texture.
|
|
pub page: u32,
|
|
/// The top bar and the sidebar.
|
|
pub side: u32,
|
|
/// Panels: rows, cards, dialogs.
|
|
pub panel: u32,
|
|
/// A selected or hovered panel or row, the open tab.
|
|
pub raised: u32,
|
|
/// Buttons and the chosen segment.
|
|
pub button: u32,
|
|
/// A button's top bevel.
|
|
pub button_bevel: u32,
|
|
/// Sunk wells: fields, tracks, switch and checkbox boxes, segmented tracks.
|
|
pub well: u32,
|
|
/// The light top (and left) edge of a panel.
|
|
pub bevel: u32,
|
|
/// The darker bottom edge of a panel or button.
|
|
pub lip: u32,
|
|
/// The 2 px outline around panels, buttons and wells.
|
|
pub outline: u32,
|
|
/// Rules between rows inside a panel.
|
|
pub rule: u32,
|
|
pub ink: u32,
|
|
pub ink_soft: u32,
|
|
pub muted: u32,
|
|
/// Links, focus, progress, the selection marker; text-safe on every background.
|
|
pub accent: u32,
|
|
/// The main action's face (Play, Install, Download), its top bevel and bottom lip.
|
|
pub face: u32,
|
|
pub face_bevel: u32,
|
|
pub face_lip: u32,
|
|
pub on_face: u32,
|
|
pub danger: u32,
|
|
pub success: u32,
|
|
/// "Partly": a mod or pack that runs in part.
|
|
pub warn: u32,
|
|
pub dark: bool,
|
|
}
|
|
|
|
pub const BLOCKS_DARK: Blocks = Blocks {
|
|
page: 0x0f0d17,
|
|
side: 0x0b0a11,
|
|
panel: 0x1a1727,
|
|
raised: 0x221e34,
|
|
button: 0x2b2641,
|
|
button_bevel: 0x3d3560,
|
|
well: 0x0c0b12,
|
|
bevel: 0x342d52,
|
|
lip: 0x07060b,
|
|
outline: 0x05040a,
|
|
rule: 0x120f1c,
|
|
ink: 0xecedf3,
|
|
ink_soft: 0xc5c7d1,
|
|
muted: 0xa29cb8,
|
|
accent: 0x9a86ff,
|
|
face: 0x5b3fd6,
|
|
face_bevel: 0x8a74ff,
|
|
face_lip: 0x3a2694,
|
|
on_face: 0xffffff,
|
|
danger: 0xf07a68,
|
|
success: 0x45c27a,
|
|
warn: 0xf0b84f,
|
|
dark: true,
|
|
};
|
|
|
|
pub const BLOCKS_LIGHT: Blocks = Blocks {
|
|
page: 0xece7f7,
|
|
side: 0xe2dbf2,
|
|
panel: 0xf8f6fd,
|
|
raised: 0xe9e3f8,
|
|
button: 0xddd4f3,
|
|
button_bevel: 0xf4f0fe,
|
|
well: 0xf3f0fb,
|
|
bevel: 0xffffff,
|
|
lip: 0xc3b8e2,
|
|
outline: 0x2b2244,
|
|
rule: 0xe4ddf4,
|
|
ink: 0x1b1530,
|
|
ink_soft: 0x3b3354,
|
|
muted: 0x585071,
|
|
accent: 0x5333c9,
|
|
face: 0x5b3fd6,
|
|
face_bevel: 0x8069ef,
|
|
face_lip: 0x3a2694,
|
|
on_face: 0xffffff,
|
|
danger: 0xa3301d,
|
|
success: 0x136336,
|
|
warn: 0x7a4f00,
|
|
dark: false,
|
|
};
|
|
|
|
/// Pixel sizes of the blocky look: outlines, bevels and lips, the cut corner, and the stone tile.
|
|
pub const BLOCK_OUTLINE: f32 = 2.;
|
|
pub const BLOCK_BEVEL: f32 = 2.;
|
|
pub const BLOCK_LIP: f32 = 4.;
|
|
/// The stone texture: a 16 by 16 tile, each texel this many logical pixels.
|
|
pub const TILE_TEXELS: usize = 16;
|
|
pub const TILE_TEXEL: f32 = 4.;
|
|
/// The texture's shades: the page colour moved this many levels (of 255) lighter or darker.
|
|
pub const TILE_SHADES: [i16; 5] = [0, 2, -2, 5, -4];
|
|
|
|
/// How strongly the game's menus veil the live backdrop (the title panorama, or the world behind
|
|
/// the pause and options screens) with the page colour. Every text colour the menus use keeps
|
|
/// WCAG AA over it, even on a selected row's plate, with a backdrop as bright as
|
|
/// [`BRIGHT_BACKDROP`] (a test checks it).
|
|
pub const VEIL: f32 = 0.90;
|
|
/// The title's home page shows more of the world; its text is `ink` and `ink_soft` only.
|
|
pub const HOME_VEIL: f32 = 0.80;
|
|
/// Muted text over the veiled backdrop: the dark palette's `muted`, lifted so it holds AA there.
|
|
pub const BACKDROP_MUTED: u32 = 0x9fa3ae;
|
|
/// The brightest backdrop the veils are measured against: a light grey of luminance 0.77,
|
|
/// brighter than the snowiest title panorama measured (0.49) and a sunlit world.
|
|
pub const BRIGHT_BACKDROP: u32 = 0xe3e3e3;
|
|
|
|
impl Palette {
|
|
/// Every colour with its token name, in the order the docs list them.
|
|
pub fn named(&self) -> [(&'static str, u32); 12] {
|
|
[
|
|
("page", self.page),
|
|
("plate-soft", self.plate_soft),
|
|
("plate", self.plate),
|
|
("surface", self.surface),
|
|
("rule", self.rule),
|
|
("ink", self.ink),
|
|
("ink-soft", self.ink_soft),
|
|
("muted", self.muted),
|
|
("accent", self.accent),
|
|
("on-accent", self.on_accent),
|
|
("danger", self.danger),
|
|
("success", self.success),
|
|
]
|
|
}
|
|
}
|
|
|
|
impl Blocks {
|
|
/// Every colour by its CSS name. The website reads the Blocks palettes; the first twelve keep
|
|
/// the names of [`Palette::named`] (`plate-soft` is `raised`, `plate` is `button`, `surface`
|
|
/// is `panel`, `on-accent` is `on_face`), so styles written for those still apply.
|
|
pub fn named(&self) -> [(&'static str, u32); 25] {
|
|
[
|
|
("page", self.page),
|
|
("plate-soft", self.raised),
|
|
("plate", self.button),
|
|
("surface", self.panel),
|
|
("rule", self.rule),
|
|
("ink", self.ink),
|
|
("ink-soft", self.ink_soft),
|
|
("muted", self.muted),
|
|
("accent", self.accent),
|
|
("on-accent", self.on_face),
|
|
("danger", self.danger),
|
|
("success", self.success),
|
|
("warn", self.warn),
|
|
("side", self.side),
|
|
("panel", self.panel),
|
|
("raised", self.raised),
|
|
("button", self.button),
|
|
("button-bevel", self.button_bevel),
|
|
("well", self.well),
|
|
("bevel", self.bevel),
|
|
("lip", self.lip),
|
|
("outline", self.outline),
|
|
("face", self.face),
|
|
("face-bevel", self.face_bevel),
|
|
("face-lip", self.face_lip),
|
|
]
|
|
}
|
|
}
|
|
|
|
/// The WCAG 2 contrast ratio of two colours.
|
|
pub fn contrast(a: u32, b: u32) -> f32 {
|
|
fn luminance(c: u32) -> f32 {
|
|
let ch = |s: u32| {
|
|
let v = ((c >> s) & 255) as f32 / 255.;
|
|
if v <= 0.03928 {
|
|
v / 12.92
|
|
} else {
|
|
((v + 0.055) / 1.055).powf(2.4)
|
|
}
|
|
};
|
|
0.2126 * ch(16) + 0.7152 * ch(8) + 0.0722 * ch(0)
|
|
}
|
|
let (x, y) = (luminance(a), luminance(b));
|
|
(x.max(y) + 0.05) / (x.min(y) + 0.05)
|
|
}
|
|
|
|
/// `tokens.css`: every token as a CSS custom property, the light palette by
|
|
/// default, the dark one when the system asks for it or the page sets `data-theme="dark"`.
|
|
pub fn css() -> String {
|
|
let mut s = String::new();
|
|
let num = |v: f32| {
|
|
let t = format!("{v:.3}");
|
|
t.trim_end_matches('0').trim_end_matches('.').to_string()
|
|
};
|
|
// The website wears the launcher's Blocks look; the game's menus keep LIGHT and DARK.
|
|
let colors = |s: &mut String, p: &Blocks| {
|
|
for (name, c) in p.named() {
|
|
let _ = writeln!(s, " --{name}: #{c:06x};");
|
|
}
|
|
let _ = writeln!(s, " --on-face: #{:06x};", p.on_face);
|
|
let _ = writeln!(
|
|
s,
|
|
" color-scheme: {};",
|
|
if p.dark { "dark" } else { "light" }
|
|
);
|
|
};
|
|
s.push_str(
|
|
"/* Generated from crates/horizon-theme by `cargo run -p xtask -- tokens`; do not edit.\n \
|
|
docs/design-system.md describes the tokens. */\n\n:root {\n",
|
|
);
|
|
colors(&mut s, &BLOCKS_LIGHT);
|
|
let _ = writeln!(
|
|
s,
|
|
" --font: \"{FONT}\", ui-sans-serif, system-ui, sans-serif;\n --mono: \"{MONO}\", ui-monospace, monospace;\n --pixel: \"{PIXEL}\", \"{FONT}\", ui-sans-serif, sans-serif;"
|
|
);
|
|
for (name, v) in [
|
|
("block-outline", BLOCK_OUTLINE),
|
|
("block-bevel", BLOCK_BEVEL),
|
|
("block-lip", BLOCK_LIP),
|
|
] {
|
|
let _ = writeln!(s, " --{name}: {}px;", num(v));
|
|
}
|
|
for step in TYPE {
|
|
let size = match step.web {
|
|
Web::Fixed => format!("{}px", num(step.px)),
|
|
Web::Fluid(min, max) => format!(
|
|
"clamp({}px, calc({}px + {} * (100vw - 375px) / 1065), {}px)",
|
|
num(min),
|
|
num(min),
|
|
num(max - min),
|
|
num(max)
|
|
),
|
|
Web::Viewport(vw) => format!("{}vw", num(vw)),
|
|
};
|
|
let n = step.name;
|
|
let _ = writeln!(s, " --text-{n}: {size};");
|
|
let _ = writeln!(s, " --text-{n}-line: {};", num(step.line));
|
|
let _ = writeln!(s, " --text-{n}-tracking: {}em;", num(step.tracking));
|
|
let _ = writeln!(s, " --text-{n}-weight: {};", step.weight);
|
|
}
|
|
for (i, v) in SPACE.iter().enumerate() {
|
|
let _ = writeln!(s, " --space-{}: {}px;", i + 1, num(*v));
|
|
}
|
|
for (name, v) in [
|
|
("gutter", GUTTER),
|
|
("gutter-narrow", GUTTER_NARROW),
|
|
("radius", RADIUS),
|
|
("radius-plate", RADIUS_PLATE),
|
|
("radius-pill", RADIUS_PILL),
|
|
("rule-width", RULE),
|
|
("control", CONTROL),
|
|
("control-large", CONTROL_LARGE),
|
|
("segment", SEGMENT),
|
|
("field", FIELD),
|
|
("row", ROW_WEB),
|
|
("index", INDEX),
|
|
("plate-bleed", PLATE_BLEED),
|
|
("rise", RISE),
|
|
] {
|
|
let _ = writeln!(s, " --{name}: {}px;", num(v));
|
|
}
|
|
let _ = writeln!(s, " --measure: {}ch;", num(MEASURE));
|
|
for m in MOTION {
|
|
let [a, b, c, d] = m.curve;
|
|
let _ = writeln!(s, " --{}: {}ms;", m.name, m.ms);
|
|
let _ = writeln!(
|
|
s,
|
|
" --ease-{}: cubic-bezier({}, {}, {}, {});",
|
|
m.name,
|
|
num(a),
|
|
num(b),
|
|
num(c),
|
|
num(d)
|
|
);
|
|
}
|
|
let _ = writeln!(s, " --stagger: {}ms;", STAGGER.as_millis());
|
|
s.push_str(
|
|
"}\n\n@media (prefers-color-scheme: dark) {\n :root:not([data-theme=\"light\"]) {\n",
|
|
);
|
|
let mut dark = String::new();
|
|
colors(&mut dark, &BLOCKS_DARK);
|
|
for line in dark.lines() {
|
|
let _ = writeln!(s, " {line}");
|
|
}
|
|
s.push_str(" }\n}\n\n:root[data-theme=\"dark\"] {\n");
|
|
s.push_str(&dark);
|
|
s.push_str("}\n");
|
|
s
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// Every text colour on every background it is drawn on reaches WCAG AA (4.5:1).
|
|
#[test]
|
|
fn text_meets_wcag_aa_in_both_palettes() {
|
|
for p in [LIGHT, DARK] {
|
|
let backgrounds = [
|
|
("page", p.page),
|
|
("plate_soft", p.plate_soft),
|
|
("plate", p.plate),
|
|
("surface", p.surface),
|
|
];
|
|
let texts = [
|
|
("ink", p.ink),
|
|
("ink_soft", p.ink_soft),
|
|
("muted", p.muted),
|
|
("accent", p.accent),
|
|
("danger", p.danger),
|
|
("success", p.success),
|
|
];
|
|
for (bn, bg) in backgrounds {
|
|
for (tn, fg) in texts {
|
|
let ratio = contrast(fg, bg);
|
|
assert!(
|
|
ratio >= 4.5,
|
|
"{} {tn} on {bn}: {ratio:.2}",
|
|
if p.dark { "dark" } else { "light" }
|
|
);
|
|
}
|
|
}
|
|
// Text on the accent, and page-coloured text on an ink segment.
|
|
assert!(contrast(p.on_accent, p.accent) >= 4.5);
|
|
assert!(contrast(p.page, p.ink) >= 4.5);
|
|
}
|
|
}
|
|
|
|
/// The blocky look: every text colour on every background it is drawn on reaches AA, the
|
|
/// main action's label too, and the focus ring (the accent) reaches 3:1 (WCAG 1.4.11).
|
|
#[test]
|
|
fn blocks_text_meets_wcag_aa_in_both_appearances() {
|
|
for p in [BLOCKS_DARK, BLOCKS_LIGHT] {
|
|
let theme = if p.dark { "dark" } else { "light" };
|
|
let backgrounds = [
|
|
("page", p.page),
|
|
("side", p.side),
|
|
("panel", p.panel),
|
|
("raised", p.raised),
|
|
("button", p.button),
|
|
("well", p.well),
|
|
];
|
|
let texts = [
|
|
("ink", p.ink),
|
|
("ink_soft", p.ink_soft),
|
|
("muted", p.muted),
|
|
("accent", p.accent),
|
|
("danger", p.danger),
|
|
("success", p.success),
|
|
("warn", p.warn),
|
|
];
|
|
for (bn, bg) in backgrounds {
|
|
for (tn, fg) in texts {
|
|
let ratio = contrast(fg, bg);
|
|
assert!(ratio >= 4.5, "{theme} {tn} on {bn}: {ratio:.2}");
|
|
}
|
|
// The texture's lightest shade under the page's text.
|
|
let lifted = TILE_SHADES.iter().max().copied().unwrap_or(0) as i32;
|
|
let shade = [16, 8, 0].iter().fold(0, |out, s| {
|
|
let c = (((p.page >> s) & 255) as i32 + lifted).clamp(0, 255) as u32;
|
|
out | c << s
|
|
});
|
|
for (tn, fg) in texts {
|
|
let ratio = contrast(fg, shade);
|
|
assert!(
|
|
ratio >= 4.5,
|
|
"{theme} {tn} on the stone texture: {ratio:.2}"
|
|
);
|
|
}
|
|
}
|
|
let label = contrast(p.on_face, p.face);
|
|
assert!(label >= 4.5, "{theme} the main action's label: {label:.2}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn every_icon_is_a_pixel_svg() {
|
|
for (name, svg) in icons::ALL {
|
|
let svg = std::str::from_utf8(svg).unwrap();
|
|
assert!(svg.contains("viewBox=\"0 0 24 24\""), "{name}");
|
|
assert!(svg.contains("currentColor"), "{name}");
|
|
}
|
|
assert!(icons::get("play").is_some());
|
|
}
|
|
|
|
/// The game's menus over the brightest backdrop: the page colour veiled over it, and a
|
|
/// selected row's plate (ink at 8 %) on top.
|
|
#[test]
|
|
fn menu_text_meets_wcag_aa_over_a_bright_backdrop() {
|
|
let mix = |a: u32, b: u32, t: f32| {
|
|
[16, 8, 0].iter().fold(0, |out, s| {
|
|
let (x, y) = (((a >> s) & 255) as f32, ((b >> s) & 255) as f32);
|
|
out | ((x * (1. - t) + y * t).round() as u32) << s
|
|
})
|
|
};
|
|
let p = DARK;
|
|
for (veil, texts) in [
|
|
(
|
|
VEIL,
|
|
&[p.ink, p.ink_soft, BACKDROP_MUTED, p.danger, p.success][..],
|
|
),
|
|
(HOME_VEIL, &[p.ink, p.ink_soft][..]),
|
|
] {
|
|
let bg = mix(BRIGHT_BACKDROP, p.page, veil);
|
|
let plate = mix(bg, p.ink, 0.08);
|
|
for &text in texts {
|
|
for under in [bg, plate] {
|
|
let ratio = contrast(text, under);
|
|
assert!(ratio >= 4.5, "#{text:06x} over veil {veil}: {ratio:.2}");
|
|
}
|
|
}
|
|
}
|
|
assert!(contrast(BACKDROP_MUTED, p.page) >= 4.5);
|
|
}
|
|
|
|
#[test]
|
|
fn contrast_matches_known_values() {
|
|
assert!((contrast(0x000000, 0xffffff) - 21.).abs() < 0.01);
|
|
assert!((contrast(0x777777, 0xffffff) - 4.48).abs() < 0.01);
|
|
}
|
|
|
|
#[test]
|
|
fn bezier_eases_out() {
|
|
assert_eq!(bezier(MOVE.curve, 0.), 0.);
|
|
assert!((bezier(MOVE.curve, 1.) - 1.).abs() < 1e-4);
|
|
assert!(MOVE.ease(0.25) > 0.6, "{}", MOVE.ease(0.25));
|
|
assert!((bezier([0., 0., 1., 1.], 0.3) - 0.3).abs() < 1e-3);
|
|
}
|
|
|
|
/// `tokens.css` (beside Cargo.toml, read by the website's build) is what [`css`] writes;
|
|
/// regenerate it with `cargo run -p xtask -- tokens`.
|
|
#[test]
|
|
fn tokens_css_is_up_to_date() {
|
|
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/tokens.css");
|
|
let written = std::fs::read_to_string(path).unwrap_or_default();
|
|
assert!(
|
|
written == css(),
|
|
"crates/horizon-theme/tokens.css is out of date: run `cargo run -p xtask -- tokens`"
|
|
);
|
|
}
|
|
}
|