OXYGENHUB

feat(web): About counts in its header, wider cards, one-line Diffusion rows, blue primaries, card sidebars (refs #159, #160, #161, #162, #163) 7f283f8e8b97

The About card's heading carries the star, watcher and fork counts with
icons; Star, Watch and Fork sit centred below a rule, clear of the topics.
The repository sidebar is 360 px wide, and the Diffusion card's rows put
the repository and its pill on one line, how far behind and the bump
beneath, a rule between rows. The Code button and + New project are the
page's primary action in the accent blue. A proposal's and an issue's
sidebar sections are cards with a shaded heading, as the repository
page's.
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-13 19:04 UTC
Signature
✔ signed by Andy McCall
Hash
7f283f8e8b97707412b1fa72430695d29c12832ae86aee49f1e3cf0027b6c879
Depends on
07b1a5bbf481 1553bfc9ae53 17ca6cddc534 18e381ea88c2 67b24adbf35a 749d9a5fcd9f 7b26e6118bc0 83d5052bf76c 9198231cee96 c9aacc0e7dc1 cb71e18eb6f9 d23464c0ac60 e1ec9fbf538e f8cf54e99879
Depended on by
166f6e9bf4f1 a10446c68043 ffb67a81188c 26d0d091deb7 61ecbd299f22
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
19 atoms, 0 tree operations, 6 files

Browse the tree at this change

modified assets/github.css +4 −0
…
200 unchanged lines
201 201
:root[data-look="github"] table.tree td.g.ic-dir { color: #54aeff; }
202 202
/* Lists inside a card (#155) take the card's border, not their own. */
203 203
:root[data-look="github"] .card-box > ul.rows, :root[data-look="github"] .card-box > ul.feed { border: 0; border-radius: 0; }
204 +
/* The page's primary action (#161), as the GitHub look's primary buttons. */
205 +
:root[data-look="github"] a.go.primary, :root[data-look="github"] summary.go.primary { background: var(--gh-primary); color: #ffffff; border-color: rgba(31,35,40,.15); }
206 +
:root[data-look="github"] a.go.primary:hover, :root[data-look="github"] summary.go.primary:hover { background: var(--gh-primary-hover); color: #ffffff; filter: none; }
207 +
:root[data-look="github"] .clone-pop[open] > summary.go.primary { background: var(--gh-primary-hover); }
modified crates/hub/src/web/icons.rs +13 −2
…
37 unchanged lines
38 38
        ),
39 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 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 +
        "star" => concat!(open!(), r#"<path d="M8 1.9l1.85 3.75 4.15.6-3 2.92.7 4.13L8 11.35 4.3 13.3l.7-4.13-3-2.92 4.15-.6z"/></svg>"#),
42 +
        "eye" => concat!(
43 +
            open!(),
44 +
            r#"<path d="M1.5 8S4 3.5 8 3.5 14.5 8 14.5 8 12 12.5 8 12.5 1.5 8 1.5 8z"/><circle cx="8" cy="8" r="2"/></svg>"#
45 +
        ),
46 +
        "fork" => concat!(
47 +
            open!(),
48 +
            r#"<circle cx="4.5" cy="3.5" r="1.5"/><circle cx="11.5" cy="3.5" r="1.5"/><circle cx="8" cy="12.5" r="1.5"/><path d="M4.5 5v1.5c0 1 .8 1.5 1.8 1.5h3.4c1 0 1.8-.5 1.8-1.5V5M8 8v3"/></svg>"#
49 +
        ),
41 50
        "history" => concat!(open!(), r#"<circle cx="8" cy="8" r="6"/><path d="M8 4.5V8l2.5 1.5"/></svg>"#),
42 51
        _ => "",
43 52
    }
…
3 unchanged lines
47 56
mod tests {
48 57
    #[test]
49 58
    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 -
        {
59 +
        for name in [
60 +
            "home", "explore", "help", "projects", "bell", "settings", "admin", "dir", "file", "link", "subrepo", "up", "history", "star",
61 +
            "eye", "fork",
62 +
        ] {
52 63
            let s = super::svg(name);
53 64
            assert!(s.starts_with("<svg class=\"ic\"") && s.ends_with("</svg>"), "{name}");
54 65
            assert_eq!(s.matches("<svg").count(), 1, "{name}");
…
4 unchanged lines
modified crates/hub/tests/issues.rs +2 −2
…
690 unchanged lines
691 691
    let csrf = csrf_of(&get(app, "/andy/alpha", Some(&andy)).await.body);
692 692
    post(app, "/andy/alpha/star", Some(&andy), &format!("_csrf={csrf}")).await;
693 693
    assert!(get(app, "/andy/gamma", Some(&bob)).await.body.contains("★ UNSTAR"));
694 -
    assert!(get(app, "/andy/gamma", None).await.body.contains("★ 2"));
695 -
    assert!(get(app, "/andy/alpha", None).await.body.contains("★ 0"), "unstarred again");
694 +
    assert!(get(app, "/andy/gamma", None).await.body.contains(r#"title="2 stars">"#));
695 +
    assert!(get(app, "/andy/alpha", None).await.body.contains(r#"title="0 stars">"#), "unstarred again");
696 696
697 697
    // Explore: sort by stars, by name, filter by text and by topic.
698 698
    let order = |b: &str| {
…
254 unchanged lines
modified templates/overview.html +17 −15
…
13 unchanged lines
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">
17 -
        <summary class="go small" aria-haspopup="true"><span aria-hidden="true">&lt;&gt;</span> Code</summary>
17 +
        <summary class="go small primary" aria-haspopup="true"><span aria-hidden="true">&lt;&gt;</span> Code</summary>
18 18
        <div class="clone-panel" role="group" aria-label="Clone or download this repository">
19 19
          <label for="clone-cmd" class="small mute">Clone with oxy</label>
20 20
          <div class="clone"><input id="clone-cmd" type="text" readonly value="oxy clone {{ repo.clone_url }}" onfocus="this.select()" onclick="this.select()"><button type="button" class="small" data-copy="oxy clone {{ repo.clone_url }}">copy</button></div>
…
21 unchanged lines
42 42
    {% endif %}
43 43
  </div>
44 44
  <aside class="side cards">
45 -
    <section class="side-card"><h3>About</h3><div class="body">
45 +
    <section class="side-card about"><h3>About <span class="more about-counts"><span title="{{ stars|plural("star", "stars") }}">{{ crate::web::icons::svg("star")|safe }} {{ stars }}</span><span title="{{ watchers|plural("watcher", "watchers") }}">{{ crate::web::icons::svg("eye")|safe }} {{ watchers }}</span>{% if fork.forks > 0 %}<a href="{{ repo.url }}/forks" title="{{ fork.forks|plural("fork", "forks") }}">{{ crate::web::icons::svg("fork")|safe }} {{ fork.forks }}</a>{% endif %}</span></h3><div class="body">
46 46
    {% if repo.description.is_empty() %}<p class="mute small">No description.</p>{% else %}<p>{{ repo.description }}</p>{% endif %}
47 47
    {% if let Some(up) = fork.upstream %}<p class="small">forked from <a href="/{{ up.owner }}/{{ up.name }}">{{ up.owner }}/{{ up.name }}</a></p>{% endif %}
48 48
    {% if !topics.is_empty() %}<p class="topics">{% for t in topics %}<a class="topic" href="/explore?topic={{ t }}">{{ t }}</a>{% endfor %}</p>{% endif %}
49 -
    <div class="bar">
50 -
    <p class="small mute counts-line"><span title="stars">★ {{ stars }}</span> · <span title="watchers">👁 {{ watchers }}</span></p>
51 -
    {% 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>
52 -
    <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>
53 -
    <a class="go small" href="{{ repo.url }}/fork">fork</a>{% endif %}
54 -
    {% if fork.forks > 0 %}<a class="small" href="{{ repo.url }}/forks">{{ fork.forks|plural("fork", "forks") }}</a>{% endif %}
55 -
    </div>
56 -
    {% 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 %}
57 49
    {% if let Some(l) = o.license %}<p class="small">Licence: <a href="{{ repo.url }}/file/{{ repo.default_bookmark }}/{{ l }}">{{ l }}</a></p>{% endif %}
50 +
    {% if shell.user.is_some() %}<div class="about-actions">
51 +
      <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>
52 +
      <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>
53 +
      <a class="go small" href="{{ repo.url }}/fork">fork</a>
54 +
    </div>{% endif %}
55 +
    {% if let Some(up) = fork.upstream %}{% if shell.user.is_some() %}<p class="about-propose"><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></p>{% endif %}{% endif %}
58 56
    </div></section>
59 57
    {% 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 %}
60 58
    {% if !follows.is_empty() || !used_by.is_empty() %}
61 59
    <section class="side-card"><h3>Diffusion <a class="more" href="{{ repo.url }}/diffusion">details ›</a></h3><div class="body">
62 -
    {% if !follows.is_empty() %}<h4>Takes from</h4>
63 -
    <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>
64 -
      <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 %}
60 +
    {% if !follows.is_empty() %}<h4>Takes from <span class="count-chip">{{ follows.len() }}</span></h4>
61 +
    <ul class="plain df-list">{% for (up, c) in follows %}<li>
62 +
      <div class="df-row"><a class="df-name" href="/{{ up.owner }}/{{ up.name }}" title="{{ up.owner }}/{{ up.name }}, pinned at {{ c.path }}">{{ up.owner }}/{{ up.name }}</a><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></div>
63 +
      {% if c.behind.unwrap_or(0) > 0 || c.bump.is_some() %}<div class="df-sub">{% match c.behind %}{% when Some(0) %}{% when Some(n) %}<span>{{ n }} behind</span>{% when None %}{% endmatch %}{% if let Some(n) = c.bump %}<a href="{{ repo.url }}/proposal/{{ n }}">bump #{{ n }}</a>{% endif %}</div>{% endif %}
64 +
    </li>{% endfor %}</ul>{% endif %}
65 65
    {% if !used_by.is_empty() %}<h4>Diffuses to <span class="count-chip">{{ used_by.len() }}</span></h4>
66 -
    <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>
67 -
      <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 %}
66 +
    <ul class="plain df-list">{% for c in used_by %}<li>
67 +
      <div class="df-row"><a class="df-name" href="/{{ c.owner }}/{{ c.name }}" title="{{ c.owner }}/{{ c.name }}">{{ c.owner }}/{{ c.name }}</a><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></div>
68 +
      {% match c.behind %}{% when Some(0) %}{% when Some(n) %}<div class="df-sub"><span>{{ n }} behind</span></div>{% when None %}{% endmatch %}
69 +
    </li>{% endfor %}</ul>{% endif %}
68 70
    </div></section>
69 71
    {% endif %}
70 72
    {% if !o.contributors.is_empty() %}
…
29 unchanged lines
modified templates/projects.html +1 −1
…
11 unchanged lines
12 12
    <input type="search" id="q" name="q" value="{{ q }}" placeholder="Filter projects by title or description" aria-label="Filter projects">
13 13
    {% if closed %}<input type="hidden" name="state" value="closed">{% endif %}
14 14
  </form>
15 -
  {% if can_edit %}<a class="go" href="#new">+ NEW PROJECT</a>{% endif %}
15 +
  {% if can_edit %}<a class="go primary" href="#new">+ NEW PROJECT</a>{% endif %}
16 16
</div>
17 17
{% if can_edit %}
18 18
<form class="card proj-new{% if error.is_some() %} open{% endif %}" id="new" method="post" action="/projects/{{ owner }}">
…
32 unchanged lines
modified templates/style.css +29 −5
…
146 unchanged lines
147 147
148 148
/* repository layout */
149 149
.side-layout { display: grid; grid-template-columns: 1fr 280px; gap: 22px; }
150 -
.side-layout.rev { grid-template-columns: minmax(0, 1fr) 320px; }
150 +
.side-layout.rev { grid-template-columns: minmax(0, 1fr) 360px; }
151 151
/* A proposal's conversation: the page is wide for its diffs, but text and
152 152
   the sidebar stay together at a readable width, and the description,
153 153
   comments and forms fill their column. */
…
557 unchanged lines
711 711
   with a heading and a gear opening a filterable menu that applies in one
712 712
   go; what is set is shown under the heading; moderation as quiet actions. */
713 713
form.card.comment-form { max-width: none; }
714 -
.side-sections .side-sec { padding: 14px 0; border-bottom: 1px solid var(--line); }
715 -
.side-sections .side-sec:first-child { padding-top: 0; }
716 -
.side-sections .side-sec:last-child { border-bottom: 0; }
714 +
/* Proposal and issue sidebars (#162, #163): each section a card, its
715 +
   heading a shaded bar across the top, as the repository page's cards.
716 +
   Overflow stays visible so the pickers' menus can open past the card. */
717 +
.side-sections { display: flex; flex-direction: column; gap: 14px; }
718 +
.side-sections .side-sec { border: 1px solid var(--line); border-radius: 6px; background: var(--panel); padding: 0 12px 10px; }
717 719
.side-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 8px; }
718 -
.side-sections .side-head h3 { margin: 0; font-size: 12px; font-weight: 600; color: var(--mute); text-transform: none; letter-spacing: 0; }
720 +
.side-sections .side-sec > .side-head { margin: 0 -12px 10px; padding: 8px 12px; background: var(--soft); border-bottom: 1px solid var(--line); border-radius: 6px 6px 0 0; }
721 +
.side-sections .side-head h3 { margin: 0; }
722 +
.side-sections .side-sec.side-actions { padding-top: 6px; }
719 723
.side-empty, .side-note { margin: 0; font-size: 12px; color: var(--mute); }
720 724
.side-empty button.linkish { font-size: inherit; }
721 725
.side-note { margin-top: 6px; }
…
396 unchanged lines
1118 1122
html:has(#backdrop) body { background: transparent; }
1119 1123
.bd-label { position: fixed; z-index: -1; pointer-events: none; font-size: 12px; color: var(--mute); opacity: 0; transition: opacity .3s; white-space: nowrap; }
1120 1124
@media (prefers-reduced-motion: reduce) { .bd-label { transition: none; } }
1125 +
1126 +
/* The page's primary action (#161): filled with the accent. */
1127 +
a.go.primary, summary.go.primary { background: var(--acc); color: var(--panel); border-color: var(--acc); }
1128 +
a.go.primary:hover, summary.go.primary:hover { filter: brightness(1.1); text-decoration: none; }
1129 +
1130 +
/* The About card (#159): counts in its heading, the actions centred. */
1131 +
.side-card.about > h3 .about-counts { display: inline-flex; align-items: center; gap: 12px; color: var(--mute); font-size: 12px; font-weight: 400; text-transform: none; letter-spacing: 0; }
1132 +
.side-card.about > h3 .about-counts > * { display: inline-flex; align-items: center; gap: 4px; color: var(--mute); }
1133 +
.side-card.about > h3 .about-counts svg.ic { width: 14px; height: 14px; vertical-align: 0; }
1134 +
.side-card.about .topics { margin: 10px 0; }
1135 +
.about-actions { display: flex; justify-content: center; align-items: center; gap: 10px; margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--line); }
1136 +
.about-actions form { margin: 0; }
1137 +
.about-propose { text-align: center; margin: 10px 0 0; }
1138 +
1139 +
/* Diffusion rows (#160): the repository and its pill on one line, how far
1140 +
   behind beneath, a rule between rows. */
1141 +
ul.df-list .df-row { display: flex; align-items: center; gap: 8px; }
1142 +
ul.df-list .df-row .df-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1143 +
ul.df-list .df-row .df-pill { flex: none; }
1144 +
ul.df-list .df-sub { display: flex; gap: 10px; margin-top: 2px; font-size: 12px; color: var(--mute); }