OXYGENHUB

Accessibility and narrow screens: focus rings, a skip link, named landmarks and aria-current, scrolling tab bars and tables, and WCAG AA text contrast in both themes checked by a test 756fe44a0f4d

closes #12
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-28 13:38 UTC
Signature
✔ signed by Andy McCall
Hash
756fe44a0f4d5efcfbfa0c7747ec85228435519137a9abf8becf5af29096cd72
Depends on
19bb31ff1f5c 1ac84c62ba53 25ab8c73514a 3f4b0ea6eabf 42a0ba4bac4e 42fa95540468 5c6eb1feaa71 6ae41081ccd3 8d2caf122517 93be9ce4653e a160b98fb922 abb98fa9964e c40c2d3edb51 d23464c0ac60 de505df5cf8d
Depended on by
166f6e9bf4f1 16ee5d19d220 d7d182467382 17ca6cddc534 c9aacc0e7dc1 757195ad7a5d c8df33a6829f a024e800af4e 36e5a9c15a84 554ccacfc123 66fd9d6b1452 e9bfea8e26db 83d5052bf76c e1ec9fbf538e 883c77eaf2c9
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
31 atoms, 1 tree operation, 7 files

Browse the tree at this change

added crates/hub/tests/contrast.rs +75 −0
1 +
//! Text contrast (hub spec §6.13): every text colour of each theme meets
2 +
//! WCAG AA (4.5:1) on every surface it is drawn on, and text never uses
3 +
//! `--dim`, which is for rules and decoration.
4 +
5 +
use std::collections::HashMap;
6 +
7 +
fn css() -> String {
8 +
    std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/../../templates/style.css")).unwrap()
9 +
}
10 +
11 +
/// `--name:#rrggbb` pairs of the block that starts with `selector`.
12 +
fn theme(css: &str, selector: &str) -> HashMap<String, String> {
13 +
    let start = css.find(selector).unwrap_or_else(|| panic!("{selector} missing"));
14 +
    let body = &css[start..start + css[start..].find('}').unwrap()];
15 +
    let mut out = HashMap::new();
16 +
    for part in body.split("--").skip(1) {
17 +
        let Some((name, rest)) = part.split_once(':') else { continue };
18 +
        let value = rest.trim_start();
19 +
        let valid = !name.is_empty() && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-');
20 +
        if valid && value.len() >= 7 && value.starts_with('#') && value[1..7].chars().all(|c| c.is_ascii_hexdigit()) {
21 +
            out.insert(name.to_string(), value[..7].to_lowercase());
22 +
        }
23 +
    }
24 +
    out
25 +
}
26 +
27 +
fn luminance(hex: &str) -> f64 {
28 +
    let c = |i: usize| {
29 +
        let x = u8::from_str_radix(&hex[i..i + 2], 16).unwrap() as f64 / 255.0;
30 +
        if x <= 0.03928 {
31 +
            x / 12.92
32 +
        } else {
33 +
            ((x + 0.055) / 1.055).powf(2.4)
34 +
        }
35 +
    };
36 +
    0.2126 * c(1) + 0.7152 * c(3) + 0.0722 * c(5)
37 +
}
38 +
39 +
fn ratio(a: &str, b: &str) -> f64 {
40 +
    let (x, y) = (luminance(a), luminance(b));
41 +
    (x.max(y) + 0.05) / (x.min(y) + 0.05)
42 +
}
43 +
44 +
#[test]
45 +
fn text_meets_aa_in_both_themes() {
46 +
    let css = css();
47 +
    let mut failures = Vec::new();
48 +
    for (name, sel) in [("dark", ":root {"), ("light", ":root[data-theme=\"light\"] {")] {
49 +
        let t = theme(&css, sel);
50 +
        let mut text: Vec<&str> = vec!["ink", "mute", "acc", "ok", "bad", "warn"];
51 +
        text.extend(t.keys().filter(|k| k.starts_with("sy-")).map(String::as_str));
52 +
        for fg in text {
53 +
            for bg in ["bg", "panel", "soft"] {
54 +
                let r = ratio(&t[fg], &t[bg]);
55 +
                if r < 4.5 {
56 +
                    failures.push(format!("{name}: --{fg} on --{bg} is {r:.2}:1"));
57 +
                }
58 +
            }
59 +
        }
60 +
        for (fg, bg) in [("band-ink", "band"), ("add-ink", "add"), ("del-ink", "del"), ("now-ink", "now")] {
61 +
            let r = ratio(&t[fg], &t[bg]);
62 +
            if r < 4.5 {
63 +
                failures.push(format!("{name}: --{fg} on --{bg} is {r:.2}:1"));
64 +
            }
65 +
        }
66 +
    }
67 +
    assert!(failures.is_empty(), "below WCAG AA:\n{}", failures.join("\n"));
68 +
}
69 +
70 +
#[test]
71 +
fn text_does_not_use_dim() {
72 +
    let css = css();
73 +
    let offenders: Vec<&str> = css.lines().filter(|l| l.contains("color: var(--dim)") && !l.contains("border")).collect();
74 +
    assert!(offenders.is_empty(), "--dim is too faint for text:\n{}", offenders.join("\n"));
75 +
}
modified crates/hub/tests/browse.rs +5 −1
…
128 unchanged lines
129 129
    assert!(o.contains("href=\"/andy/demo/file/main/docs/guide.md\""), "README link rebased: {o}");
