//! The one palette, the one mark, and the CSS that draws the field. //! //! atgc renders itself in a browser in two places now — the pages the OAuth //! callback serves ([`super::pages`]) and the site `site` builds — //! and the promise `brand/` was built on is that atgc looks like one thing //! wherever it is drawn. Two pages that each declared their own `--a` //! is exactly the drift `brand/build.py --check` exists to catch on the //! raster side, with nothing catching it here. //! //! So the custom properties are declared once, the mark is drawn once, and //! the field's stylesheet is written once. A page picks the parts it needs //! and adds only what is its own. //! //! # Why the mark is drawn in custom properties //! //! `brand/svg/logomark-dark.svg` bakes its palette in, which needs a light //! copy beside it. The copy here paints in `var(--a)`/`var(--t)`/`var(--g)`/ //! `var(--c)`/`var(--bond)` instead, so it inverts with whatever page it is //! inlined into. The one context that cannot resolve those is a favicon — //! a separate document, with no host stylesheet — which is what //! [`favicon_link`] substitutes concrete colours for. /// The light palette and the dark one, as the body of a `:root` rule. /// /// These are `brand/gen.py`'s own `LIGHT` and `DARK`, not a web-flavoured /// approximation of them beside it — the same `#0d1117` behind the app icon /// is the background of the site, and the four bases are the exact values /// every raster in `brand/png` is drawn with. That was the point of putting /// the palette in a generator: a hand-mixed hex in a stylesheet is the drift /// `brand/build.py --check` exists to catch, and until now the markup side /// had no equivalent. /// /// The four base colours are the chromatogram convention [`crate::art`] /// reads them in — A green, T red, G yellow, C cyan. They are kept apart /// from `--fg`/`--muted`/`--edge` for the same reason [`crate::art::Ink`] /// keeps them out of `term::style`: retheming atgc's secondary text should /// not repaint the field's hydrogen bonds. /// /// `--field`, `--sweep` and `--blend` are the three the field's rendering /// needs to differ on, since a light band over art reads as a wash on white /// and as a glow on black. const DARK_VARS: &str = "\ --bg: #0d1117; --fg: #e6edf3; --card: #161b22; --muted: #9aa7b4; --halo: rgba(13,17,23,.92); --edge: rgba(230,237,243,.10); --bond: #7d8590; --a: #5ddc7c; --t: #ff7f74; --g: #f2c94c; --c: #4fd6e0; --field: .62; --sweep: rgba(255,255,255,.12); --blend: screen;"; const LIGHT_VARS: &str = "\ --bg: #ffffff; --fg: #1c2128; --card: #f6f8fa; --muted: #57606a; --halo: rgba(255,255,255,.92); --edge: rgba(28,33,40,.12); --bond: #57606a; --a: #1a9850; --t: #d33f36; --g: #b8860b; --c: #0e8ba0; --field: .8; --sweep: rgba(28,33,40,.11); --blend: multiply;"; /// Which way round a page's `:root` is written. /// /// Not a style question but a question about what the page is *for*. A /// browser opened by `auth login` is a window somebody's own machine threw /// up mid-command, and it should look like the rest of that machine — so it /// follows the system. The site is a thing somebody arrives at, and atgc is /// a terminal client: black is what it looks like, and following a light /// system setting there would show a stranger the version of atgc that is /// least like atgc. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum Scheme { /// Light, unless the reader's system asks for dark. System, /// Dark, unless the reader's system asks for light. Only the site /// wants this, so it is compiled where the site is. #[cfg(any(feature = "www", test))] DarkFirst, } /// The palette, and nothing else, as CSS. Every page includes this block /// first and then styles with `var(...)`. /// /// Both orderings are written from the same two palettes rather than as two /// stylesheets, so a colour can only ever be changed in one place. Which /// block wins is the media query's business; which colours are in it is not /// negotiable per page. pub(crate) fn theme_css(scheme: Scheme) -> String { let (first, second, query, declared) = match scheme { Scheme::System => (LIGHT_VARS, DARK_VARS, "dark", "light dark"), #[cfg(any(feature = "www", test))] Scheme::DarkFirst => (DARK_VARS, LIGHT_VARS, "light", "dark light"), }; format!( " :root {{\n color-scheme: {declared};\n{first}\n }}\n \ @media (prefers-color-scheme: {query}) {{\n :root {{\n{second}\n }}\n }}" ) } /// The field of DNA behind a page: one prerendered `
`, centred and
/// cropped by the viewport.
///
/// The fade is on the box rather than on the art, so it lands at the screen
/// edges whatever size the art came out, and where the art is too small to
/// reach them it has already faded to nothing by the time it ends. Isolated
/// so the sweep's blend mode stops there rather than reaching what is on top
/// of it.
///
/// Everything animated is a property the compositor can handle on its own —
/// an opacity breath on the field and a light band drifting along the axis
/// of the helices — so nothing here relayouts, and `prefers-reduced-motion`
/// turns both off rather than slowing them down.
pub(crate) const FIELD_CSS: &str = r#" .field { position: fixed; inset: 0; z-index: 0; overflow: hidden;
display: grid; place-items: center; isolation: isolate;
pointer-events: none; user-select: none;
--fade: radial-gradient(120% 105% at 50% 50%, #000 28%, transparent 88%);
-webkit-mask-image: var(--fade); mask-image: var(--fade); }
.field pre {
margin: 0; white-space: pre; color: var(--bond);
font-family: ui-monospace, SFMono-Regular, Menlo, "DejaVu Sans Mono", monospace;
font-size: clamp(9px, max(.62vw, 1.3vh), 20px); line-height: 1.15;
opacity: var(--field); animation: breathe 14s ease-in-out infinite;
}
.field i { font-style: normal; }
.field .a { color: var(--a); } .field .t { color: var(--t); }
.field .g { color: var(--g); } .field .c { color: var(--c); }
.sweep { position: absolute; inset: -60%; mix-blend-mode: var(--blend);
background: linear-gradient(41deg, transparent 40%, var(--sweep) 50%,
transparent 60%);
animation: drift 18s ease-in-out infinite alternate; }
@keyframes breathe { from, to { opacity: calc(var(--field) * .72); }
50% { opacity: var(--field); } }
@keyframes drift { from { transform: translate3d(-24%, 21%, 0); }
to { transform: translate3d(24%, -21%, 0); } }
@media (prefers-reduced-motion: reduce) {
.field pre, .sweep { animation: none; }
}"#;
/// The field, in the markup a page hangs it in. `aria-hidden` because it is
/// nineteen thousand cells of texture: read aloud it is noise, and there is
/// nothing in it a reader misses by skipping it.
pub(crate) fn field_markup() -> String {
format!(
r#""#,
super::field::field()
)
}
/// The mark from `brand/`: two base pairs, A-T over G-C, which is what the
/// name is.
///
/// Not `ATGC_MARK_SVG`, which is what it used to be called: a constant that
/// reads as an environment variable to a grep costs anyone auditing `ATGC_*`
/// a detour to discover it is neither.
pub(crate) const MARK_SVG: &str = r#""#;
/// Where the project lives, as `CARGO_PKG_HOMEPAGE`. The same address the
/// panel `atgc about` draws carries on its `site` line and `--help` ends on,
/// because there is one of it and `Cargo.toml` is where it is written down.
pub(crate) const SITE: &str = env!("CARGO_PKG_HOMEPAGE");
/// The header both loopback pages open with: the mark and the wordmark,
/// linked to [`SITE`], with the running version beside them.
///
/// One function rather than the two identical copies this was, and the link
/// is why it was worth collapsing them — a page that answered a login and a
/// page that explained why one failed had no business disagreeing about
/// where atgc comes from. The version stays outside the link: it is a fact
/// about the binary that served the page, not part of the mark.
///
/// `target="_blank"` because this tab was opened mid-command and closing it
/// is the instruction on both pages; a link that navigated away from the
/// answer would take the answer with it.
pub(crate) fn header_markup() -> String {
format!(
r#"
{MARK_SVG}
atgc
v{version}
"#,
version = env!("CARGO_PKG_VERSION")
)
}
/// The header's look, minus where it sits. Each page keeps its own margins
/// and its own answer to whether the row is centred — the card a login lands
/// on is centred text and the failure card is not — and everything else
/// about the header is the same on both, so it is declared once here.
pub(crate) const HEADER_CSS: &str = r#" .brand { display: flex; align-items: center; gap: .45rem;
border-bottom: 1px solid var(--edge); }
.brand .mark { width: 22px; height: 22px; flex: none; }
.brand-link { display: flex; align-items: center; gap: .45rem;
color: inherit; text-decoration: none; }
.brand-link:hover .brand-name { text-decoration: underline; }
.brand-name { font-weight: 700; color: var(--fg); }
.brand-version { color: var(--muted); font-size: .78rem; font-family: monospace; }"#;
/// The colours a favicon has to name outright.
///
/// A favicon is a document of its own: nothing in it can reach the host
/// page's `:root`, so [`MARK_SVG`]'s custom properties resolve to nothing
/// and the mark comes out invisible. These are the dark palette's four
/// bases, which carry on either tab colour, over `brand/`'s neutral strand
/// gray — the same compromise `brand/svg/logomark.svg` makes for a
/// background it cannot know.
const FAVICON_INK: [(&str, &str); 5] = [
("var(--bond)", "#77818d"),
("var(--a)", "#5ddc7c"),
("var(--t)", "#ff7f74"),
("var(--g)", "#f2c94c"),
("var(--c)", "#4fd6e0"),
];
/// A `` carrying the mark itself, as a data URI.
///
/// A data URI and not a file, because the two callers have opposite reasons
/// for wanting one: the OAuth pages are served by a loopback socket that
/// exists for a few seconds and has no second route to answer, and the site
/// is one self-contained file by rule. Either way this costs no request.
pub(crate) fn favicon_link() -> String {
format!(
r#""#,
encode_uri_component(&favicon_svg())
)
}
/// The same mark with its colours resolved, as an SVG document.
///
/// The site takes this as a file and lets its bundler decide whether to
/// inline it; the loopback pages have no bundler and no origin, so
/// [`favicon_link`] wraps it in a `data:` URI itself. One geometry either
/// way, which is why this is not gated with the rest of the site's assets:
/// `favicon_link` is compiled into every build.
pub(crate) fn favicon_svg() -> String {
let mut svg = MARK_SVG.to_string();
for (var, hex) in FAVICON_INK {
svg = svg.replace(var, hex);
}
svg
}
/// Percent-encode the characters that cannot stand in a `data:` URI inside
/// an HTML attribute. Not a general encoder: it is enough that the result
/// parses, and encoding the other 90% of an SVG's bytes would triple a
/// four-kilobyte mark for nothing.
fn encode_uri_component(text: &str) -> String {
let mut out = String::with_capacity(text.len() + text.len() / 8);
for ch in text.chars() {
match ch {
'%' => out.push_str("%25"),
'#' => out.push_str("%23"),
'"' => out.push_str("%22"),
'<' => out.push_str("%3C"),
'>' => out.push_str("%3E"),
'&' => out.push_str("%26"),
'\n' => out.push_str("%0A"),
ch => out.push(ch),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
/// The mark takes its colours from the page it is inlined into. A hex
/// value in it is a second palette, which is the whole thing this
/// module exists to prevent.
#[test]
fn the_mark_is_painted_in_the_page_s_own_properties() {
for var in ["--a", "--t", "--g", "--c", "--bond"] {
assert!(
MARK_SVG.contains(&format!("var({var})")),
"mark does not use var({var})"
);
}
assert!(!MARK_SVG.contains('#'), "mark hardcodes a colour");
}
/// Both palettes declare the same properties. A variable defined in
/// one and forgotten in the other does not fail loudly: it keeps the
/// value from the block that did declare it, which is the bug that is
/// hard to see.
#[test]
fn the_two_palettes_declare_the_same_properties() {
let names = |block: &str| {
let mut found: Vec = block
.split("--")
.skip(1)
.filter_map(|rest| rest.split(':').next())
.map(str::to_string)
.collect();
found.sort();
found.dedup();
found
};
assert_eq!(names(LIGHT_VARS), names(DARK_VARS));
for base in ["a", "c", "g", "t"] {
assert!(names(DARK_VARS).contains(&base.to_string()), "no --{base}");
}
}
/// The two orderings are the same two palettes, swapped. Which one a
/// page defaults to is the only thing `Scheme` decides — a page cannot
/// use it to smuggle in a colour of its own.
#[test]
fn a_scheme_only_chooses_which_palette_leads() {
let system = theme_css(Scheme::System);
let dark_first = theme_css(Scheme::DarkFirst);
assert!(system.contains(LIGHT_VARS) && system.contains(DARK_VARS));
assert!(dark_first.contains(LIGHT_VARS) && dark_first.contains(DARK_VARS));
// The leading block is the unconditional one, and the other is
// behind the query that asks for it.
assert!(
system
.split("@media")
.next()
.is_some_and(|r| r.contains(LIGHT_VARS))
);
assert!(
dark_first
.split("@media")
.next()
.is_some_and(|r| r.contains(DARK_VARS))
);
assert!(system.contains("prefers-color-scheme: dark"));
assert!(dark_first.contains("prefers-color-scheme: light"));
// And `color-scheme` names the default first, so the browser paints
// its own furniture to match before any of this is parsed.
assert!(system.contains("color-scheme: light dark"));
assert!(dark_first.contains("color-scheme: dark light"));
}
/// The favicon is the same geometry with the variables resolved: no
/// `var(` survives into it, because nothing would resolve one, and the
/// characters that would end the attribute are encoded.
#[test]
fn the_favicon_is_the_mark_with_its_colours_named() {
let link = favicon_link();
assert!(link.starts_with(r#""#));
assert!(!link.contains("var("), "a custom property survived");
// One quote pair only: the attribute's own. Anything else would end
// it early.
assert_eq!(link.matches('"').count(), 4, "unescaped quote in {link}");
for (_, hex) in FAVICON_INK {
assert!(link.contains(&format!("%23{}", &hex[1..])), "no {hex}");
}
}
/// The two animations the field declares are the two it turns off. A
/// keyframe with no reduced-motion answer is a page that keeps moving
/// for somebody who asked it not to.
#[test]
fn every_field_animation_is_given_up_under_reduced_motion() {
assert!(FIELD_CSS.contains("@keyframes breathe"));
assert!(FIELD_CSS.contains("@keyframes drift"));
let (_, reduced) = FIELD_CSS
.split_once("prefers-reduced-motion")
.expect("no reduced-motion block");
assert!(reduced.contains(".field pre") && reduced.contains(".sweep"));
assert!(reduced.contains("animation: none"));
}
/// The markup carries the terminal's own art, and hides it from a
/// screen reader: nineteen thousand cells of texture read aloud is
/// noise with nothing in it.
#[test]
fn the_field_markup_is_the_art_hidden_from_a_reader() {
let markup = field_markup();
assert!(markup.contains(&super::super::field::field()));
assert!(markup.contains(r#"aria-hidden="true""#));
}
}