#![forbid(unsafe_code)]
use makeover_layout::{Bevel, Depth, Fill, Intent, RowPart, Selector, Token, Tone};
use std::fmt::Write as _;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Emit {
pub border_width: &'static str,
pub class_prefix: &'static str,
}
impl Default for Emit {
fn default() -> Self {
Self {
border_width: "1px",
class_prefix: "",
}
}
}
#[must_use]
pub fn bevel_var(bevel: Bevel) -> &'static str {
match bevel {
Bevel::Raised => "--bevel-raised",
Bevel::Inset => "--bevel-inset",
}
}
#[must_use]
pub fn fill_var(fill: Fill) -> String {
match fill {
Fill::Well => format!("var(--{}, var(--{}))", fill.token(), Fill::Page.token()),
other => format!("var(--{})", other.token()),
}
}
#[must_use]
pub fn bevel_shadow(bevel: Bevel, opts: &Emit) -> String {
let (top_left, bottom_right) = bevel.edges();
let w = opts.border_width;
format!(
"inset {w} {w} 0 var(--{}), inset -{w} -{w} 0 var(--{})",
top_left.token(),
bottom_right.token()
)
}
#[must_use]
pub fn bevel_properties(opts: &Emit) -> String {
let mut css = String::new();
for bevel in [Bevel::Raised, Bevel::Inset] {
let _ = writeln!(
css,
" {}: {};",
bevel_var(bevel),
bevel_shadow(bevel, opts)
);
}
css
}
#[must_use]
pub fn depth_class(depth: Depth, opts: &Emit) -> Option<String> {
let name = match depth {
Depth::Flat => return None,
Depth::Raised => "raised",
Depth::Well => "well",
Depth::Sunken => "sunken",
};
Some(format!("{}{name}", opts.class_prefix))
}
fn class(name: &str, opts: &Emit) -> String {
format!("{}{name}", opts.class_prefix)
}
#[must_use]
pub fn depth_declarations(depth: Depth) -> String {
let mut css = String::new();
if let Some(fill) = depth.fill() {
let _ = writeln!(css, " background: {};", fill_var(fill));
}
if let Some(bevel) = depth.bevel() {
let _ = writeln!(css, " box-shadow: var({});", bevel_var(bevel));
}
css
}
#[must_use]
pub fn depth_rule(selector: &str, depth: Depth) -> String {
let body = depth_declarations(depth);
if body.is_empty() {
return String::new();
}
format!(".{selector} {{\n{body}}}\n")
}
#[must_use]
pub fn interactive_rules(selector: &str) -> String {
format!(
".{selector}:hover {{\n background: var(--hover-surface);\n}}\n{}",
depth_rule(&format!("{selector}:active"), Depth::Raised.pressed())
)
}
#[must_use]
pub fn depth_rules(opts: &Emit) -> String {
let mut css = String::new();
for depth in [Depth::Raised, Depth::Well] {
let Some(class) = depth_class(depth, opts) else {
continue;
};
css.push_str(&depth_rule(&class, depth));
}
if let Some(raised) = depth_class(Depth::Raised, opts) {
css.push_str(&interactive_rules(&raised));
}
css
}
fn surface_rules(opts: &Emit) -> String {
let mut css = String::new();
for name in ["button", "card"] {
let c = class(name, opts);
css.push_str(&depth_rule(&c, Depth::Raised));
css.push_str(&interactive_rules(&c));
}
let field = class("field", opts);
css.push_str(&depth_rule(&field, Depth::Well));
let _ = writeln!(
css,
".{field}[aria-invalid=\"true\"] {{\n box-shadow: var({}), 0 0 0 {} var(--danger);\n}}",
bevel_var(Bevel::Inset),
opts.border_width
);
css
}
fn token_rules(opts: &Emit) -> String {
let mut css = String::new();
let badge = class("badge", opts);
let _ = writeln!(
css,
".{badge} {{\n color: var(--{});\n}}",
Tone::Neutral.token()
);
for tone in [Tone::Info, Tone::Success, Tone::Warning, Tone::Danger] {
let _ = writeln!(
css,
".{badge}[data-tone=\"{0}\"] {{\n color: var(--{0});\n}}",
tone.token()
);
}
let chip = class("chip", opts);
let unlatched = Token::Chip { removable: false };
css.push_str(&depth_rule(&chip, unlatched.depth(false)));
css.push_str(&interactive_rules(&chip));
css.push_str(&depth_rule(
&format!("{chip}.latched"),
unlatched.depth(true),
));
css
}
fn selector_rules(opts: &Emit) -> String {
let mut css = String::new();
for (selector, name) in [
(Selector::Tabs, "tab"),
(Selector::Segmented, "segment"),
(Selector::Toggle, "toggle"),
] {
let c = class(name, opts);
css.push_str(&depth_rule(&c, selector.unchosen()));
css.push_str(&interactive_rules(&c));
css.push_str(&depth_rule(&format!("{c}.chosen"), selector.chosen()));
}
css
}
fn row_rules(opts: &Emit) -> String {
let mut css = String::new();
let row = class("row", opts);
for part in [
RowPart::Primary,
RowPart::Secondary,
RowPart::Meta,
RowPart::Actions,
] {
let name = match part {
RowPart::Primary => "row-primary",
RowPart::Secondary => "row-secondary",
RowPart::Meta => "row-meta",
RowPart::Actions => "row-actions",
};
let c = class(name, opts);
if !matches!(part, RowPart::Actions) {
let _ = writeln!(css, ".{c} {{\n color: var(--{});\n}}", part.intent());
}
if part.revealed_on_hover() {
let _ = writeln!(css, ".{c} {{\n visibility: hidden;\n}}");
let _ = writeln!(
css,
".{row}:hover .{c},\n.{row}:focus-within .{c} {{\n visibility: visible;\n}}"
);
}
}
css
}
fn progress_rules(opts: &Emit) -> String {
let progress = class("progress", opts);
let fill = class("progress-fill", opts);
let mut css = depth_rule(&progress, Depth::Well);
let _ = writeln!(
css,
".{progress} > .{fill} {{\n background: var(--action);\n}}"
);
for tone in [Tone::Info, Tone::Success, Tone::Warning, Tone::Danger] {
let _ = writeln!(
css,
".{progress} > .{fill}[data-tone=\"{0}\"] {{\n background: var(--{0});\n}}",
tone.token()
);
}
css
}
#[must_use]
pub fn component_rules(opts: &Emit) -> String {
let mut css = String::new();
css.push_str(&surface_rules(opts));
css.push_str(&token_rules(opts));
css.push_str(&selector_rules(opts));
css.push_str(&row_rules(opts));
css.push_str(&progress_rules(opts));
css
}
#[must_use]
pub fn stylesheet(opts: &Emit) -> String {
format!(
"/* Generated by makeover-webview from makeover-layout. Do not edit.\n \
Depth is a fill and an edge together; naming them apart is what let\n \
them disagree. See the crate's README and wiki note makeover-layout. */\n\
:root {{\n{}}}\n\n{}\n{}",
bevel_properties(opts),
depth_rules(opts),
component_rules(opts)
)
}
#[cfg(test)]
mod tests {
use super::*;
use makeover_layout::Edge;
#[test]
fn the_emitted_bevel_matches_what_the_apps_already_hand_write() {
let opts = Emit::default();
assert_eq!(
bevel_shadow(Bevel::Raised, &opts),
"inset 1px 1px 0 var(--bevel-light), inset -1px -1px 0 var(--bevel-dark)"
);
assert_eq!(
bevel_shadow(Bevel::Inset, &opts),
"inset 1px 1px 0 var(--bevel-dark), inset -1px -1px 0 var(--bevel-light)"
);
}
#[test]
fn no_colour_ever_reaches_the_output() {
let css = stylesheet(&Emit::default());
assert!(!css.contains('#'), "a hex literal escaped into the CSS");
assert!(
!css.contains("rgb"),
"a colour function escaped into the CSS"
);
assert!(css.contains("var(--surface-raised)"));
assert!(css.contains("var(--bevel-light)"));
}
#[test]
fn a_well_falls_back_through_css_rather_than_through_rust() {
assert_eq!(
fill_var(Fill::Well),
"var(--surface-well, var(--surface-page))"
);
assert_eq!(fill_var(Fill::Raised), "var(--surface-raised)");
assert_eq!(fill_var(Fill::Page), "var(--surface-page)");
}
#[test]
fn raised_and_well_do_not_collapse_onto_each_other() {
let css = depth_rules(&Emit::default());
assert!(css.contains(".raised {"));
assert!(css.contains(".well {"));
assert!(css.contains("var(--bevel-raised)"));
assert!(css.contains("var(--bevel-inset)"));
}
#[test]
fn the_cascade_carries_the_pressed_state() {
let css = depth_rules(&Emit::default());
assert!(css.contains(".raised:active {"));
}
#[test]
fn pressing_moves_the_fill_and_not_only_the_edge() {
let pressed = interactive_rules("button");
assert!(pressed.contains(".button:active {"));
assert!(
pressed.contains("background: var(--surface-well, var(--surface-page))"),
"pressed dropped its fill: {pressed}"
);
assert!(pressed.contains("box-shadow: var(--bevel-inset)"));
}
#[test]
fn pressed_takes_its_fill_from_the_description_not_from_the_app() {
let css = stylesheet(&Emit::default());
let mut checked = 0;
for rule in css.split("}\n") {
if !rule.contains(":active") {
continue;
}
checked += 1;
assert!(
!rule.contains("surface-sunken"),
"a pressed rule took the app's fill: {rule}"
);
}
assert!(checked > 0, "no pressed rules found to check");
assert_eq!(
Depth::Raised.pressed().fill(),
Some(Fill::Well),
"the description changed under us"
);
}
#[test]
fn hover_resolves_against_the_token_makeover_already_derives() {
let css = interactive_rules("card");
assert!(css.contains(".card:hover {"));
assert!(css.contains("background: var(--hover-surface)"));
assert!(!css.contains("surface-overlay"));
}
#[test]
fn a_badge_gets_no_edge_and_no_fill() {
let css = token_rules(&Emit::default());
let badge = css
.lines()
.skip_while(|l| !l.starts_with(".badge {"))
.take_while(|l| !l.starts_with('}'))
.collect::<Vec<_>>()
.join("\n");
assert!(!badge.contains("box-shadow"), "badge kept an edge: {badge}");
assert!(!badge.contains("background"), "badge kept a fill: {badge}");
assert_eq!(Token::Badge.depth(false), Depth::Flat);
assert_eq!(Token::Badge.depth(true), Depth::Flat);
}
#[test]
fn a_badge_carries_a_tone_and_neutral_is_the_bare_class() {
let css = token_rules(&Emit::default());
assert!(css.contains(".badge {\n color: var(--content-muted);"));
assert!(!css.contains("data-tone=\"content-muted\""));
for tone in ["info", "success", "warning", "danger"] {
assert!(
css.contains(&format!(".badge[data-tone=\"{tone}\"]")),
"missing tone {tone}"
);
assert!(css.contains(&format!("color: var(--{tone})")));
}
}
#[test]
fn a_chip_is_raised_and_latches_into_a_well() {
let css = token_rules(&Emit::default());
assert!(css.contains(".chip {"));
assert!(css.contains(".chip.latched {"));
assert!(css.contains(".chip:active {"));
assert!(Token::Chip { removable: false }.interactive());
assert!(!Token::Badge.interactive());
}
#[test]
fn only_a_tab_comes_forward_when_chosen() {
let css = selector_rules(&Emit::default());
assert!(css.contains(".tab.chosen {"));
assert!(css.contains(".segment.chosen {"));
assert!(css.contains(".toggle.chosen {"));
assert_eq!(Selector::Tabs.chosen(), Depth::Raised);
assert_eq!(Selector::Segmented.chosen(), Depth::Well);
assert_eq!(Selector::Toggle.chosen(), Depth::Well);
let tab = css
.lines()
.skip_while(|l| !l.starts_with(".tab.chosen {"))
.take_while(|l| !l.starts_with('}'))
.collect::<Vec<_>>()
.join("\n");
assert!(tab.contains("var(--bevel-raised)"), "tab was held in: {tab}");
}
#[test]
fn an_unchosen_tab_recedes_without_looking_picked() {
let css = selector_rules(&Emit::default());
assert!(
css.contains(".tab {\n background: var(--surface-sunken);\n}"),
"unchosen tab is not recessed: {css}"
);
assert_eq!(Selector::Tabs.unchosen(), Depth::Sunken);
assert!(css.contains(".tab:hover {"));
}
#[test]
fn a_segment_stands_up_so_the_chosen_one_can_be_held_in() {
let css = selector_rules(&Emit::default());
assert!(css.contains(".segment {\n background: var(--surface-raised);"));
assert_eq!(Selector::Segmented.unchosen(), Depth::Raised);
assert_eq!(Selector::Segmented.chosen(), Depth::Well);
}
#[test]
fn row_actions_are_revealed_without_moving_the_row() {
let css = row_rules(&Emit::default());
assert!(css.contains(".row-actions {\n visibility: hidden;"));
assert!(!css.contains("display: none"));
assert!(css.contains(".row:focus-within .row-actions"));
assert!(RowPart::Actions.revealed_on_hover());
}
#[test]
fn the_three_text_parts_take_their_intents_and_actions_inherits() {
let css = row_rules(&Emit::default());
assert!(css.contains(".row-primary {\n color: var(--content);"));
assert!(css.contains(".row-secondary {\n color: var(--content-secondary);"));
assert!(css.contains(".row-meta {\n color: var(--content-muted);"));
assert!(!css.contains(".row-actions {\n color:"));
}
#[test]
fn the_progress_trough_is_a_well() {
let css = progress_rules(&Emit::default());
assert!(css.contains(".progress {"));
assert!(css.contains("box-shadow: var(--bevel-inset)"));
assert!(css.contains(".progress > .progress-fill {"));
assert!(css.contains("background: var(--action)"));
assert!(!css.contains("> .fill "));
}
#[test]
fn a_progress_bar_can_carry_a_tone_and_defaults_to_action() {
let css = progress_rules(&Emit::default());
assert!(css.contains(".progress > .progress-fill {\n background: var(--action);"));
assert!(!css.contains("progress-fill {\n color: var(--content-muted)"));
for tone in ["info", "success", "warning", "danger"] {
assert!(
css.contains(&format!(
".progress > .progress-fill[data-tone=\"{tone}\"]"
)),
"missing progress tone {tone}"
);
}
assert!(css.contains("[data-tone=\"success\"] {\n background: var(--success);"));
assert!(css.contains("[data-tone=\"danger\"] {\n background: var(--danger);"));
}
#[test]
fn no_scrollbar_track_is_emitted() {
let css = stylesheet(&Emit::default());
assert!(!css.contains("scrollbar"));
assert!(!css.contains("::-webkit"));
}
#[test]
fn an_invalid_field_is_ringed_without_being_lit() {
let css = surface_rules(&Emit::default());
assert!(css.contains(".field {"));
assert!(css.contains(".field[aria-invalid=\"true\"] {"));
assert!(!css.contains(".field.invalid"));
assert!(css.contains("0 0 0 1px var(--danger)"));
}
#[test]
fn an_invalid_field_keeps_the_well_underneath_it() {
let css = surface_rules(&Emit::default());
let invalid = css
.lines()
.skip_while(|l| !l.starts_with(".field[aria-invalid"))
.take_while(|l| !l.starts_with('}'))
.collect::<Vec<_>>()
.join("\n");
assert!(
invalid.contains("var(--bevel-inset)"),
"the well was dropped: {invalid}"
);
assert!(invalid.contains("var(--danger)"));
}
#[test]
fn button_and_card_come_out_identical_by_construction() {
let opts = Emit::default();
let css = surface_rules(&opts);
assert_eq!(
depth_declarations(Depth::Raised),
depth_declarations(Depth::Raised)
);
assert!(css.contains(".button {"));
assert!(css.contains(".card {"));
assert_eq!(
interactive_rules("button").replace("button", "card"),
interactive_rules("card")
);
}
#[test]
fn a_prefix_reaches_the_component_classes_too() {
let opts = Emit {
class_prefix: "mo-",
..Emit::default()
};
let css = stylesheet(&opts);
for name in [
"mo-button",
"mo-card",
"mo-field",
"mo-badge",
"mo-chip",
"mo-tab",
"mo-row-primary",
"mo-progress",
"mo-progress-fill",
] {
assert!(css.contains(&format!(".{name}")), "unprefixed: {name}");
}
assert!(!css.contains(".button {"));
assert!(!css.contains(".card {"));
assert!(!css.contains(".badge {"));
}
#[test]
fn the_whole_sheet_still_names_every_colour() {
let css = stylesheet(&Emit::default());
assert!(!css.contains('#'));
assert!(!css.contains("rgb"));
for line in css.lines() {
let declaration = line.strip_prefix(" ").map(str::trim);
let Some(Some((_, value))) = declaration.map(|d| d.split_once(": ")) else {
continue;
};
if value.contains("var(--") {
continue;
}
assert!(
value.contains("hidden")
|| value.contains("visible")
|| value.contains("inset"),
"unrecognised literal value: {line}"
);
}
}
#[test]
fn flat_emits_nothing_at_all() {
assert_eq!(depth_class(Depth::Flat, &Emit::default()), None);
assert!(!depth_rules(&Emit::default()).contains("flat"));
}
#[test]
fn a_prefix_namespaces_every_class() {
let opts = Emit {
class_prefix: "mo-",
..Emit::default()
};
let css = depth_rules(&opts);
assert!(css.contains(".mo-raised {"));
assert!(css.contains(".mo-well {"));
assert!(!css.contains(".raised {"));
}
#[test]
fn the_border_width_is_the_callers() {
let opts = Emit {
border_width: "2px",
..Emit::default()
};
assert!(bevel_shadow(Bevel::Raised, &opts).contains("inset 2px 2px 0"));
}
#[test]
fn edges_agree_with_the_description() {
let (tl, br) = Bevel::Raised.edges();
assert_eq!(tl.token(), Edge::Light.token());
assert_eq!(br.token(), Edge::Dark.token());
}
}