130 130
    assert!(o.contains(&format!("oxy clone {}/andy/demo", h.base)));
131 131
    assert!(o.contains("2 changes"));
132 +
    // Landmarks, skip link, current tab (hub spec §6.13).
133 +
    assert!(o.contains("<a class=\"skip\" href=\"#main\">") && o.contains("<main id=\"main\""));
134 +
    assert!(o.contains("aria-label=\"Site\"") && o.contains("aria-label=\"Repository\"") && o.contains("aria-label=\"Account\""));
135 +
    assert!(o.contains("class=\"on\" aria-current=\"page\">Code</a>"), "the current tab is announced");
132 136
    assert!(o.contains("v0.1"));
133 137
    assert!(o.contains("feature"));
134 138
…
15 unchanged lines
150 154
    // Sidebar, permalink, blame shading and "blame before this change" (hub spec §6.6).
151 155
    assert!(f.contains("class=\"file-side\""), "sidebar");
152 156
    assert!(f.contains("href=\"/andy/demo/tree/main/docs\""), "sibling directory listed");
153 -
    let here = f.split("aria-current=\"page\">").nth(1).unwrap_or("");
157 +
    let here = f.split("class=\"here\" aria-current=\"page\">").nth(1).unwrap_or("");
154 158
    assert!(here.trim_start().starts_with("<a href=\"/andy/demo/file/main/src/main.rs\""), "the file marked");
155 159
    assert!(f.contains(&format!("data-permalink=\"/andy/demo/file/{c2}/src/main.rs\"")), "permalink at the head");
156 160
    assert!(b.contains(&format!("data-permalink=\"/andy/demo/file/{c2}/src/main.rs?blame=1\"")));
…
120 unchanged lines
modified docs/spec.md +1 −1
…
315 unchanged lines
316 316
317 317
### 6.13 Keyboard and accessibility
318 318
319 -
`/` focuses search, `g c` / `g p` / `g i` jump to Code, Proposals, Issues, `y` on a tree or file page switches to its permalink, `?` shows the sheet. Every interactive element is reachable by keyboard; colour is never the only carrier of meaning (glyphs accompany every status colour); contrast meets WCAG AA in both themes.
319 +
`/` focuses search, `g c` / `g p` / `g i` jump to Code, Proposals, Issues, `y` on a tree or file page switches to its permalink, `?` shows the sheet. Every interactive element is reachable by keyboard and shows a visible focus ring; a "Skip to content" link comes first; the header, site band, repository and settings tabs are named navigation landmarks, and the current tab carries `aria-current`. Colour is never the only carrier of meaning (glyphs accompany every status colour). Text meets WCAG AA (4.5:1) on every surface in both themes, checked by a test over the theme variables; `--dim` is for rules and decoration, never text. Under 760 pixels the tab bars and wide tables scroll sideways instead of breaking the layout, sectioned pages stack their section list above the content, and the header search hides (the `/` key and the search page remain).
320 320
321 321
## 7. Proposals and review
322 322
…
245 unchanged lines
modified templates/base.html +5 −4
…
8 unchanged lines
9 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 +
<a class="skip" href="#main">Skip to content</a>
12 13
<header>
13 14
  <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 15
  <form class="find" action="/search" method="get" role="search"><input type="search" name="q" placeholder="Search…" aria-label="Search" autocomplete="off"></form>
