//! 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""#)); } }