OXYGENHUB

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&lt;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