15 -
  <nav>
16 +
  <nav aria-label="Account">
16 17
    <button type="button" class="theme-toggle" data-theme-toggle aria-label="Theme" title="Theme"><span aria-hidden="true">◐</span></button>
17 18
    {% if let Some(u) = shell.user %}
18 19
    <a class="go" href="/new" title="New repository">+ NEW</a>
…
6 unchanged lines
25 26
    {% endif %}
26 27
  </nav>
27 28
</header>
28 -
<div class="band">
29 +
<nav class="band" aria-label="Site">
29 30
  <a href="/">{% if shell.user.is_some() %}Dashboard{% else %}Repositories{% endif %}</a>
30 31
  <a href="/explore">Explore</a>
31 32
  <a href="/help">Help</a>
…
1 unchanged lines
33 34
  <span class="grow"></span>
34 35
  {% if shell.is_admin %}<a class="admin" href="/admin">Admin</a>{% endif %}
35 36
  <a class="dim" href="/{{ shell.version }}" title="OxygenHub {{ shell.version }}" tabindex="-1">v{{ shell.version }}</a>
36 -
</div>
37 +
</nav>
37 38
{% block tabs %}{% endblock %}
38 -
<main{% block mainclass %}{% endblock %}>
39 +
<main id="main" tabindex="-1"{% block mainclass %}{% endblock %}>
39 40
{% block content %}{% endblock %}
40 41
</main>
41 42
<footer>
…
19 unchanged lines
modified templates/repo_tabs.html +14 −14
1 1
{% macro tabs(repo, tab) %}
2 -
<div class="tabs">
2 +
<nav class="tabs" aria-label="Repository">
3 3
  <div class="tabs-in">
4 4
    <span class="crumb"><a href="/{{ repo.owner }}">{{ repo.owner }}</a> / <a href="{{ repo.url }}"><strong>{{ repo.name }}</strong></a>{% if repo.visibility != "public" %} <span class="tag">{{ repo.visibility }}</span>{% endif %}</span>
5 -
    <a href="{{ repo.url }}"{% if tab == "code" %} class="on"{% endif %}>Code</a>
6 -
    <a href="{{ repo.url }}/changes/{{ repo.default_bookmark }}"{% if tab == "changes" %} class="on"{% endif %}>Changes</a>
7 -
    <a href="{{ repo.url }}/proposals"{% if tab == "proposals" %} class="on"{% endif %}>Proposals{% if repo.open_proposals > 0 %} <span class="count-chip">{{ repo.open_proposals }}</span>{% endif %}</a>
8 -
    <a href="{{ repo.url }}/issues"{% if tab == "issues" %} class="on"{% endif %}>Issues{% if repo.open_issues > 0 %} <span class="count-chip">{{ repo.open_issues }}</span>{% endif %}</a>
9 -
    <a href="{{ repo.url }}/flow"{% if tab == "flow" %} class="on"{% endif %}>Flow</a>
10 -
    <a href="{{ repo.url }}/bookmarks"{% if tab == "bookmarks" %} class="on"{% endif %}>Bookmarks</a>
11 -
    <a href="{{ repo.url }}/releases"{% if tab == "releases" %} class="on"{% endif %}>Releases</a>
12 -
    <a href="{{ repo.url }}/tags"{% if tab == "tags" %} class="on"{% endif %}>Tags</a>
13 -
    <a href="{{ repo.url }}/conflicts"{% if tab == "conflicts" %} class="on"{% endif %}>Conflicts</a>
14 -
    <a href="{{ repo.url }}/trust"{% if tab == "trust" %} class="on"{% endif %}>Trust</a>
15 -
    <a href="{{ repo.url }}/pushes"{% if tab == "pushes" %} class="on"{% endif %}>Pushes</a>
16 -
    {% if repo.can_maintain() %}<a href="{{ repo.url }}/settings"{% if tab == "settings" %} class="on"{% endif %}>Settings</a>{% endif %}
