Theme switch, auto theme, and a highlighting fix c40c2d3edb51
The header gets a theme switch that cycles dark, light and auto, and Settings gains an Appearance section; both keep the choice in the theme cookie. Auto follows the system's colour scheme and changes with it. Checking every page in light mode found highlighted code drawing boxes in both themes: syntect's scope names are emitted as classes, and an empty comment scope took the page's .comment card style. Highlighting classes are now prefixed sy-. The admin link in the band gets a colour that reads on the band in both themes.
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-09-28 00:07 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
c40c2d3edb51ad93af38f6aab2cdfc4c4437b8c8a68d40edd1d10dcccd76f44f- Depends on
- 145306044896 18e381ea88c2 19bb31ff1f5c 4edcf24025cf 5c6eb1feaa71 93be9ce4653e d23464c0ac60
- Depended on by
- 1062a0f8e985 0024ccb1b050 9a8e3b4419e0 17ca6cddc534 95b525b0b977 f3e5e2bc78e9 8475176972e8 83940d53664f d98a93c10a0d f6346d875ee5 749d9a5fcd9f f5c443378333 756fe44a0f4d 34beb1a7e376 64caed18236a 9df855f29948 101740454617 42a0ba4bac4e
- In bookmarks
- api-unknown-fields cancel-landed-runs clear-waiting close-proposal-140 diffusion-pin-bookmark draft-ready image-check job-waits main oh-comment-body-file proposal-list-fast run-one-job wiki-says-so
- In tags
- v0.1.0 v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.4.1 v0.4.2 v0.5.0 v0.5.1 v0.5.2 v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
- Edits
- 30 atoms, 0 tree operations, 10 files
Browse the tree at this change
modified assets/hub.js +20 −0
| … | 27 unchanged lines |
||
| 28 | 28 | mds.forEach(function (t) { new E({ element: t, spellChecker: false, status: false, forceSync: true, autoDownloadFontAwesome: false, toolbar: toolbar }); }); |
|
| 29 | 29 | }); |
|
| 30 | 30 | ||
| 31 | + | /* Theme switch in the header: dark, light, auto (follows the system); kept in the theme cookie. */ |
|
| 32 | + | var toggle = document.querySelector("[data-theme-toggle]"); |
|
| 33 | + | if (toggle) { |
|
| 34 | + | var root = document.documentElement, order = ["dark", "light", "auto"], glyph = { dark: "◐", light: "☀", auto: "◑" }; |
|
| 35 | + | var show = function () { |
|
| 36 | + | var p = root.dataset.themePref || "dark"; |
|
| 37 | + | toggle.title = "Theme: " + p + " (click to change)"; |
|
| 38 | + | toggle.setAttribute("aria-label", toggle.title); |
|
| 39 | + | toggle.firstElementChild.textContent = glyph[p]; |
|
| 40 | + | }; |
|
| 41 | + | show(); |
|
| 42 | + | toggle.addEventListener("click", function () { |
|
| 43 | + | var p = order[(order.indexOf(root.dataset.themePref || "dark") + 1) % order.length]; |
|
| 44 | + | document.cookie = "theme=" + p + "; path=/; max-age=31536000; samesite=lax"; |
|
| 45 | + | root.dataset.themePref = p; |
|
| 46 | + | root.dataset.theme = p === "auto" ? (window.matchMedia && matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark") : p; |
|
| 47 | + | show(); |
|
| 48 | + | }); |
|
| 49 | + | } |
|
| 50 | + | ||
| 31 | 51 | /* Copy buttons. */ |
|
| 32 | 52 | document.querySelectorAll("[data-copy]").forEach(function (b) { |
|
| 33 | 53 | b.addEventListener("click", function () { |
|
| … | 63 unchanged lines |
modified crates/hub/src/render/highlight.rs +6 −3
| … | 38 unchanged lines |
||
| 39 | 39 | let Some(syntax) = syntax else { |
|
| 40 | 40 | return text.lines().map(super::escape).collect(); |
|
| 41 | 41 | }; |
|
| 42 | - | let mut generator = ClassedHTMLGenerator::new_with_class_style(syntax, set, ClassStyle::Spaced); |
|
| 42 | + | let mut generator = ClassedHTMLGenerator::new_with_class_style(syntax, set, ClassStyle::SpacedPrefixed { prefix: "sy-" }); |
|
| 43 | 43 | let mut out = Vec::new(); |
|
| 44 | 44 | for line in LinesWithEndings::from(text) { |
|
| 45 | 45 | if generator.parse_html_for_line_which_includes_newline(line).is_err() { |
|
| … | 45 unchanged lines |
||
| 91 | 91 | fn highlights_rust_and_falls_back() { |
|
| 92 | 92 | let l = lines("main.rs", "fn main() {\n let x = 1;\n}\n"); |
|
| 93 | 93 | assert_eq!(l.len(), 3); |
|
| 94 | - | assert!(l[0].contains("<span class=\"source rust\">"), "{}", l[0]); |
|
| 95 | - | assert!(l[0].contains("storage type function"), "{}", l[0]); |
|
| 94 | + | assert!(l[0].contains("<span class=\"sy-source sy-rust\">"), "{}", l[0]); |
|
| 95 | + | assert!(l[0].contains("sy-storage sy-type sy-function"), "{}", l[0]); |
|
| 96 | + | // Scope names never reach the page as bare classes (`comment` is also a page class). |
|
| 97 | + | let c = lines("main.rs", "// note\n"); |
|
| 98 | + | assert!(c[0].contains("sy-comment") && !c[0].contains("\"comment") && !c[0].contains(" comment"), "{}", c[0]); |
|
| 96 | 99 | assert!(l[1].ends_with("</span>"), "spans are balanced per line: {}", l[1]); |
|
| 97 | 100 | assert_eq!(lines("x.unknownext", "a<b\nc"), vec!["a<b", "c"]); |
|
| 98 | 101 | assert_eq!(language("a/b/lib.rs"), Some("Rust")); |
|
| … | 4 unchanged lines |
modified crates/hub/src/web/mod.rs +1 −0
| … | 47 unchanged lines |
||
| 48 | 48 | .route("/register", get(session::register_form).post(session::register)) |
|
| 49 | 49 | .route("/settings", get(settings::profile).post(settings::update_profile)) |
|
| 50 | 50 | .route("/settings/password", post(settings::change_password)) |
|
| 51 | + | .route("/settings/appearance", post(settings::appearance)) |
|
| 51 | 52 | .route("/settings/security", get(settings::security)) |
|
| 52 | 53 | .route("/settings/security/totp/begin", post(settings::totp_begin)) |
|
| 53 | 54 | .route("/settings/security/totp/enable", post(settings::totp_enable)) |
|
| … | 436 unchanged lines |
modified crates/hub/src/web/settings.rs +71 −8
| … | 6 unchanged lines |
||
| 7 | 7 | use crate::Shared; |
|
| 8 | 8 | use askama::Template; |
|
| 9 | 9 | use axum::extract::{Form, Multipart, Path, State}; |
|
| 10 | + | use axum::http::{header, HeaderMap, StatusCode}; |
|
| 10 | 11 | use axum::response::{IntoResponse, Response}; |
|
| 11 | 12 | use oxy_core::identity::{Identity, Signature}; |
|
| 12 | 13 | use serde::Deserialize; |
|
| … | 29 unchanged lines |
||
| 42 | 43 | me: accounts::Account, |
|
| 43 | 44 | notice: Option<String>, |
|
| 44 | 45 | error: Option<String>, |
|
| 46 | + | /// The theme cookie's value, for the Appearance form. |
|
| 47 | + | theme: &'static str, |
|
| 48 | + | themes: &'static [(&'static str, &'static str)], |
|
| 49 | + | } |
|
| 50 | + | ||
| 51 | + | /// The themes (hub spec §6.1): the cookie value and its label. |
|
| 52 | + | const THEMES: &[(&str, &str)] = &[("dark", "Dark"), ("light", "Light"), ("auto", "Match the system")]; |
|
| 53 | + | ||
| 54 | + | /// The theme the request's cookie asks for; dark when unset or unknown. |
|
| 55 | + | fn theme_of(headers: &HeaderMap) -> &'static str { |
|
| 56 | + | let v = auth::viewer::cookie(headers, "theme").unwrap_or_default(); |
|
| 57 | + | THEMES.iter().map(|(t, _)| *t).find(|t| *t == v).unwrap_or("dark") |
|
| 58 | + | } |
|
| 59 | + | ||
| 60 | + | impl SettingsPage { |
|
| 61 | + | fn new(shell: Shell, me: accounts::Account, headers: &HeaderMap) -> SettingsPage { |
|
| 62 | + | SettingsPage { shell, me, notice: None, error: None, theme: theme_of(headers), themes: THEMES } |
|
| 63 | + | } |
|
| 64 | + | ||
| 65 | + | fn notice(mut self, n: impl Into<String>) -> Self { |
|
| 66 | + | self.notice = Some(n.into()); |
|
| 67 | + | self |
|
| 68 | + | } |
|
| 69 | + | ||
| 70 | + | fn error(mut self, e: impl Into<String>) -> Self { |
|
| 71 | + | self.error = Some(e.into()); |
|
| 72 | + | self |
|
| 73 | + | } |
|
| 45 | 74 | } |
|
| 46 | 75 | ||
| 47 | - | pub async fn profile(State(app): State<Shared>, viewer: Viewer) -> Result<Page<SettingsPage>, Error> { |
|
| 76 | + | pub async fn profile(State(app): State<Shared>, viewer: Viewer, headers: HeaderMap) -> Result<Page<SettingsPage>, Error> { |
|
| 48 | 77 | let shell = Shell::new(&app, "/settings", &viewer); |
|
| 49 | 78 | let me = require(&viewer, &shell)?; |
|
| 50 | - | Ok(Page(SettingsPage { shell, me: me.account, notice: None, error: None })) |
|
| 79 | + | Ok(Page(SettingsPage::new(shell, me.account, &headers))) |
|
| 51 | 80 | } |
|
| 52 | 81 | ||
| 53 | 82 | #[derive(Deserialize)] |
|
| 83 | + | pub struct AppearanceForm { |
|
| 84 | + | #[serde(rename = "_csrf")] |
|
| 85 | + | csrf_token: String, |
|
| 86 | + | theme: String, |
|
| 87 | + | } |
|
| 88 | + | ||
| 89 | + | /// Remember the theme in a cookie for a year; the page script applies it. |
|
| 90 | + | pub async fn appearance(State(app): State<Shared>, viewer: Viewer, Form(f): Form<AppearanceForm>) -> Result<Response, Error> { |
|
| 91 | + | let shell = Shell::new(&app, "/settings", &viewer); |
|
| 92 | + | require(&viewer, &shell)?; |
|
| 93 | + | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 94 | + | let Some((theme, _)) = THEMES.iter().find(|(t, _)| *t == f.theme) else { |
|
| 95 | + | return Err(Error::bad(shell, "Pick dark, light or match the system.")); |
|
| 96 | + | }; |
|
| 97 | + | Ok(( |
|
| 98 | + | StatusCode::SEE_OTHER, |
|
| 99 | + | [ |
|
| 100 | + | (header::SET_COOKIE, format!("theme={theme}; Path=/; Max-Age=31536000; SameSite=Lax")), |
|
| 101 | + | (header::LOCATION, "/settings".to_string()), |
|
| 102 | + | ], |
|
| 103 | + | ) |
|
| 104 | + | .into_response()) |
|
| 105 | + | } |
|
| 106 | + | ||
| 107 | + | #[derive(Deserialize)] |
|
| 54 | 108 | pub struct ProfileForm { |
|
| 55 | 109 | #[serde(rename = "_csrf")] |
|
| 56 | 110 | csrf_token: String, |
|
| … | 1 unchanged lines |
||
| 58 | 112 | email: String, |
|
| 59 | 113 | } |
|
| 60 | 114 | ||
| 61 | - | pub async fn update_profile(State(app): State<Shared>, viewer: Viewer, Form(f): Form<ProfileForm>) -> Result<Page<SettingsPage>, Error> { |
|
| 115 | + | pub async fn update_profile( |
|
| 116 | + | State(app): State<Shared>, |
|
| 117 | + | viewer: Viewer, |
|
| 118 | + | headers: HeaderMap, |
|
| 119 | + | Form(f): Form<ProfileForm>, |
|
| 120 | + | ) -> Result<Page<SettingsPage>, Error> { |
|
| 62 | 121 | let shell = Shell::new(&app, "/settings", &viewer); |
|
| 63 | 122 | let me = require(&viewer, &shell)?; |
|
| 64 | 123 | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| … | 2 unchanged lines |
||
| 67 | 126 | .map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 68 | 127 | let updated = |
|
| 69 | 128 | accounts::account_by_id(&app.db, me.account.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(me.account); |
|
| 70 | - | Ok(Page(SettingsPage { shell, me: updated, notice: Some("Profile saved.".into()), error: None })) |
|
| 129 | + | Ok(Page(SettingsPage::new(shell, updated, &headers).notice("Profile saved."))) |
|
| 71 | 130 | } |
|
| 72 | 131 | ||
| 73 | 132 | #[derive(Deserialize)] |
|
| … | 4 unchanged lines |
||
| 78 | 137 | password: String, |
|
| 79 | 138 | } |
|
| 80 | 139 | ||
| 81 | - | pub async fn change_password(State(app): State<Shared>, viewer: Viewer, Form(f): Form<PasswordForm>) -> Result<Response, Error> { |
|
| 140 | + | pub async fn change_password( |
|
| 141 | + | State(app): State<Shared>, |
|
| 142 | + | viewer: Viewer, |
|
| 143 | + | headers: HeaderMap, |
|
| 144 | + | Form(f): Form<PasswordForm>, |
|
| 145 | + | ) -> Result<Response, Error> { |
|
| 82 | 146 | let shell = Shell::new(&app, "/settings", &viewer); |
|
| 83 | 147 | let me = require(&viewer, &shell)?; |
|
| 84 | 148 | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 85 | 149 | if !auth::verify_password(&me.account.password_hash, &f.current) { |
|
| 86 | - | return Ok(Page(SettingsPage { shell, me: me.account, notice: None, error: Some("Your current password is wrong.".into()) }) |
|
| 87 | - | .into_response()); |
|
| 150 | + | return Ok(Page(SettingsPage::new(shell, me.account, &headers).error("Your current password is wrong.")).into_response()); |
|
| 88 | 151 | } |
|
| 89 | 152 | if let Err(e) = auth::password_ok(&f.password) { |
|
| 90 | - | return Ok(Page(SettingsPage { shell, me: me.account, notice: None, error: Some(e.into()) }).into_response()); |
|
| 153 | + | return Ok(Page(SettingsPage::new(shell, me.account, &headers).error(e)).into_response()); |
|
| 91 | 154 | } |
|
| 92 | 155 | let hash = auth::hash_password(&f.password).map_err(|e| Error::bad(shell.clone(), e.to_string()))?; |
|
| 93 | 156 | // A new session salt signs out every other session; this one is renewed. |
|
| … | 344 unchanged lines |
modified crates/hub/tests/browse.rs +1 −1
| … | 126 unchanged lines |
||
| 127 | 127 | assert!(page(&h.app, &format!("/andy/demo/tree/{}", &c1[..10])).await.contains("README.md")); |
|
| 128 | 128 | assert_eq!(get(&h.app, "/andy/demo/tree/main/nope").await.0, StatusCode::NOT_FOUND); |
|
| 129 | 129 | let f = page(&h.app, "/andy/demo/file/main/src/main.rs").await; |
|
| 130 | - | assert!(f.contains("storage type function"), "highlighted: {f}"); |
|
| 130 | + | assert!(f.contains("sy-storage sy-type sy-function"), "highlighted: {f}"); |
|
| 131 | 131 | assert!(f.contains("id=\"L5\"")); |
|
| 132 | 132 | assert!(f.contains("Rust")); |
|
| 133 | 133 | let (status, raw) = get(&h.app, "/andy/demo/file/main/src/main.rs?plain=1").await; |
|
| … | 90 unchanged lines |
modified crates/hub/tests/issues.rs +27 −0
| … | 351 unchanged lines |
||
| 352 | 352 | let page = get(app, "/andy/fresh", Some(&andy)).await; |
|
| 353 | 353 | assert!(!page.body.contains("This repository is empty") && page.body.contains("README.md"), "{}", page.body); |
|
| 354 | 354 | } |
|
| 355 | + | ||
| 356 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 357 | + | async fn theme_is_chosen_in_settings_and_remembered_in_a_cookie() { |
|
| 358 | + | let h = hub().await; |
|
| 359 | + | let app = &h.app; |
|
| 360 | + | let andy = register(app, "andy").await; |
|
| 361 | + | let page = get(app, "/settings", Some(&andy)).await; |
|
| 362 | + | assert!(page.body.contains("Appearance")); |
|
| 363 | + | assert!(page.body.contains(r#"value="dark" checked"#), "dark by default: {}", page.body); |
|
| 364 | + | assert!(page.body.contains("data-theme-toggle"), "the header has the switch"); |
|
| 365 | + | let csrf = csrf_of(&page.body); |
|
| 366 | + | let r = post(app, "/settings/appearance", Some(&andy), &format!("_csrf={csrf}&theme=light")).await; |
|
| 367 | + | assert_eq!(r.status, StatusCode::SEE_OTHER); |
|
| 368 | + | assert_eq!(r.cookie.as_deref(), Some("light")); |
|
| 369 | + | assert_eq!(r.location.as_deref(), Some("/settings")); |
|
| 370 | + | let page = send_with_theme(app, &andy, "light").await; |
|
| 371 | + | assert!(page.contains(r#"value="light" checked"#), "the form shows the cookie's theme"); |
|
| 372 | + | assert_eq!(post(app, "/settings/appearance", Some(&andy), &format!("_csrf={csrf}&theme=purple")).await.status, StatusCode::BAD_REQUEST); |
|
| 373 | + | assert_eq!(post(app, "/settings/appearance", Some(&andy), "_csrf=nope&theme=light").await.status, StatusCode::BAD_REQUEST); |
|
| 374 | + | } |
|
| 375 | + | ||
| 376 | + | async fn send_with_theme(app: &Shared, session: &str, theme: &str) -> String { |
|
| 377 | + | let req = |
|
| 378 | + | Request::builder().uri("/settings").header(header::COOKIE, format!("oxyhub={session}; theme={theme}")).body(Body::empty()).unwrap(); |
|
| 379 | + | let res = oxygenhub::router(app.clone()).oneshot(req).await.unwrap(); |
|
| 380 | + | String::from_utf8_lossy(&res.into_body().collect().await.unwrap().to_bytes()).into_owned() |
|
| 381 | + | } |
modified docs/spec.md +2 −0
| … | 198 unchanged lines |
||
| 199 | 199 | ||
| 200 | 200 | The visual language follows the RetroPlay Network's shape (dark by default, `Barlow` for text and `Press Start 2P` for the wordmark and first columns, square corners, thin `--line` rules, a `--panel` header and footer, a full-width `--band` navigation bar with the active tab underlined in `--acc`) with the palette taken from the Oxygen mark shared with the VS Code extension: the mark's cyan `#00F0FF` to blue `#0072FF` gradient on `#070c18`, so `--acc` is cyan, `--acc-2` blue, and the band a deep blue. The mark itself is the logo in the header, the footer and the favicon; the wordmark beside it reads OXYGEN**HUB**. The stylesheet starts from `retroplay-manager/templates/style.css` lines 1–171 (tokens, shell, band, tabs, main, tables, forms, footer) and adds forge components; the store, showcase and game components are not carried over. |
|
| 201 | 201 | ||
| 202 | + | The theme is dark, light, or auto (follow the system's `prefers-color-scheme`, and change with it), chosen with the switch in the header (it cycles) or under Settings → Appearance, and kept in a `theme` cookie for a year; dark when unset. A script in the page head applies it before first paint. Syntax highlighting uses the highlighter's scope names as classes prefixed `sy-`, so no scope can collide with the page's own classes. |
|
| 203 | + | ||
| 202 | 204 | Tokens (dark default, light under `:root[data-theme="light"]`): |
|
| 203 | 205 | ||
| 204 | 206 | ``` |
|
| … | 338 unchanged lines |
modified templates/base.html +2 −1
| … | 5 unchanged lines |
||
| 6 | 6 | <title>{% block title %}{{ shell.site }}{% endblock %} · {{ shell.site }}</title> |
|
| 7 | 7 | <link rel="stylesheet" href="/style.css"> |
|
| 8 | 8 | <link rel="icon" href="/favicon.svg" type="image/svg+xml"> |
|
| 9 | - | <script>(function(){var m=document.cookie.match(/(?:^|; )theme=(light|dark|auto)/);if(m&&m[1]!=="auto")document.documentElement.dataset.theme=m[1];})();</script> |
|
| 9 | + | <script>/* Theme before first paint: the theme cookie (dark, light or auto; dark when unset), auto following the system. */(function(){var d=document.documentElement,m=document.cookie.match(/(?:^|; )theme=(light|dark|auto)/),p=m?m[1]:"dark",q=window.matchMedia&&matchMedia("(prefers-color-scheme: light)");function a(){d.dataset.theme=p==="auto"?(q&&q.matches?"light":"dark"):p;}d.dataset.themePref=p;a();if(q&&q.addEventListener)q.addEventListener("change",function(){if(d.dataset.themePref==="auto")d.dataset.theme=q.matches?"light":"dark";});})();</script> |
|
| 10 | 10 | </head> |
|
| 11 | 11 | <body{% block bodyattrs %}{% endblock %}> |
|
| 12 | 12 | <header> |
|
| 13 | 13 | <a class="brand" href="/"><svg xmlns="http://www.w3.org/2000/svg" viewBox="-24 -12 48 24" width="48" height="24" aria-hidden="true"><defs><linearGradient id="oxyGradH" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" stop-color="#00F0FF"/><stop offset="100%" stop-color="#0072FF"/></linearGradient></defs><path d="M -11 -6 L 11 -6" stroke="url(#oxyGradH)" stroke-width="3" stroke-linecap="round"/><path d="M -11 6 L 11 6" stroke="url(#oxyGradH)" stroke-width="3" stroke-linecap="round"/><circle cx="-11" cy="0" r="10" fill="none" stroke="url(#oxyGradH)" stroke-width="3"/><circle cx="-11" cy="0" r="3.5" fill="#00F0FF"/><circle cx="11" cy="0" r="10" fill="none" stroke="url(#oxyGradH)" stroke-width="3"/><circle cx="11" cy="0" r="3.5" fill="#FFFFFF"/></svg><span class="px">OXYGEN<em>HUB</em></span></a> |
|
| 14 | 14 | <form class="find" action="/search" method="get" role="search"><input type="search" name="q" placeholder="Search…" aria-label="Search" autocomplete="off"></form> |
|
| 15 | 15 | <nav> |
|
| 16 | + | <button type="button" class="theme-toggle" data-theme-toggle aria-label="Theme" title="Theme"><span aria-hidden="true">◐</span></button> |
|
| 16 | 17 | {% if let Some(u) = shell.user %} |
|
| 17 | 18 | <a class="go" href="/new" title="New repository">+ NEW</a> |
|
| 18 | 19 | <a class="bell" href="/notifications" title="Notifications">✉{% if shell.unread > 0 %}<span class="count">{{ shell.unread }}</span>{% endif %}</a> |
|
| … | 39 unchanged lines |
modified templates/settings.html +11 −0
| … | 11 unchanged lines |
||
| 12 | 12 | <label for="email">Email</label><input type="email" id="email" name="email" value="{{ me.email }}"> |
|
| 13 | 13 | <button type="submit">SAVE</button> |
|
| 14 | 14 | </form> |
|
| 15 | + | <form class="card" method="post" action="/settings/appearance"> |
|
| 16 | + | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 17 | + | <h2>Appearance</h2> |
|
| 18 | + | <fieldset class="choices"> |
|
| 19 | + | <legend class="small mute">Theme, remembered in this browser</legend> |
|
| 20 | + | {% for (value, label) in themes %} |
|
| 21 | + | <label class="choice"><input type="radio" name="theme" value="{{ value }}"{% if theme == *value %} checked{% endif %}> {{ label }}</label> |
|
| 22 | + | {% endfor %} |
|
| 23 | + | </fieldset> |
|
| 24 | + | <button type="submit">SAVE</button> |
|
| 25 | + | </form> |
|
| 15 | 26 | <form class="card" method="post" action="/settings/password"> |
|
| 16 | 27 | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 17 | 28 | <h2>Change password</h2> |
|
| … | 6 unchanged lines |
modified templates/style.css +24 −19
| … | 11 unchanged lines |
||
| 12 | 12 | :root { |
|
| 13 | 13 | /* Oxygen's mark: cyan #00F0FF to blue #0072FF on #070c18. */ |
|
| 14 | 14 | --bg:#070c18; --panel:#0e1526; --soft:#141d33; --ink:#e6ecf7; --mute:#8592ad; --line:#1f2b47; --dim:#43506d; |
|
| 15 | - | --acc:#00e0f0; --acc-2:#0072ff; --band:#0b3f9e; --band-ink:#ffffff; --band-line:rgba(7,12,24,.5); --now:#00f0ff; --now-ink:#070c18; |
|
| 15 | + | --acc:#00e0f0; --acc-2:#0072ff; --band:#0b3f9e; --band-ink:#ffffff; --band-acc:#00f0ff; --band-line:rgba(7,12,24,.5); --now:#00f0ff; --now-ink:#070c18; |
|
| 16 | 16 | --ok:#5fe3a1; --bad:#ff5e7a; --warn:#ffb347; |
|
| 17 | 17 | --add:#0d3a3a; --del:#3f1a2e; --add-ink:#8ff0d8; --del-ink:#ffa5b8; |
|
| 18 | 18 | --m1:#00f0ff; --m2:#d6dcea; --m3:#5aa8ff; --m4:#0072ff; --m5:#8592ad; |
|
| … | 3 unchanged lines |
||
| 22 | 22 | } |
|
| 23 | 23 | :root[data-theme="light"] { |
|
| 24 | 24 | --bg:#f4f7fc; --panel:#ffffff; --soft:#e9eef8; --ink:#1b2333; --mute:#66718a; --line:#d6dded; --dim:#b3bccf; |
|
| 25 | - | --acc:#0072ff; --acc-2:#00b8d4; --band:#0b3f9e; --band-ink:#ffffff; --band-line:rgba(255,255,255,.25); --now:#0072ff; --now-ink:#ffffff; |
|
| 25 | + | --acc:#0072ff; --acc-2:#00b8d4; --band:#0b3f9e; --band-ink:#ffffff; --band-acc:#9ff3ff; --band-line:rgba(255,255,255,.25); --now:#0072ff; --now-ink:#ffffff; |
|
| 26 | 26 | --ok:#1f8f5f; --bad:#c62d47; --warn:#b8651b; |
|
| 27 | 27 | --add:#e1f5ee; --del:#fbe3e8; --add-ink:#176b48; --del-ink:#a1243c; |
|
| 28 | 28 | --m1:#0090a8; --m2:#7a8290; --m3:#2f6fcf; --m4:#0055c0; --m5:#8a8f9c; |
|
| … | 30 unchanged lines |
||
| 59 | 59 | .brand:hover { text-decoration: none; } |
|
| 60 | 60 | header nav { margin-left: auto; display: flex; gap: 8px; align-items: center; } |
|
| 61 | 61 | header nav .me { color: var(--ink); font-weight: 600; padding: 0 6px; } |
|
| 62 | + | header nav .theme-toggle { background: none; border: 1px solid var(--line); color: var(--ink); font-size: 15px; line-height: 1; padding: 4px 7px; cursor: pointer; } |
|
| 63 | + | header nav .theme-toggle:hover { border-color: var(--acc); } |
|
| 64 | + | fieldset.choices { border: 0; padding: 0; margin: 0 0 10px; display: flex; gap: 18px; flex-wrap: wrap; } |
|
| 65 | + | fieldset.choices legend { padding: 0; margin-bottom: 6px; } |
|
| 66 | + | form.card label.choice, label.choice { display: inline-flex; gap: 6px; align-items: center; font-weight: 400; text-transform: none; letter-spacing: 0; font-size: 14px; color: var(--ink); margin: 0; } |
|
| 62 | 67 | form.inline { display: inline; margin: 0; } |
|
| 63 | - | .band a.admin { color: var(--acc); } |
|
| 68 | + | .band a.admin { color: var(--band-acc); } |
|
| 64 | 69 | form.find { margin: 0; flex: 0 1 320px; } |
|
| 65 | 70 | form.find input { width: 100%; background: var(--bg); border: 1px solid var(--line); color: var(--ink); padding: 6px 10px; font: inherit; } |
|
| 66 | 71 | form.find input:focus { outline: 2px solid var(--band); outline-offset: 0; } |
|
| … | 159 unchanged lines |
||
| 226 | 231 | .tabs-in { padding: 0 8px; } |
|
| 227 | 232 | } |
|
| 228 | 233 | ||
| 229 | - | /* syntax (syntect scopes, spaced classes) */ |
|
| 230 | - | .source .keyword, .source .storage { color: var(--sy-kw); } |
|
| 231 | - | .source .string { color: var(--sy-str); } |
|
| 232 | - | .source .constant.numeric, .source .constant.language { color: var(--sy-num); } |
|
| 233 | - | .source .constant { color: var(--sy-const); } |
|
| 234 | - | .source .comment { color: var(--sy-cmt); font-style: italic; } |
|
| 235 | - | .source .entity.name.function, .source .support.function, .source .meta.function-call .variable.function { color: var(--sy-fn); } |
|
| 236 | - | .source .entity.name.type, .source .storage.type, .source .support.type, .source .entity.name.struct, .source .entity.name.enum, .source .entity.name.class { color: var(--sy-ty); } |
|
| 237 | - | .source .variable { color: var(--sy-var); } |
|
| 238 | - | .source .entity.name.tag { color: var(--sy-kw); } |
|
| 239 | - | .source .entity.other.attribute-name { color: var(--sy-const); } |
|
| 240 | - | .source .markup.heading { color: var(--sy-ty); font-weight: 600; } |
|
| 241 | - | .source .markup.bold { font-weight: 600; } |
|
| 242 | - | .source .markup.italic { font-style: italic; } |
|
| 243 | - | .source .markup.raw { color: var(--sy-str); } |
|
| 244 | - | .source .markup.underline.link { color: var(--sy-fn); } |
|
| 234 | + | /* syntax (syntect scopes as classes prefixed sy-, so no scope name can collide with the page's own classes) */ |
|
| 235 | + | .sy-source .sy-keyword, .sy-source .sy-storage { color: var(--sy-kw); } |
|
| 236 | + | .sy-source .sy-string { color: var(--sy-str); } |
|
| 237 | + | .sy-source .sy-constant.sy-numeric, .sy-source .sy-constant.sy-language { color: var(--sy-num); } |
|
| 238 | + | .sy-source .sy-constant { color: var(--sy-const); } |
|
| 239 | + | .sy-source .sy-comment { color: var(--sy-cmt); font-style: italic; } |
|
| 240 | + | .sy-source .sy-entity.sy-name.sy-function, .sy-source .sy-support.sy-function, .sy-source .sy-meta.sy-function-call .sy-variable.sy-function { color: var(--sy-fn); } |
|
| 241 | + | .sy-source .sy-entity.sy-name.sy-type, .sy-source .sy-storage.sy-type, .sy-source .sy-support.sy-type, .sy-source .sy-entity.sy-name.sy-struct, .sy-source .sy-entity.sy-name.sy-enum, .sy-source .sy-entity.sy-name.sy-class { color: var(--sy-ty); } |
|
| 242 | + | .sy-source .sy-variable { color: var(--sy-var); } |
|
| 243 | + | .sy-source .sy-entity.sy-name.sy-tag { color: var(--sy-kw); } |
|
| 244 | + | .sy-source .sy-entity.sy-other.sy-attribute-name { color: var(--sy-const); } |
|
| 245 | + | .sy-source .sy-markup.sy-heading { color: var(--sy-ty); font-weight: 600; } |
|
| 246 | + | .sy-source .sy-markup.sy-bold { font-weight: 600; } |
|
| 247 | + | .sy-source .sy-markup.sy-italic { font-style: italic; } |
|
| 248 | + | .sy-source .sy-markup.sy-raw { color: var(--sy-str); } |
|
| 249 | + | .sy-source .sy-markup.sy-underline.sy-link { color: var(--sy-fn); } |
|
| 245 | 250 | ||
| 246 | 251 | /* forms */ |
|
| 247 | 252 | form.card { max-width: 460px; background: var(--panel); border: 1px solid var(--line); padding: 18px 20px; margin: 0 0 16px; } |
|
| … | 69 unchanged lines |