Something went wrong. Try again.
atproto git client
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384//! 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 `<pre>`, 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#"<div class="field" aria-hidden="true"><pre>{}</pre><div class="sweep"></div></div>"#, 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#"<svg class="mark" viewBox="0 0 128 128" role="img" aria-label="atgc"> <path d="M 97.97 28.03 L 97.97 28.03 L 97.87 29.06 L 97.71 30.08 L 97.48 31.11 L 97.19 32.14 L 96.84 33.17 L 96.43 34.19 L 95.95 35.22 L 95.41 36.25 L 94.81 37.28 L 94.16 38.31 L 93.44 39.33 L 92.68 40.36 L 91.85 41.39 L 90.97 42.42 L 90.05 43.44 L 89.07 44.47 L 88.04 45.50 L 86.97 46.53 L 85.85 47.56 L 84.70 48.58 L 83.50 49.61 L 82.27 50.64 L 81.00 51.67 L 79.70 52.69 L 78.37 53.72 L 77.01 54.75 L 75.63 55.78 L 74.22 56.81 L 72.80 57.83 L 71.36 58.86 L 69.90 59.89 L 68.44 60.92 L 66.96 61.94 L 65.48 62.97 L 64.00 64.00 L 62.52 65.03 L 61.04 66.06 L 59.56 67.08 L 58.10 68.11 L 56.64 69.14 L 55.20 70.17 L 53.78 71.19 L 52.37 72.22 L 50.99 73.25 L 49.63 74.28 L 48.30 75.31 L 47.00 76.33 L 45.73 77.36 L 44.50 78.39 L 43.30 79.42 L 42.15 80.44 L 41.03 81.47 L 39.96 82.50 L 38.93 83.53 L 37.95 84.56 L 37.03 85.58 L 36.15 86.61 L 35.32 87.64 L 34.56 88.67 L 33.84 89.69 L 33.19 90.72 L 32.59 91.75 L 32.05 92.78 L 31.57 93.81 L 31.16 94.83 L 30.81 95.86 L 30.52 96.89 L 30.29 97.92 L 30.13 98.94 L 30.03 99.97 L 30.00 101.00" fill="none" stroke="var(--bond)" stroke-width="8.40" stroke-linecap="round" stroke-linejoin="round" opacity="0.34"/> <path d="M 30.00 27.00 L 30.03 28.03" fill="none" stroke="var(--bond)" stroke-width="8.40" stroke-linecap="round" stroke-linejoin="round" opacity="0.34"/> <line x1="36.30" y1="22.75" x2="91.70" y2="22.75" stroke="var(--bond)" stroke-width="4.6" stroke-linecap="round" opacity="0.8"/> <line x1="36.30" y1="31.25" x2="91.70" y2="31.25" stroke="var(--bond)" stroke-width="4.6" stroke-linecap="round" opacity="0.8"/> <line x1="36.30" y1="92.49" x2="91.70" y2="92.49" stroke="var(--bond)" stroke-width="4.6" stroke-linecap="round" opacity="0.8"/> <line x1="36.30" y1="101.00" x2="91.70" y2="101.00" stroke="var(--bond)" stroke-width="4.6" stroke-linecap="round" opacity="0.8"/> <line x1="36.30" y1="109.51" x2="91.70" y2="109.51" stroke="var(--bond)" stroke-width="4.6" stroke-linecap="round" opacity="0.8"/> <path d="M 98.00 27.00 L 97.97 28.03" fill="none" stroke="var(--bond)" stroke-width="11.60" stroke-linecap="round" stroke-linejoin="round" opacity="0.92"/> <path d="M 30.03 28.03 L 30.03 28.03 L 30.13 29.06 L 30.29 30.08 L 30.52 31.11 L 30.81 32.14 L 31.16 33.17 L 31.57 34.19 L 32.05 35.22 L 32.59 36.25 L 33.19 37.28 L 33.84 38.31 L 34.56 39.33 L 35.32 40.36 L 36.15 41.39 L 37.03 42.42 L 37.95 43.44 L 38.93 44.47 L 39.96 45.50 L 41.03 46.53 L 42.15 47.56 L 43.30 48.58 L 44.50 49.61 L 45.73 50.64 L 47.00 51.67 L 48.30 52.69 L 49.63 53.72 L 50.99 54.75 L 52.37 55.78 L 53.78 56.81 L 55.20 57.83 L 56.64 58.86 L 58.10 59.89 L 59.56 60.92 L 61.04 61.94 L 62.52 62.97 L 64.00 64.00 L 65.48 65.03 L 66.96 66.06 L 68.44 67.08 L 69.90 68.11 L 71.36 69.14 L 72.80 70.17 L 74.22 71.19 L 75.63 72.22 L 77.01 73.25 L 78.37 74.28 L 79.70 75.31 L 81.00 76.33 L 82.27 77.36 L 83.50 78.39 L 84.70 79.42 L 85.85 80.44 L 86.97 81.47 L 88.04 82.50 L 89.07 83.53 L 90.05 84.56 L 90.97 85.58 L 91.85 86.61 L 92.68 87.64 L 93.44 88.67 L 94.16 89.69 L 94.81 90.72 L 95.41 91.75 L 95.95 92.78 L 96.43 93.81 L 96.84 94.83 L 97.19 95.86 L 97.48 96.89 L 97.71 97.92 L 97.87 98.94 L 97.97 99.97 L 98.00 101.00" fill="none" stroke="var(--bond)" stroke-width="11.60" stroke-linecap="round" stroke-linejoin="round" opacity="0.92"/> <circle cx="30.00" cy="27.00" r="14.00" fill="var(--t)"/> <circle cx="98.00" cy="27.00" r="14.00" fill="var(--a)"/> <circle cx="30.00" cy="101.00" r="14.00" fill="var(--g)"/> <circle cx="98.00" cy="101.00" r="14.00" fill="var(--c)"/></svg>"#;
/// 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#"<div class="brand"> <a class="brand-link" href="{SITE}" target="_blank" rel="noopener noreferrer"> {MARK_SVG} <span class="brand-name">atgc</span> </a> <span class="brand-version">v{version}</span> </div>"#, 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 `<link rel="icon">` 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#"<link rel="icon" href="data:image/svg+xml,{}">"#, 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<String> = 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#"<link rel="icon" href="data:image/svg+xml,"#)); assert!(link.ends_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""#)); }}