5 +
    <a href="{{ repo.url }}"{% if tab == "code" %} class="on" aria-current="page"{% endif %}>Code</a>
6 +
    <a href="{{ repo.url }}/changes/{{ repo.default_bookmark }}"{% if tab == "changes" %} class="on" aria-current="page"{% endif %}>Changes</a>
7 +
    <a href="{{ repo.url }}/proposals"{% if tab == "proposals" %} class="on" aria-current="page"{% endif %}>Proposals{% if repo.open_proposals > 0 %} <span class="count-chip">{{ repo.open_proposals }}</span>{% endif %}</a>
8 +
    <a href="{{ repo.url }}/issues"{% if tab == "issues" %} class="on" aria-current="page"{% endif %}>Issues{% if repo.open_issues > 0 %} <span class="count-chip">{{ repo.open_issues }}</span>{% endif %}</a>
9 +
    <a href="{{ repo.url }}/flow"{% if tab == "flow" %} class="on" aria-current="page"{% endif %}>Flow</a>
10 +
    <a href="{{ repo.url }}/bookmarks"{% if tab == "bookmarks" %} class="on" aria-current="page"{% endif %}>Bookmarks</a>
11 +
    <a href="{{ repo.url }}/releases"{% if tab == "releases" %} class="on" aria-current="page"{% endif %}>Releases</a>
12 +
    <a href="{{ repo.url }}/tags"{% if tab == "tags" %} class="on" aria-current="page"{% endif %}>Tags</a>
13 +
    <a href="{{ repo.url }}/conflicts"{% if tab == "conflicts" %} class="on" aria-current="page"{% endif %}>Conflicts</a>
14 +
    <a href="{{ repo.url }}/trust"{% if tab == "trust" %} class="on" aria-current="page"{% endif %}>Trust</a>
15 +
    <a href="{{ repo.url }}/pushes"{% if tab == "pushes" %} class="on" aria-current="page"{% endif %}>Pushes</a>
16 +
    {% if repo.can_maintain() %}<a href="{{ repo.url }}/settings"{% if tab == "settings" %} class="on" aria-current="page"{% endif %}>Settings</a>{% endif %}
17 17
  </div>
18 -
</div>
18 +
</nav>
19 19
{% endmacro %}
20 20
21 21
{% macro sig(s) %}<span class="sig {{ s.class() }}" title="{{ s.title() }}">{{ s.glyph() }}</span>{% endmacro %}
…
53 unchanged lines
modified templates/settings_tabs.html +6 −6
1 1
{% macro tabs(on) %}
2 -
<div class="tabs"><div class="tabs-in">
2 +
<nav class="tabs" aria-label="Settings"><div class="tabs-in">
3 3
  <span class="crumb"><strong>Settings</strong></span>
4 -
  <a href="/settings"{% if on == "profile" %} class="on"{% endif %}>Profile</a>
5 -
  <a href="/settings/security"{% if on == "security" %} class="on"{% endif %}>Security</a>
6 -
  <a href="/settings/keys"{% if on == "keys" %} class="on"{% endif %}>Keys</a>
7 -
  <a href="/settings/tokens"{% if on == "tokens" %} class="on"{% endif %}>Tokens</a>
4 +
  <a href="/settings"{% if on == "profile" %} class="on" aria-current="page"{% endif %}>Profile</a>
5 +
  <a href="/settings/security"{% if on == "security" %} class="on" aria-current="page"{% endif %}>Security</a>
6 +
  <a href="/settings/keys"{% if on == "keys" %} class="on" aria-current="page"{% endif %}>Keys</a>
7 +
  <a href="/settings/tokens"{% if on == "tokens" %} class="on" aria-current="page"{% endif %}>Tokens</a>
8 8
  <a href="/orgs/new">New organisation</a>
9 -
</div></div>
9 +
</div></nav>
10 10
{% endmacro %}
modified templates/style.css +28 −11
…
16 unchanged lines
17 17
  --add:#0d3a3a; --del:#3f1a2e; --add-ink:#8ff0d8; --del-ink:#ffa5b8;
18 18
  --m1:#00f0ff; --m2:#d6dcea; --m3:#5aa8ff; --m4:#0072ff; --m5:#8592ad;
