OXYGENHUB

feat(web): sidebar cards, a shaded tree head, icons, aligned Diffusion tables (closes #152) c9aacc0e7dc1

The repository sidebar is a column of rounded cards with shaded headings.
Diffusion is one card, Takes from and Diffuses to, each repository on its
own line with its pill beneath; bookmarks are the ten most recently moved
with a link to the rest; the hub's delegate key carries the OxygenHub mark
and no longer wraps. The tree has a shaded head with the latest change, its
author and the history, and folder, file, link and subrepo icons; the site
bar has icons. The Diffusion tables share one column grid, and the off half
of the pill lost its stray dashed border. Help › Coming from GitHub covers
submodules and Renovate/Dependabot.
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-13 13:24 UTC
Signature
✔ signed by Andy McCall
Hash
c9aacc0e7dc16401299181ea95653dd34f1a0447778c213d09a69d1c9337e61b
Depends on
07b1a5bbf481 145306044896 1553bfc9ae53 18e381ea88c2 19bb31ff1f5c 206c1fc1b06a 25ab8c73514a 3f4b0ea6eabf 42fa95540468 43e8ca33bc5d 4edcf24025cf 58f915792b85 64caed18236a 6ad368c5958e 73ef9f767132 749d9a5fcd9f 756fe44a0f4d 757195ad7a5d 83d5052bf76c 8933faff2043 9198231cee96 9e50caedf4db a024e800af4e a7e6e8e6094c b5668108487f d23464c0ac60 e9bfea8e26db f677d6270298
Depended on by
166f6e9bf4f1 e97500b70f9a 16ee5d19d220 a10446c68043 67be8ac00c24 3dd6582de060 ffb67a81188c 35fea4237cc9 6ae3fa0af4f6 d7d182467382 61ecbd299f22 7f283f8e8b97 f8cf54e99879 1656dc14c2ac 4fa0da126b70 08e221b77658 eb2a3502601d
In bookmarks
api-unknown-fields cancel-landed-runs diffusion-pin-bookmark draft-ready main oh-comment-body-file proposal-list-fast
In tags
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
56 atoms, 1 tree operation, 15 files

Browse the tree at this change

added crates/hub/src/web/icons.rs +58 −0
1 +
//! Small inline icons (oxygenhub#152): 16-unit SVGs drawn in
2 +
//! `currentColor`, so they take the colour of the text around them.
3 +
4 +
/// The opening tag every icon shares.
5 +
macro_rules! open {
6 +
    () => {
7 +
        r#"<svg class="ic" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">"#
8 +
    };
9 +
}
10 +
11 +
/// The icon by name, as markup; empty for a name it does not know.
12 +
pub fn svg(name: &str) -> &'static str {
13 +
    match name {
14 +
        "home" => concat!(open!(), r#"<path d="M2.5 7.5 8 3l5.5 4.5V13a.5.5 0 0 1-.5.5H9.5V10h-3v3.5H3a.5.5 0 0 1-.5-.5z"/></svg>"#),
15 +
        "explore" => concat!(open!(), r#"<circle cx="8" cy="8" r="6"/><path d="m10.6 5.4-1.6 3.6-3.6 1.6 1.6-3.6z"/></svg>"#),
16 +
        "help" => concat!(
17 +
            open!(),
18 +
            r#"<circle cx="8" cy="8" r="6"/><path d="M6.3 6.4a1.8 1.8 0 1 1 2.5 1.6c-.5.2-.8.6-.8 1.1v.4"/><circle cx="8" cy="11.4" r=".5" fill="currentColor" stroke="none"/></svg>"#
19 +
        ),
20 +
        "projects" => concat!(open!(), r#"<rect x="2" y="2.5" width="12" height="11" rx="1.5"/><path d="M6 2.5v11M10 2.5v7"/></svg>"#),
21 +
        "bell" => concat!(open!(), r#"<path d="M4 11V7a4 4 0 0 1 8 0v4l1.2 1.5H2.8z"/><path d="M6.6 14a1.5 1.5 0 0 0 2.8 0"/></svg>"#),
22 +
        "settings" => concat!(
23 +
            open!(),
24 +
            r#"<path d="M2 4.5h7M12.5 4.5H14M2 11.5h1.5M7 11.5h7"/><circle cx="10.75" cy="4.5" r="1.75"/><circle cx="5.25" cy="11.5" r="1.75"/></svg>"#
25 +
        ),
26 +
        "admin" => concat!(open!(), r#"<path d="M8 1.8 13 3.8v3.7c0 3-2.1 5.6-5 6.7-2.9-1.1-5-3.7-5-6.7V3.8z"/></svg>"#),
27 +
        "dir" => concat!(
28 +
            open!(),
29 +
            r#"<path d="M1.75 3h4.1l1.6 1.6h6.8c.4 0 .75.35.75.75v7.15c0 .4-.35.75-.75.75H1.75c-.4 0-.75-.35-.75-.75V3.75c0-.4.35-.75.75-.75z" fill="currentColor" stroke="none"/></svg>"#
30 +
        ),
31 +
        "file" => concat!(
32 +
            open!(),
33 +
            r#"<path d="M4 1.75h5l3.5 3.5v8.5a.5.5 0 0 1-.5.5H4a.5.5 0 0 1-.5-.5V2.25a.5.5 0 0 1 .5-.5z"/><path d="M9 1.75v3.5h3.5"/></svg>"#
34 +
        ),
35 +
        "link" => concat!(
36 +
            open!(),
37 +
            r#"<path d="M4 1.75h5l3.5 3.5v8.5a.5.5 0 0 1-.5.5H4a.5.5 0 0 1-.5-.5V2.25a.5.5 0 0 1 .5-.5z"/><path d="M6 11.5 9.5 8M7 8h2.5v2.5"/></svg>"#
38 +
        ),
39 +
        "subrepo" => concat!(open!(), r#"<path d="M2.5 5 8 2.5 13.5 5v6L8 13.5 2.5 11z"/><path d="M2.5 5 8 7.5 13.5 5M8 7.5v6"/></svg>"#),
40 +
        "up" => concat!(open!(), r#"<path d="M6 3.5 3 6.5l3 3"/><path d="M3 6.5h6.5a3 3 0 0 1 3 3v3"/></svg>"#),
41 +
        "history" => concat!(open!(), r#"<circle cx="8" cy="8" r="6"/><path d="M8 4.5V8l2.5 1.5"/></svg>"#),
42 +
        _ => "",
43 +
    }
44 +
}
45 +
46 +
#[cfg(test)]
47 +
mod tests {
48 +
    #[test]
49 +
    fn every_named_icon_is_one_closed_svg() {
50 +
        for name in ["home", "explore", "help", "projects", "bell", "settings", "admin", "dir", "file", "link", "subrepo", "up", "history"]
51 +
        {
52 +
            let s = super::svg(name);
53 +
            assert!(s.starts_with("<svg class=\"ic\"") && s.ends_with("</svg>"), "{name}");
54 +
            assert_eq!(s.matches("<svg").count(), 1, "{name}");
55 +
        }
56 +
        assert_eq!(super::svg("nothing"), "");
57 +
    }
58 +
}
modified CHANGELOG.md +2 −1
…
3 unchanged lines
4 4
5 5
## Unreleased
6 6
7 -
- **Diffusion (#148, docs/diffusion.md).** When a repository that others pin as a subrepo moves on, each consumer that follows it gets a proposal moving the pin, signed by the hub, tested by the consumer's own pipeline, with the upstream's changes listed. One proposal per consumer and subrepo, kept up to date; never landed by itself. Turned on per repository (Settings › Diffusion, `PUT /repos/{owner}/{name}/diffusion`, or `follow` in `.oxymodules`), off by default; `green` waits for the upstream's run to pass. A repository's page shows who uses it and how far behind each is (Used by, `GET /repos/{owner}/{name}/consumers`). Consumers are bumped on the bookmark their work lands on, learned from where their proposals land.
7 +
- **Diffusion (#148, docs/diffusion.md).** When a repository that others pin as a subrepo moves on, each consumer that follows it gets a proposal moving the pin, signed by the hub, tested by the consumer's own pipeline, with the upstream's changes listed. One proposal per consumer and subrepo, kept up to date; never landed by itself. Turned on per repository (Settings › Diffusion, `PUT /repos/{owner}/{name}/diffusion`, or `follow` in `.oxymodules`), off by default; `green` waits for the upstream's run to pass. A repository's page shows who uses it and how far behind each is (Diffusion › Diffuses to, `GET /repos/{owner}/{name}/consumers`). Consumers are bumped on the bookmark their work lands on, learned from where their proposals land.
8 +
- **Tidier pages (#152).** The repository sidebar is a column of cards with shaded headings: Diffusion in one card (Takes from and Diffuses to, each with its pill), the ten most recently moved bookmarks with a link to the rest, and the hub's delegate key shown with the OxygenHub mark. The code tree has a shaded head with the latest change and the history, and folder and file icons; the site bar has icons; the Diffusion tables share one column grid; Help › Coming from GitHub covers submodules and Renovate.
8 9
- **Admin › Runners › Images (#146):** every runner image, where it is and what uses it.
9 10
- **Faster pages after a restart (#145):** bookmark summaries are kept on disk; slow requests are logged.
10 11
…
162 unchanged lines
modified assets/github.css +3 −0
…
192 unchanged lines
193 193
:root[data-look="github"] select:not([multiple]) { padding-right: 30px; }
194 194
:root[data-look="github"] button.menu-item.menu-pick { background: none; border: 0; border-bottom: 1px solid var(--line); border-radius: 0; box-shadow: none; padding: 8px 12px; text-transform: none; font-size: 13px; font-weight: 400; text-align: left; }
195 195
:root[data-look="github"] button.menu-item.menu-pick:hover { background: var(--soft); }
196 +
/* The tree under its shaded head (#152): the head takes the top corners. */
197 +
:root[data-look="github"] .tree-box .tree-head + table.tree { border-top-left-radius: 0; border-top-right-radius: 0; }
198 +
:root[data-look="github"] .side-card > h3 { font-size: 14px; }
modified crates/hub/src/repos/view.rs +21 −1
…
906 unchanged lines
907 907
    pub languages: Vec<LanguageShare>,
908 908
}
909 909
910 +
impl Overview {
911 +
    /// The sidebar's bookmarks (#152): the default first, then the most
912 +
    /// recently moved, ten in all.
913 +
    pub fn recent_bookmarks(&self) -> Vec<&BookmarkView> {
914 +
        let mut list: Vec<&BookmarkView> = self.bookmarks.iter().collect();
915 +
        list.sort_by(|a, b| b.default.cmp(&a.default).then(b.latest_ms.cmp(&a.latest_ms)).then_with(|| a.name.cmp(&b.name)));
916 +
        list.truncate(10);
917 +
        list
918 +
    }
919 +
}
920 +
910 921
/// Someone who authored changes in a state (hub spec §6.4).
911 922
#[derive(Clone, Debug, PartialEq, Eq)]
912 923
pub struct Contributor {
…
6 unchanged lines
919 930
    /// Of `changes`, how many a delegate signed for this author, by the
920 931
    /// delegate's name (an agent's work stays the author's, and is shown).
921 932
    pub via: Vec<(String, usize)>,
933 +
    /// The hub's own delegate key, filled by the web layer (#152).
934 +
    pub hub: bool,
922 935
}
923 936
924 937
/// One language's share of a state's code, by bytes.
…
17 unchanged lines
942 955
        // Unsigned changes with no Git author carry no name at all.
943 956
        let name =
944 957
            if a.key.is_empty() && a.email.is_empty() && a.name == "unknown" { "Unattributed changes".to_string() } else { a.name.clone() };
945 -
        let entry = by.entry(id).or_insert_with(|| Contributor { name, key: a.key.clone(), changes: 0, account: None, via: Vec::new() });
958 +
        let entry = by.entry(id).or_insert_with(|| Contributor {
959 +
            name,
960 +
            key: a.key.clone(),
961 +
            changes: 0,
962 +
            account: None,
963 +
            via: Vec::new(),
964 +
            hub: false,
965 +
        });
946 966
        entry.changes += 1;
947 967
        if !meta.author.is_zero() {
948 968
            let signer = repo.store().signatures(hash).ok().and_then(|s| s.first().map(|s| s.key)).filter(|k| *k != meta.author);
…
550 unchanged lines
modified crates/hub/src/web/mod.rs +1 −0
…
16 unchanged lines
17 17
pub mod help;
18 18
pub mod history;
19 19
pub mod home;
20 +
pub mod icons;
20 21
mod inbox;
21 22
pub mod issues;
22 23
mod orgs;
…
722 unchanged lines
modified crates/hub/src/web/repo.rs +20 −5
…
109 unchanged lines
110 110
        app.repos.read(&repo, move |r, cache| view::overview(r, cache, &default)).await.map_err(|e| Error::from_repo(shell.clone(), e))?;
111 111
    // Contributors link to the hub accounts their keys are linked to.
112 112
    let linked: HashMap<String, String> = crate::db::accounts::identity_accounts(&app.db).await.unwrap_or_default().into_iter().collect();
113 +
    let hub_key = app.hub_key.key.to_hex();
113 114
    for c in &mut o.contributors {
114 115
        c.account = linked.get(&c.key).cloned();
116 +
        c.hub = c.key == hub_key;
115 117
    }
118 +
    if let Some(c) = o.latest.as_mut() {
119 +
        c.author.account = linked.get(&c.author.key).cloned();
120 +
    }
116 121
    // One row per account: a person's keys over time are one contributor,
117 122
    // their agents' shares summed beneath (as `oxy log` shows via).
118 123
    let mut merged: Vec<view::Contributor> = Vec::new();
…
319 unchanged lines
438 443
}
439 444
440 445
impl EntryKind {
441 -
    pub fn glyph(&self) -> &'static str {
446 +
    /// The tree's icon for the entry (oxygenhub#152).
447 +
    pub fn icon(&self) -> &'static str {
448 +
        super::icons::svg(match self {
449 +
            EntryKind::Dir => "dir",
450 +
            EntryKind::File => "file",
451 +
            EntryKind::Symlink => "link",
452 +
            EntryKind::Subrepo => "subrepo",
453 +
        })
454 +
    }
455 +
456 +
    pub fn class(&self) -> &'static str {
442 457
        match self {
443 -
            EntryKind::Dir => "▸",
444 -
            EntryKind::File => "·",
445 -
            EntryKind::Symlink => "↪",
446 -
            EntryKind::Subrepo => "◫",
458 +
            EntryKind::Dir => "ic-dir",
459 +
            EntryKind::File => "ic-file",
460 +
            EntryKind::Symlink => "ic-link",
461 +
            EntryKind::Subrepo => "ic-sub",
447 462
        }
448 463
    }
449 464
}
…
98 unchanged lines
modified crates/hub/tests/flow.rs +4 −4
…
3167 unchanged lines
3168 3168
    assert!(api.push("origin", false).unwrap().iter().all(|r| r.ok));
3169 3169
    assert_eq!(settle(&|l| l.first().is_some_and(|c| c["behind"] == 1)).await[0]["behind"], 1);
3170 3170
    let (_, page) = call(&h.app, "GET", "/andy/api", None, None).await;
3171 -
    assert!(page.contains("Used by") && page.contains("andy/game") && page.contains("1 behind"), "the page shows it");
3171 +
    assert!(page.contains("Diffuses to") && page.contains("andy/game") && page.contains("1 behind"), "the page shows it");
3172 3172
3173 3173
    // A private consumer is not shown to someone who cannot read it.
3174 3174
    sqlx::query("UPDATE repositories SET visibility = 'private' WHERE name = 'game'").execute(&h.app.db).await.unwrap();
…
210 unchanged lines
3385 3385
    pass_all().await;
3386 3386
    let (st, page) = call(&h.app, "GET", "/andy/api/diffusion", None, None).await;
3387 3387
    assert_eq!(st, StatusCode::OK);
3388 -
    assert!(page.contains("Used by") && page.contains("andy/game") && page.contains(&format!("#{}", p2.number)), "the bump is listed");
3388 +
    assert!(page.contains("Diffuses to") && page.contains("andy/game") && page.contains(&format!("#{}", p2.number)), "the bump is listed");
3389 3389
    assert!(page.contains("df-green"), "a passing run of a checked upstream is green");
3390 3390
    let (_, page) = call(&h.app, "GET", "/andy/game/diffusion", None, None).await;
3391 -
    assert!(page.contains("Follows") && page.contains("andy/api"));
3391 +
    assert!(page.contains("Takes from") && page.contains("andy/api"));
3392 3392
    let (_, overview) = call(&h.app, "GET", "/andy/game", None, None).await;
3393 -
    assert!(overview.contains("Follows") && overview.contains(">Diffusion</a>"), "the tab and the panel");
3393 +
    assert!(overview.contains("Takes from") && overview.contains(">Diffusion</a>"), "the tab and the panel");
3394 3394
    let (st, _) = call(&h.app, "GET", "/admin/diffusion", None, None).await;
3395 3395
    assert_eq!(st, StatusCode::NOT_FOUND, "administrators only");
3396 3396
…
148 unchanged lines
modified crates/hub/tests/issues.rs +1 −1
…
755 unchanged lines
756 756
    // a New project button on the list; a stranger is told who can make one.
757 757
    let home = get(app, "/andy/demo", Some(&andy)).await.body;
758 758
    assert!(
759 -
        home.contains("href=\"/projects/andy\">Projects</a>") && home.contains("title=\"andy's project boards\">Projects</a>"),
759 +
        home.contains("href=\"/projects/andy\"><svg class=\"ic\"") && home.contains("title=\"andy's project boards\">Projects</a>"),
760 760
        "site bar and tab"
761 761
    );
762 762
    let list = get(app, "/projects/andy", Some(&andy)).await.body;
…
147 unchanged lines
modified templates/base.html +6 −6
…
28 unchanged lines
29 29
  </nav>
30 30
</header>
31 31
<nav class="band" aria-label="Site">
32 -
  <a href="/">{% if shell.user.is_some() %}Dashboard{% else %}Repositories{% endif %}</a>
33 -
  <a href="/explore">Explore</a>
34 -
  <a href="/help">Help</a>
35 -
  {% if let Some(u) = shell.user %}<a href="/projects/{{ u }}">Projects</a><a href="/notifications">Notifications</a><a href="/settings">Settings</a>{% endif %}
32 +
  <a href="/">{{ crate::web::icons::svg("home")|safe }}{% if shell.user.is_some() %}Dashboard{% else %}Repositories{% endif %}</a>
33 +
  <a href="/explore">{{ crate::web::icons::svg("explore")|safe }}Explore</a>
34 +
  <a href="/help">{{ crate::web::icons::svg("help")|safe }}Help</a>
35 +
  {% if let Some(u) = shell.user %}<a href="/projects/{{ u }}">{{ crate::web::icons::svg("projects")|safe }}Projects</a><a href="/notifications">{{ crate::web::icons::svg("bell")|safe }}Notifications</a><a href="/settings">{{ crate::web::icons::svg("settings")|safe }}Settings</a>{% endif %}
36 36
  <span class="grow"></span>
37 -
  {% if shell.is_admin %}<a class="admin" href="/admin">Admin</a>{% endif %}
37 +
  {% if shell.is_admin %}<a class="admin" href="/admin">{{ crate::web::icons::svg("admin")|safe }}Admin</a>{% endif %}
38 38
  <a class="dim" href="/about" title="OxygenHub {{ shell.version }} ({{ shell.build }})">v{{ shell.version }}</a>
39 39
</nav>
40 40
{% block tabs %}{% endblock %}
…
7 unchanged lines
48 48
      <p class="small">{{ shell.site }} · a forge for Oxygen repositories. Nothing here is inferred: what a page says, the object store says.</p>
49 49
    </div>
50 50
    <div class="cols">
51 -
      <div><h3>Use</h3><a href="/help">Help</a><a href="/explore">Explore</a><a href="/api/v1/repos">API</a><a href="/.well-known/oxyhub-key">Hub key</a></div>
51 +
      <div><h3>Use</h3><a href="/help">Help</a><a href="/explore">{{ crate::web::icons::svg("explore")|safe }}Explore</a><a href="/api/v1/repos">API</a><a href="/.well-known/oxyhub-key">Hub key</a></div>
52 52
      <div><h3>Keys</h3><span class="small"><kbd>/</kbd> search · <kbd>g</kbd><kbd>c</kbd> code · <kbd>g</kbd><kbd>h</kbd> history · <kbd>?</kbd> help</span></div>
53 53
    </div>
54 54
  </div>
…
9 unchanged lines
modified templates/diffusion.html +2 −2
…
7 unchanged lines
8 8
<div class="head"><h1>Diffusion <small>how changes move between this repository and the ones that pin it</small></h1></div>
9 9
<p class="small mute df-key">Each pill has two halves. Left, the upstream: <span class="df-pill"><span class="df-half df-green">checked</span></span> its own run passed, <span class="df-pill"><span class="df-half df-amber">untested</span></span> it has no checks, <span class="df-pill"><span class="df-half df-red">failing</span></span> its run failed. Right, the repository that pins it: <span class="df-pill"><span class="df-half df-green">passes</span></span> its run on the bump passed, <span class="df-pill"><span class="df-half df-red">fails</span></span> it failed, <span class="df-pill"><span class="df-half df-grey">testing</span></span> still running, <span class="df-pill"><span class="df-half df-green">up to date</span></span> nothing to bump.</p>
10 10
{% if !used_by.is_empty() %}
11 -
<h2>Used by <span class="count-chip">{{ used_by.len() }}</span></h2>
11 +
<h2>Diffuses to <span class="count-chip">{{ used_by.len() }}</span></h2>
12 12
{% if unverified %}<div class="strip warn">{{ repo.owner }}/{{ repo.name }} has no checks of its own, so its consumers' bumps are amber: only their own pipelines test them. A <code>.flow/pipeline.kdl</code> here that passes turns them green.</div>{% endif %}
13 13
{% call d::rows(used_by) %}
14 14
{% endif %}
15 15
{% if !follows.is_empty() %}
16 -
<h2>Follows <span class="count-chip">{{ follows.len() }}</span></h2>
16 +
<h2>Takes from <span class="count-chip">{{ follows.len() }}</span></h2>
17 17
<div class="table"><table class="diffusion">
18 18
  <tr><th>Upstream</th><th>Subrepo</th><th>Behind</th><th>Upstream │ downstream</th><th>Open bump</th><th>Last landed</th></tr>
19 19
  {% for (up, c) in follows %}
…
14 unchanged lines
modified templates/diffusion_rows.html +2 −1
…
1 unchanged lines
2 2
3 3
{% macro rows(list) %}
4 4
<div class="table"><table class="diffusion">
5 +
  <colgroup><col class="df-c-repo"><col class="df-c-pin"><col class="df-c-behind"><col class="df-c-pill"><col class="df-c-bump"><col class="df-c-landed"><col class="df-c-follow"></colgroup>
5 6
  <tr><th>Repository</th><th>Pin on</th><th>Behind</th><th>Upstream │ downstream</th><th>Open bump</th><th>Last landed</th><th>Follows</th></tr>
6 7
  {% for c in list %}
7 8
  <tr>
8 -
    <td><a href="/{{ c.owner }}/{{ c.name }}">{{ c.owner }}/{{ c.name }}</a> <span class="small mute">{{ c.path }}</span></td>
9 +
    <td><a href="/{{ c.owner }}/{{ c.name }}">{{ c.owner }}/{{ c.name }}</a> <span class="small mute df-path">{{ c.path }}</span></td>
9 10
    <td class="small"><span class="lbl lbl-bm">{{ c.bookmark }}</span></td>
10 11
    <td class="small">{% match c.behind %}{% when Some(0) %}<span class="mute">0</span>{% when Some(n) %}{{ n }}{% when None %}<span class="mute" title="Its pin names changes this repository does not hold">?</span>{% endmatch %}</td>
11 12
    <td>{% call chip(c) %}</td>
…
7 unchanged lines
modified templates/help.html +2 −0
…
121 unchanged lines
122 122
      <tr><td>pull request</td><td><strong>proposal</strong></td><td>Reviewed and tested on its projected state. It cannot go stale, and approvals can be signed seals.</td></tr>
123 123
      <tr><td>branch protection</td><td><strong>trust policy</strong></td><td>Signed rules in the repository itself: who may move which bookmark, with how many signatures. Every client enforces them, not only the hub.</td></tr>
124 124
      <tr><td>Actions, CI badge</td><td><strong>OxygenFlow</strong>, state stamp</td><td>Jobs run in microVMs on exact states; results are receipts, and the stamp says which state they belong to.</td></tr>
125 +
      <tr><td>git submodule</td><td><strong>subrepo</strong></td><td>Another Oxygen repository pinned at a path by its heads, listed in <code>.oxymodules</code>. The pin is a change like any other, so bumping it combines with your other work instead of conflicting with it.</td></tr>
126 +
      <tr><td>Renovate, Dependabot</td><td><strong>Diffusion</strong></td><td>Built into the hub, not a bot you install. When a subrepo you pin moves on its default bookmark, the hub works out the bump, runs your checks on it and opens a proposal labelled <code>dependencies</code> with the upstream's changelog. Choose per repository under Settings › Diffusion (when the upstream is green, on every move, or off), or per subrepo in <code>.oxymodules</code>; it never lands anything for you. The Diffusion tab shows both directions: what you pin, and who pins you.</td></tr>
125 127
      <tr><td>git blame</td><td><strong>blame</strong></td><td>Exact through rebases and squashes: each line is an atom with one authoring change, so there is no "ignore revisions" file.</td></tr>
126 128
      <tr><td>fork</td><td><strong>fork</strong></td><td>A copy under your name with its own identity and bookmarks, sharing the stored objects. Its proposals go upstream as <code>you/name:bookmark</code>; the upstream's trust policy is not copied.</td></tr>
127 129
      <tr><td>personal access token</td><td><strong>token</strong></td><td>For pushing and the API. Your signing key is what proves authorship; a token only authorises.</td></tr>
…
7 unchanged lines
modified templates/overview.html +35 −20
…
9 unchanged lines
10 10
    {% if o.conflicted %}<div class="strip bad">This state carries conflicts. <a href="{{ repo.url }}/conflicts">See them</a></div>{% endif %}
11 11
    <div class="bar">
12 12
      <span class="ref px"><a href="{{ repo.url }}/bookmarks">◆ {{ repo.default_bookmark }}</a></span>
13 -
      <a class="go small" href="{{ repo.url }}/changes/{{ repo.default_bookmark }}">{{ o.changes|plural("change", "changes") }}</a>
13 +
      {% if o.latest.is_none() %}<a class="go small" href="{{ repo.url }}/changes/{{ repo.default_bookmark }}">{{ o.changes|plural("change", "changes") }}</a>{% endif %}
14 14
      {% if repo.can(crate::auth::Access::Write) %}<a class="go small" href="{{ repo.url }}/newfile/{{ repo.default_bookmark }}">new file</a>{% endif %}
15 15
      <form class="find inline-find" method="get" action="{{ repo.url }}/search"><input type="search" name="q" placeholder="Search code…"></form>
16 16
      <details class="clone-pop">
…
5 unchanged lines
22 22
        </div>
23 23
      </details>
24 24
    </div>
25 -
    {% if let Some(c) = o.latest %}<p class="small mute latest-line">latest <a href="{{ repo.url }}/change/{{ c.hash }}">{{ c.title }}</a> · {{ c.when_ms|ago }}</p>{% endif %}
26 25
    {% if let Some(q) = quickstart %}
27 26
    {% include "quickstart.html" %}
28 27
    {% else %}
28 +
    <div class="tree-box">
29 +
    {% if let Some(c) = o.latest %}<div class="tree-head">
30 +
      <span class="tree-title">{% if let Some(a) = c.author.account %}<a class="who" href="/{{ a }}"><img class="avatar" src="/avatar/{{ a }}" alt="" width="20" height="20"> {{ a }}</a>{% else %}<span class="who">{{ c.author.name }}</span>{% endif %} <a class="t" href="{{ repo.url }}/change/{{ c.hash }}" title="{{ c.title }}">{{ c.title }}</a></span>
31 +
      <span class="tree-meta"><a href="{{ repo.url }}/change/{{ c.hash }}"><code>{{ c.short }}</code></a><time class="ago" title="{{ c.when_ms|when }}">{{ c.when_ms|ago }}</time><a class="hist" href="{{ repo.url }}/changes/{{ repo.default_bookmark }}">{{ crate::web::icons::svg("history")|safe }} {{ o.changes|plural("change", "changes") }}</a></span>
32 +
    </div>{% endif %}
29 33
    {% include "tree_table.html" %}
34 +
    </div>
30 35
    {% endif %}
31 36
    {% if let Some(html) = readme_html %}
32 37
    <article class="doc readme"><h2 class="small mute">{{ o.tree.readme.as_ref().unwrap().path }}</h2>{{ html|safe }}</article>
33 38
    {% endif %}
34 39
  </div>
35 -
  <aside class="side">
36 -
    <h3>About</h3>
40 +
  <aside class="side cards">
41 +
    <section class="side-card"><h3>About</h3><div class="body">
37 42
    {% if repo.description.is_empty() %}<p class="mute small">No description.</p>{% else %}<p>{{ repo.description }}</p>{% endif %}
38 43
    {% if let Some(up) = fork.upstream %}<p class="small">forked from <a href="/{{ up.owner }}/{{ up.name }}">{{ up.owner }}/{{ up.name }}</a></p>{% endif %}
44 +
    {% if !topics.is_empty() %}<p class="topics">{% for t in topics %}<a class="topic" href="/explore?topic={{ t }}">{{ t }}</a>{% endfor %}</p>{% endif %}
39 45
    <div class="bar">
40 -
    {% if !topics.is_empty() %}<p class="topics">{% for t in topics %}<a class="topic" href="/explore?topic={{ t }}">{{ t }}</a>{% endfor %}</p>{% endif %}
41 46
    <p class="small mute counts-line"><span title="stars">★ {{ stars }}</span> · <span title="watchers">👁 {{ watchers }}</span></p>
42 47
    {% if shell.user.is_some() %}<form class="inline" method="post" action="{{ repo.url }}/star"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button type="submit" class="small">{% if starred %}★ UNSTAR{% else %}☆ STAR{% endif %}</button></form>
43 48
    <form class="inline" method="post" action="{{ repo.url }}/watch"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button type="submit" class="small">{% if watching %}UNWATCH{% else %}WATCH{% endif %}</button></form>
…
1 unchanged lines
45 50
    {% if fork.forks > 0 %}<a class="small" href="{{ repo.url }}/forks">{{ fork.forks|plural("fork", "forks") }}</a>{% endif %}
46 51
    </div>
47 52
    {% if let Some(up) = fork.upstream %}{% if shell.user.is_some() %}<a class="go small" href="/{{ up.owner }}/{{ up.name }}/proposals/new?source={{ repo.owner }}/{{ repo.name }}:{{ repo.default_bookmark }}">propose to {{ up.owner }}/{{ up.name }}</a>{% endif %}{% endif %}
53 +
    {% if let Some(l) = o.license %}<p class="small">Licence: <a href="{{ repo.url }}/file/{{ repo.default_bookmark }}/{{ l }}">{{ l }}</a></p>{% endif %}
54 +
    </div></section>
55 +
    {% if let Some(rel) = latest_release %}<section class="side-card"><h3>Latest release</h3><div class="body"><p><a href="{{ repo.url }}/releases/tag/{{ rel.tag }}">{{ rel.title }}</a> <span class="small mute">{{ rel.created_ms|ago_i }}</span></p></div></section>{% endif %}
56 +
    {% if !follows.is_empty() || !used_by.is_empty() %}
57 +
    <section class="side-card"><h3>Diffusion <a class="more" href="{{ repo.url }}/diffusion">details ›</a></h3><div class="body">
58 +
    {% if !follows.is_empty() %}<h4>Takes from</h4>
59 +
    <ul class="plain df-list">{% for (up, c) in follows %}<li><a class="df-name" href="/{{ up.owner }}/{{ up.name }}" title="pinned at {{ c.path }}">{{ up.owner }}/{{ up.name }}</a>
60 +
      <span class="df-line"><span class="df-pill" role="img" aria-label="upstream {{ c.upstream.word }}, here {{ c.downstream.word }}"><span class="df-half {{ c.upstream.class }}" title="Upstream: {{ c.upstream.title }}">{{ c.upstream.word }}</span><span class="df-half {{ c.downstream.class }}" title="Here: {{ c.downstream.title }}">{{ c.downstream.word }}</span></span>{% if let Some(n) = c.bump %}<a href="{{ repo.url }}/proposal/{{ n }}">bump #{{ n }}</a>{% endif %}</span></li>{% endfor %}</ul>{% endif %}
61 +
    {% if !used_by.is_empty() %}<h4>Diffuses to <span class="count-chip">{{ used_by.len() }}</span></h4>
62 +
    <ul class="plain df-list">{% for c in used_by %}<li><a class="df-name" href="/{{ c.owner }}/{{ c.name }}">{{ c.owner }}/{{ c.name }}</a>
63 +
      <span class="df-line"><span class="df-pill" role="img" aria-label="here {{ c.upstream.word }}, {{ c.name }} {{ c.downstream.word }}"><span class="df-half {{ c.upstream.class }}" title="Here: {{ c.upstream.title }}">{{ c.upstream.word }}</span><span class="df-half {{ c.downstream.class }}" title="{{ c.owner }}/{{ c.name }}: {{ c.downstream.title }}">{{ c.downstream.word }}</span></span>{% match c.behind %}{% when Some(0) %}{% when Some(n) %}<span class="mute">{{ n }} behind</span>{% when None %}{% endmatch %}</span></li>{% endfor %}</ul>{% endif %}
64 +
    </div></section>
65 +
    {% endif %}
48 66
    {% if !o.contributors.is_empty() %}
49 -
    <h3>Contributors <span class="count-chip">{{ o.contributors.len() }}</span></h3>
67 +
    <section class="side-card"><h3>Contributors <span class="count-chip">{{ o.contributors.len() }}</span></h3><div class="body">
50 68
    <ul class="plain contributors">{% for c in o.contributors.iter().take(10) %}
51 -
      <li>{% if let Some(u) = c.account %}<a href="/{{ u }}"><img class="avatar sm" src="/avatar/{{ u }}" alt="" width="20" height="20"> {{ u }}</a> <span class="mute small">{{ c.name }}</span>{% else %}<span class="avatar-none" aria-hidden="true">○</span> {{ c.name }}{% endif %} <span class="mute small" title="changes">· {{ c.changes }}</span>{% if !c.via.is_empty() %}<ul class="plain via-list">{% for v in c.via %}<li><span class="mute" aria-hidden="true">↳</span> <span class="mute small">via</span> <img class="avatar sm" src="/avatar/agent/{{ v.0 }}" alt="" width="16" height="16"> {{ v.0 }} <span class="mute small" title="changes signed by this agent for them">· {{ v.1 }}</span></li>{% endfor %}</ul>{% endif %}</li>
69 +
      <li class="c">{% if let Some(u) = c.account %}<img class="avatar sm" src="/avatar/{{ u }}" alt="" width="20" height="20"><a class="who" href="/{{ u }}" title="{{ c.name }}">{{ u }} <span class="mute small">{{ c.name }}</span></a>{% else if c.hub %}<img class="avatar sm" src="/favicon.svg" alt="" width="20" height="20"><span class="who" title="{{ c.name }}: this hub's delegate key, signing the changes made in the browser for people who allowed it">{{ c.name }}</span>{% else %}<span class="avatar-none" aria-hidden="true">○</span><span class="who" title="{{ c.name }}">{{ c.name }}</span>{% endif %}<span class="n mute small" title="changes">{{ c.changes }}</span>{% if !c.via.is_empty() %}<ul class="plain via-list">{% for v in c.via %}<li><span class="mute" aria-hidden="true">↳</span> <span class="mute small">via</span> <img class="avatar sm" src="/avatar/agent/{{ v.0 }}" alt="" width="16" height="16"> {{ v.0 }} <span class="mute small" title="changes signed by this agent for them">· {{ v.1 }}</span></li>{% endfor %}</ul>{% endif %}</li>
52 70
    {% endfor %}</ul>
53 71
    {% if o.contributors.len() > 10 %}<p class="small mute">and {{ o.contributors.len() - 10 }} more</p>{% endif %}
72 +
    </div></section>
54 73
    {% endif %}
55 74
    {% if !o.languages.is_empty() %}
56 -
    <h3>Languages</h3>
75 +
    <section class="side-card"><h3>Languages</h3><div class="body">
57 76
    <div class="lang-bar" role="img" aria-label="{% for l in o.languages %}{{ l.name }} {{ "{:.1}"|format(l.percent) }}%{% if !loop.last %}, {% endif %}{% endfor %}">{% for l in o.languages %}<span style="width: {{ "{:.3}"|format(l.percent) }}%; background: {{ l.colour }}" title="{{ l.name }} {{ "{:.1}"|format(l.percent) }}%"></span>{% endfor %}</div>
58 77
    <ul class="plain langs">{% for l in o.languages %}<li><span class="lang-dot" style="background: {{ l.colour }}" aria-hidden="true"></span> <strong>{{ l.name }}</strong> <span class="mute">{{ "{:.1}"|format(l.percent) }}%</span></li>{% endfor %}</ul>
78 +
    </div></section>
59 79
    {% endif %}
60 -
    <h3>Bookmarks</h3>
61 -
    <ul class="plain">{% for b in o.bookmarks %}<li><a href="{{ repo.url }}/tree/{{ b.name }}">{{ b.name }}</a>{% if b.heads.len() > 1 %} <span class="tag warn">{{ b.heads.len() }} heads</span>{% endif %}{% if b.conflicted %} <span class="tag bad">conflicts</span>{% endif %}</li>{% endfor %}</ul>
62 -
    {% if let Some(rel) = latest_release %}<h3>Latest release</h3><p><a href="{{ repo.url }}/releases/tag/{{ rel.tag }}">{{ rel.title }}</a> <span class="small mute">{{ rel.created_ms|ago_i }}</span></p>{% endif %}
63 -
    {% if !used_by.is_empty() %}<h3>Used by <span class="count-chip">{{ used_by.len() }}</span></h3>
64 -
    <ul class="plain used-by">{% for c in used_by %}<li><a href="/{{ c.owner }}/{{ c.name }}">{{ c.owner }}/{{ c.name }}</a> <span class="df-pill"><span class="df-half {{ c.upstream.class }}" title="Upstream: {{ c.upstream.title }}">{{ c.upstream.word }}</span><span class="df-half {{ c.downstream.class }}" title="Downstream: {{ c.downstream.title }}">{% match c.behind %}{% when Some(0) %}up to date{% when Some(n) %}{{ c.downstream.word }} · {{ n }} behind{% when None %}{{ c.downstream.word }}{% endmatch %}</span></span></li>{% endfor %}</ul>
65 -
    <p class="small"><a href="{{ repo.url }}/diffusion">Diffusion ›</a></p>{% endif %}
66 -
    {% if !follows.is_empty() %}<h3>Follows</h3>
67 -
    <ul class="plain used-by">{% for (up, c) in follows %}<li><a href="/{{ up.owner }}/{{ up.name }}">{{ up.owner }}/{{ up.name }}</a> <span class="df-pill" role="img" aria-label="upstream {{ c.upstream.word }}, downstream {{ c.downstream.word }}"><span class="df-half {{ c.upstream.class }}" title="Upstream: {{ c.upstream.title }}">{{ c.upstream.word }}</span><span class="df-half {{ c.downstream.class }}" title="Downstream: {{ c.downstream.title }}">{{ c.downstream.word }}</span></span>{% if let Some(n) = c.bump %} <a class="small" href="{{ repo.url }}/proposal/{{ n }}">#{{ n }}</a>{% endif %}</li>{% endfor %}</ul>
68 -
    <p class="small"><a href="{{ repo.url }}/diffusion">Diffusion ›</a></p>{% endif %}
69 -
    <h3>Tags</h3>
80 +
    <section class="side-card"><h3>Bookmarks <span class="count-chip">{{ o.bookmarks.len() }}</span>{% if o.bookmarks.len() > 10 %}<a class="more" href="{{ repo.url }}/bookmarks">all {{ o.bookmarks.len() }} ›</a>{% endif %}</h3><div class="body">
81 +
    <ul class="plain bookmarks">{% for b in o.recent_bookmarks() %}<li><a href="{{ repo.url }}/tree/{{ b.name }}" title="{{ b.name }}">{{ b.name }}</a>{% if b.heads.len() > 1 %}<span class="tag warn">{{ b.heads.len() }} heads</span>{% endif %}{% if b.conflicted %}<span class="tag bad">conflicts</span>{% endif %}<time class="small mute" title="{{ b.latest_ms|when }}">{{ b.latest_ms|ago }}</time></li>{% endfor %}</ul>
82 +
    </div></section>
83 +
    <section class="side-card"><h3>Tags</h3><div class="body">
70 84
    {% if o.tags.is_empty() %}<p class="mute small">None.</p>{% else %}<ul class="plain">{% for t in o.tags %}<li><a href="{{ repo.url }}/tree/{{ t.name }}">{{ t.name }}</a>{% if t.signer.is_some() %} <span class="sig sig-valid" title="signed">✔</span>{% endif %}</li>{% endfor %}</ul>{% endif %}
71 -
    <h3>Trust</h3>
85 +
    </div></section>
86 +
    <section class="side-card"><h3>Trust</h3><div class="body">
72 87
    {% match o.policy %}
73 88
    {% when crate::repos::view::PolicyBadge::Active %}<p><a href="{{ repo.url }}/trust"><span class="sig sig-valid">✔</span> policy in force</a></p>
74 89
    {% when crate::repos::view::PolicyBadge::Expired %}<p><a href="{{ repo.url }}/trust"><span class="sig sig-invalid">✘</span> policy expired</a></p>
75 90
    {% when crate::repos::view::PolicyBadge::None %}<p><a href="{{ repo.url }}/trust"><span class="sig sig-unsigned">○</span> no policy</a></p>
76 91
    {% endmatch %}
77 -
    {% if let Some(l) = o.license %}<h3>Licence</h3><p><a href="{{ repo.url }}/file/{{ repo.default_bookmark }}/{{ l }}">{{ l }}</a></p>{% endif %}
92 +
    </div></section>
78 93
  </aside>
79 94
</div>
80 95
{% endblock %}
modified templates/style.css +60 −2
…
143 unchanged lines
144 144
145 145
/* repository layout */
146 146
.side-layout { display: grid; grid-template-columns: 1fr 280px; gap: 22px; }
147 +
.side-layout.rev { grid-template-columns: minmax(0, 1fr) 320px; }
147 148
/* A proposal's conversation: the page is wide for its diffs, but text and
148 149
   the sidebar stay together at a readable width, and the description,
149 150
   comments and forms fill their column. */
…
112 unchanged lines
262 263
263 264
@media (max-width: 760px) {
264 265
  header { flex-wrap: wrap; }
265 -
  .side-layout { grid-template-columns: 1fr; }
266 +
  .side-layout, .side-layout.rev { grid-template-columns: 1fr; }
266 267
  .foot { grid-template-columns: 1fr; }
267 268
  main { padding: 10px 14px 20px; }
268 269
  .band { padding: 0 8px; overflow-x: auto; }
…
736 unchanged lines
1005 1006
.df-off { color: var(--dim); border-color: var(--line); border-style: dashed; }
1006 1007
.df-key .df-chip { margin-right: 2px; }
1007 1008
table.diffusion td { vertical-align: middle; }
1009 +
/* Every upstream's table shares one column grid (#152), so the tables on
1010 +
   the Diffusion pages line up with each other. */
1011 +
table.diffusion { table-layout: fixed; width: 100%; min-width: 880px; }
1012 +
table.diffusion td { overflow-wrap: anywhere; }
1013 +
table.diffusion .df-c-repo { width: 26%; }
1014 +
table.diffusion .df-c-pin { width: 10%; }
1015 +
table.diffusion .df-c-behind { width: 7%; }
1016 +
table.diffusion .df-c-pill { width: 17%; }
1017 +
table.diffusion .df-c-bump { width: 16%; }
1018 +
table.diffusion .df-c-landed { width: 14%; }
1019 +
table.diffusion .df-c-follow { width: 10%; }
1020 +
table.diffusion .df-path { display: block; }
1008 1021
1009 1022
/* The Diffusion pill (#150): two halves like a capsule, the upstream on
1010 1023
   the left, the repository that pins it on the right. */
…
4 unchanged lines
1015 1028
.df-half.df-amber { background: #e3a008; color: #2b1d00; }
1016 1029
.df-half.df-red { background: #c62d47; color: #fff; }
1017 1030
.df-half.df-grey { background: var(--soft); color: var(--mute); }
1018 -
.df-half.df-off { background: transparent; color: var(--mute); }
1031 +
.df-half.df-off { background: transparent; color: var(--mute); border-style: solid; border-width: 0; }
1032 +
.df-half + .df-half.df-off { border-left: 1px solid var(--line); }
1033 +
1034 +
/* Icons (#152): inline SVGs in the colour of their text. */
1035 +
svg.ic { width: 16px; height: 16px; vertical-align: -3px; flex: none; }
1036 +
.band a { display: inline-flex; align-items: center; gap: 6px; }
1037 +
.band a svg.ic { opacity: .8; }
1038 +
table.tree td.g svg.ic { vertical-align: -3px; }
1039 +
table.tree td.g.ic-dir { color: #54aeff; }
1040 +
table.tree td.g.ic-file, table.tree td.g.ic-link { color: var(--mute); }
1041 +
table.tree td.g.ic-sub { color: var(--acc); }
1042 +
1043 +
/* The tree's shaded head (#152): the latest change, and the history. */
1044 +
.tree-box { margin-top: 4px; }
1045 +
.tree-head { display: flex; align-items: center; gap: 10px; background: var(--soft); border: 1px solid var(--line); border-bottom: 0; border-radius: 6px 6px 0 0; padding: 8px 16px; font-size: 13px; }
1046 +
.tree-head .tree-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1047 +
.tree-head .tree-title a.t { color: var(--ink); }
1048 +
.tree-head .tree-title a.who { font-weight: 600; color: var(--ink); }
1049 +
.tree-head img.avatar { width: 20px; height: 20px; border-radius: 50%; vertical-align: middle; }
1050 +
.tree-head .tree-meta { display: flex; align-items: center; gap: 12px; white-space: nowrap; color: var(--mute); }
1051 +
.tree-head .tree-meta a { color: var(--mute); }
1052 +
.tree-head .tree-meta a.hist { display: inline-flex; align-items: center; gap: 4px; color: var(--ink); font-weight: 600; }
1053 +
.tree-box .tree-head + table.tree { border-top-left-radius: 0; border-top-right-radius: 0; margin-top: 0; }
1054 +
@media (max-width: 760px) { .tree-head .tree-meta .ago { display: none; } }
1055 +
1056 +
/* The sidebar as cards (#152): each section a rounded box with a shaded
1057 +
   heading. */
1058 +
aside.side.cards { display: flex; flex-direction: column; gap: 14px; }
1059 +
.side-card { border: 1px solid var(--line); border-radius: 6px; background: var(--panel); overflow: hidden; }
1060 +
.side-card > h3 { margin: 0; padding: 8px 12px; background: var(--soft); border-bottom: 1px solid var(--line); display: flex; align-items: center; gap: 6px; }
1061 +
.side-card > h3 .more { margin-left: auto; font-size: 12px; font-weight: 400; text-transform: none; letter-spacing: 0; }
1062 +
.side-card > .body { padding: 10px 12px; }
1063 +
.side-card > .body > :first-child { margin-top: 0; }
1064 +
.side-card > .body > :last-child { margin-bottom: 0; }
1065 +
.side-card h4 { margin: 10px 0 4px; font-size: 12px; font-weight: 600; color: var(--mute); }
1066 +
.side-card h4:first-child { margin-top: 0; }
1067 +
ul.contributors li.c { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
1068 +
ul.contributors li.c > .who { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1069 +
ul.contributors li.c > .n { margin-left: auto; }
1070 +
ul.df-list { margin: 0; }
1071 +
ul.df-list li { padding: 6px 0; border-top: 1px solid var(--line); }
1072 +
ul.df-list li:first-child { border-top: 0; padding-top: 0; }
1073 +
ul.df-list .df-name { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1074 +
ul.df-list .df-line { display: flex; align-items: center; gap: 8px; margin-top: 3px; font-size: 12px; }
1075 +
ul.bookmarks li { display: flex; align-items: center; gap: 6px; }
1076 +
ul.bookmarks li > a { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
modified templates/tree_rows.html +2 −2
…
2 unchanged lines
3 3
{% else %}
4 4
<table class="tree" data-last="{{ repo.url }}/last/{{ t.target.label }}" data-change="{{ repo.url }}/change/">
5 5
{% if !t.path.is_empty() %}
6 -
<tr><td class="g">↰</td><td colspan="3"><a href="{{ repo.url }}/tree/{{ t.target.label }}{% if let Some((_, p)) = t.crumbs.iter().rev().nth(1) %}/{{ p }}{% endif %}">..</a></td></tr>
6 +
<tr><td class="g ic-file">{{ crate::web::icons::svg("up")|safe }}</td><td colspan="3"><a href="{{ repo.url }}/tree/{{ t.target.label }}{% if let Some((_, p)) = t.crumbs.iter().rev().nth(1) %}/{{ p }}{% endif %}">..</a></td></tr>
7 7
{% endif %}
8 8
{% for e in t.entries %}
9 9
<tr data-path="{{ e.path }}">
10 -
  <td class="g">{{ e.kind.glyph() }}</td>
10 +
  <td class="g {{ e.kind.class() }}">{{ e.kind.icon()|safe }}</td>
11 11
  <td class="name">
12 12
    {% if e.kind == crate::repos::view::EntryKind::Dir %}<a href="{{ repo.url }}/tree/{{ t.target.label }}/{{ e.path }}">{{ e.name }}</a>
13 13
    {% else %}<a href="{{ repo.url }}/file/{{ t.target.label }}/{{ e.path }}">{{ e.name }}</a>{% endif %}
…
9 unchanged lines