19 19
  /* syntax */
20 -
  --sy-kw:#7fb4ff; --sy-str:#8ff0d8; --sy-num:#9fd3ff; --sy-cmt:#5b6785; --sy-fn:#00e0f0; --sy-ty:#c6a0ff; --sy-var:#e6ecf7; --sy-const:#b48cff;
20 +
  --sy-kw:#7fb4ff; --sy-str:#8ff0d8; --sy-num:#9fd3ff; --sy-cmt:#7a86a4; --sy-fn:#00e0f0; --sy-ty:#c6a0ff; --sy-var:#e6ecf7; --sy-const:#b48cff;
21 21
  color-scheme: dark;
22 22
}
23 23
:root[data-theme="light"] {
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-acc:#9ff3ff; --band-line:rgba(255,255,255,.25); --now:#0072ff; --now-ink:#ffffff;
26 -
  --ok:#1f8f5f; --bad:#c62d47; --warn:#6b3fd1;
24 +
  --bg:#f4f7fc; --panel:#ffffff; --soft:#e9eef8; --ink:#1b2333; --mute:#5f6a81; --line:#d6dded; --dim:#b3bccf;
25 +
  --acc:#0064e0; --acc-2:#00b8d4; --band:#0b3f9e; --band-ink:#ffffff; --band-acc:#9ff3ff; --band-line:rgba(255,255,255,.25); --now:#0064e0; --now-ink:#ffffff;
26 +
  --ok:#1a7850; --bad:#c62d47; --warn:#6b3fd1;
27 27
  --add:#e1f5ee; --del:#fbe3e8; --add-ink:#176b48; --del-ink:#a1243c;
28 28
  --m1:#0090a8; --m2:#7a8290; --m3:#2f6fcf; --m4:#0055c0; --m5:#8a8f9c;
29 -
  --sy-kw:#0b4fc9; --sy-str:#1f8f5f; --sy-num:#1e63b8; --sy-cmt:#8a94a8; --sy-fn:#0083a8; --sy-ty:#7a3fd1; --sy-var:#1b2333; --sy-const:#6b3fd1;
29 +
  --sy-kw:#0b4fc9; --sy-str:#1a7850; --sy-num:#1e63b8; --sy-cmt:#606b81; --sy-fn:#007394; --sy-ty:#7a3fd1; --sy-var:#1b2333; --sy-const:#6b3fd1;
30 30
  color-scheme: light;
31 31
}
32 32
…
11 unchanged lines
44 44
p { margin: 0 0 10px; }
45 45
.small { font-size: 12px; }
46 46
.mute { color: var(--mute); }
47 -
.dim { color: var(--dim); }
47 +
.dim { color: var(--mute); }
48 48
.center { text-align: center; }
49 49
.right { text-align: right; }
50 50
.empty { color: var(--mute); padding: 22px; text-align: center; border: 1px dashed var(--line); }
…
61 unchanged lines
112 112
.lbl-tag { color: var(--acc); border-color: var(--acc); }
113 113
.sig { display: inline-block; width: 1.2em; text-align: center; font-weight: 600; }
114 114
.sig-valid { color: var(--ok); }
115 -
.sig-unsigned { color: var(--dim); }
115 +
.sig-unsigned { color: var(--mute); }
116 116
.sig-invalid { color: var(--bad); }
117 117
.sig-revoked { color: var(--warn); }
118 118
…
61 unchanged lines
180 180
.modes a.on { border-color: var(--acc); color: var(--acc); }
181 181
table.code { width: 100%; }
182 182
table.code td { padding: 0 8px; border: 0; background: transparent; line-height: 1.5; font-family: "IBM Plex Mono", ui-monospace, monospace; font-size: 13px; }
183 -
table.code td.n { width: 1%; min-width: 3em; text-align: right; color: var(--dim); user-select: none; cursor: pointer; padding-right: 12px; }
183 +
table.code td.n { width: 1%; min-width: 3em; text-align: right; color: var(--mute); user-select: none; cursor: pointer; padding-right: 12px; }
184 184
table.code td.n:hover { color: var(--acc); }
185 185
table.code td.t pre { margin: 0; white-space: pre; }
186 186
table.code tr.hl td { background: color-mix(in srgb, var(--acc) 18%, transparent); }
…
114 unchanged lines
301 301
td.thread { background: var(--soft); padding: 8px 12px; }
302 302
td.thread .comment { margin: 4px 0; font-family: "Barlow", sans-serif; font-size: 14px; }
303 303
details.linecomment { display: inline; float: right; }
304 -
details.linecomment summary { list-style: none; cursor: pointer; color: var(--dim); font-size: 12px; padding: 0 4px; }
304 +
details.linecomment summary { list-style: none; cursor: pointer; color: var(--mute); font-size: 12px; padding: 0 4px; }
305 305
details.linecomment summary:hover { color: var(--acc); }
306 306
details.linecomment form { font-family: "Barlow", sans-serif; background: var(--panel); border: 1px solid var(--acc); padding: 8px; margin: 4px 0; }
307 307
details.linecomment textarea { width: 100%; font: inherit; background: var(--bg); color: var(--ink); border: 1px solid var(--line); }
…
85 unchanged lines
393 393
.file-side li a { display: block; padding: 2px 12px 2px calc(12px + var(--d, 0) * 14px); color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
394 394
.file-side li a:hover { background: var(--soft); text-decoration: none; }
395 395
.file-side li.here a { background: color-mix(in srgb, var(--acc) 16%, transparent); font-weight: 600; box-shadow: inset 3px 0 0 var(--acc); }
396 -
.file-side .glyph { color: var(--dim); font-size: 10px; }
396 +
.file-side .glyph { color: var(--mute); font-size: 10px; }
397 397
.file-main { min-width: 0; overflow-x: auto; }
398 398
.linebar { display: flex; align-items: center; gap: 8px; padding: 6px 12px; border-bottom: 1px solid var(--line); background: color-mix(in srgb, var(--acc) 8%, var(--panel)); }
399 399
.linebar[hidden] { display: none; }
…
3 unchanged lines
403 403
table.code td.blame.age-3 { --age: 4; } table.code td.blame.age-4 { --age: 5; } table.code td.blame.age-5 { --age: 6; }
404 404
table.code td.blame.age-6 { --age: 7; } table.code td.blame.age-7 { --age: 8; } table.code td.blame.age-8 { --age: 9; }
405 405
table.code td.blame.age-9 { --age: 10; }
406 -
table.code td.blame .before { color: var(--dim); margin-left: 4px; }
406 +
table.code td.blame .before { color: var(--mute); margin-left: 4px; }
407 407
table.code td.blame .before:hover { color: var(--acc); text-decoration: none; }
408 408
@media (max-width: 900px) {
409 409
  .file-layout { grid-template-columns: minmax(0, 1fr); }
…
63 unchanged lines
473 473
.picture-row { display: flex; gap: 18px; align-items: flex-start; flex-wrap: wrap; }
474 474
.picture-row img.avatar.xl { width: 96px; height: 96px; }
475 475
.picture-row > div { flex: 1; min-width: 220px; }
476 +
477 +
/* Accessibility and narrow screens (hub spec §6.13). */
478 +
:focus-visible { outline: 2px solid var(--acc); outline-offset: 2px; }
479 +
.band a:focus-visible, .tabs a:focus-visible { outline-offset: -2px; }
480 +
.skip { position: absolute; left: 12px; top: -48px; z-index: 100; padding: 8px 14px; background: var(--acc); color: var(--bg); font-weight: 600; }
481 +
.skip:focus { top: 8px; }
482 +
main:focus { outline: none; }
483 +
@media (max-width: 760px) {
484 +
  .tabs-in { overflow-x: auto; white-space: nowrap; scrollbar-width: thin; -webkit-overflow-scrolling: touch; }
485 +
  .tabs-in > * { flex-shrink: 0; }
486 +
  .band { overflow-x: auto; white-space: nowrap; }
487 +
  main table.list, main table.flow-runs { display: block; overflow-x: auto; }
488 +
  .sectioned { grid-template-columns: 1fr; }
489 +
  .section-nav { position: static; display: flex; flex-wrap: wrap; gap: 4px; }
490 +
  header .find { display: none; }
491 +
}
492 +
@media (prefers-reduced-motion: reduce) { * { scroll-behavior: auto !important; transition: none !important; } }