OXYGENHUB

feat(web): the card layout on every page (closes #159, closes #160, closes #161, closes #162, closes #163, closes #164) 61ecbd299f22

Profile and organisation pages put Repositories and Recent activity in
cards and their sidebar lists (organisations, signing keys, members,
teams) in side cards; search groups its results in cards; the wiki's
sidebar sections are cards. Every panel's and form card's leading heading
is a shaded bar across the box's top, which brings settings, admin, help,
about, trust and the rest in line. The repository sidebar is 380 px, so a
Diffusion row's longest names fit beside their pill. The footer's Explore
link loses the site bar's icon it picked up by mistake.
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-13 19:43 UTC
Signature
✔ signed by Andy McCall
Hash
61ecbd299f22db4f8ed79fb1e3cc558994a8ac49e7f2315d576e0e1cf1fab587
Depends on
101740454617 18e381ea88c2 33bd0bd45f8a 66fd9d6b1452 749d9a5fcd9f 7f283f8e8b97 b5668108487f c9aacc0e7dc1 d23464c0ac60 e1ec9fbf538e eb2a3502601d
Depended on by
84e3c09656b2 16ee5d19d220 2a0c09c09716 ffb67a81188c d7d182467382 6246d1e51f4f 26d0d091deb7 0a3b244b748f
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
29 atoms, 0 tree operations, 9 files

Browse the tree at this change

modified CHANGELOG.md +1 −0
…
5 unchanged lines
6 6
7 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 8
- **Diffusion in the API (#153):** `GET /repos/{owner}/{name}/diffusion` gives what a repository takes from and what it diffuses to, each with its pill, for the VS Code extension.
9 +
- **The card layout everywhere (#159–#164):** the About card's counts in its heading and its buttons centred; wider repository cards with each Diffusion row on one line; the Code button and + New project in the accent blue; proposal and issue sidebars, profile and organisation pages, search results and the wiki's sidebar as cards; every panel's and form card's heading a shaded bar.
9 10
- **Your sky (#157, docs/backgrounds.md):** a quiet, moving backdrop behind the dashboard, drawn in Rust compiled to WebAssembly: your repositories as clusters and their last 30 days (every change pushed, proposal and issue events, releases, Flow runs) as marks around them; the cursor lights and links them, and names the cluster it rests by. Settings › Appearance: Moving, Still or Off; still under reduced motion.
10 11
- **Signed-in pages are private (#158):** HTML for a signed-in viewer says `Cache-Control: private, no-store`, so no proxy or CDN serves one viewer's page to another.
11 12
- **The dashboard in cards (#155):** each section a rounded card with its heading and count in a shaded bar, your repositories a sidebar card with New in its heading.
…
166 unchanged lines
modified assets/github.css +2 −0
…
204 unchanged lines
205 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 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 207
:root[data-look="github"] .clone-pop[open] > summary.go.primary { background: var(--gh-primary-hover); }
208 +
:root[data-look="github"] :is(.panel, form.card) > h2:first-child,
209 +
:root[data-look="github"] :is(.panel, form.card) > input[type=hidden]:first-child + h2 { border-radius: 6px 6px 0 0; font-size: 16px; }
modified crates/hub/tests/issues.rs +1 −1
…
552 unchanged lines
553 553
554 554
    let page = get(app, "/retro", Some(&bob)).await;
555 555
    assert_eq!(page.status, StatusCode::OK);
556 -
    for want in ["Retro Club", "organisation", "Members <small>2</small>", "core", "1 member"] {
556 +
    for want in ["Retro Club", "organisation", r#"Members <span class="count-chip">2</span>"#, "core", "1 member"] {
557 557
        assert!(page.body.contains(want), "org page lacks {want}: {}", page.body);
558 558
    }
559 559
    assert!(!page.body.contains("/orgs/retro/settings"), "bob is not an owner");
…
406 unchanged lines
modified templates/base.html +1 −1
…
47 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">{{ 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>
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>
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/org.html +8 −4
…
7 unchanged lines
8 8
    <p class="handle">{{ org.name }} <span class="tag">organisation</span></p>
9 9
    {% if !org.description.is_empty() %}<p class="bio">{{ org.description }}</p>{% endif %}
10 10
    {% if can_admin %}<a class="go small" href="/orgs/{{ org.name }}/settings">settings</a>{% endif %}
11 -
    <h3>Members <small>{{ members.len() }}</small></h3>
11 +
    <section class="side-card"><h3>Members <span class="count-chip">{{ members.len() }}</span></h3><div class="body">
12 12
    <ul class="plain">{% for (m, role) in members %}<li><img class="avatar xs" src="/avatar/{{ m.username }}" alt=""> <a href="/{{ m.username }}">{{ m.username }}</a>{% if role == "owner" %} <span class="small mute">owner</span>{% endif %}</li>{% endfor %}</ul>
13 +
    </div></section>
13 14
    {% if !teams.is_empty() %}
14 15
    <p><a class="go small" href="/projects/{{ org.name }}">Projects</a></p>
15 -
    <h3>Teams</h3>
16 +
    <section class="side-card"><h3>Teams <span class="count-chip">{{ teams.len() }}</span></h3><div class="body">
16 17
    <ul class="plain">{% for (t, n) in teams %}<li>{{ t.name }} <span class="small mute">{{ n|plural("member", "members") }}</span></li>{% endfor %}</ul>
18 +
    </div></section>
17 19
    {% endif %}
18 20
  </aside>
19 21
  <div>
20 -
    <h2>Repositories <small>{{ repos.len() }}</small></h2>
22 +
    <section class="card-box"><div class="card-head"><h2>Repositories</h2><span class="count-chip">{{ repos.len() }}</span></div>
21 23
    {% include "repo_list.html" %}
22 -
    <h2>Recent activity</h2>
24 +
    </section>
25 +
    <section class="card-box"><div class="card-head"><h2>Recent activity</h2></div>
23 26
    {% include "feed.html" %}
27 +
    </section>
24 28
  </div>
25 29
</div>
26 30
{% endblock %}
modified templates/profile.html +8 −4
…
17 unchanged lines
18 18
    {% if is_me %}<a class="go small" href="/settings">edit profile</a>{% endif %}
19 19
    {% if !orgs.is_empty() %}
20 20
    <p><a class="go small" href="/projects/{{ account.username }}">Projects</a></p>
21 -
    <h3>Organisations</h3>
21 +
    <section class="side-card"><h3>Organisations <span class="count-chip">{{ orgs.len() }}</span></h3><div class="body">
22 22
    <ul class="plain">{% for (o, role) in orgs %}<li><img class="avatar xs" src="/avatar/{{ o.name }}" alt=""> <a href="/{{ o.name }}">{{ o.name }}</a>{% if role == "owner" %} <span class="small mute">owner</span>{% endif %}</li>{% endfor %}</ul>
23 +
    </div></section>
23 24
    {% endif %}
24 -
    <h3>Signing keys</h3>
25 +
    <section class="side-card"><h3>Signing keys</h3><div class="body">
25 26
    {% if keys.is_empty() %}<p class="small mute">None linked.</p>
26 27
    {% else %}<ul class="plain keys small">{% for k in keys %}<li><code title="{{ k.key }}">{{ k.key[..16] }}…</code><br><span class="mute">{{ k.name }}</span></li>{% endfor %}</ul>{% endif %}
28 +
    </div></section>
27 29
  </aside>
28 30
  <div>
29 -
    <h2>Repositories <small>{{ repos.len() }}</small></h2>
31 +
    <section class="card-box"><div class="card-head"><h2>Repositories</h2><span class="count-chip">{{ repos.len() }}</span></div>
30 32
    {% include "repo_list.html" %}
31 -
    <h2>Recent activity</h2>
33 +
    </section>
34 +
    <section class="card-box"><div class="card-head"><h2>Recent activity</h2></div>
32 35
    {% include "feed.html" %}
36 +
    </section>
33 37
  </div>
34 38
</div>
35 39
{% endblock %}
modified templates/search.html +3 −3
…
2 unchanged lines
3 3
{% block content %}
4 4
<div class="head"><h1>Search <small>{% if q.is_empty() %}repositories, issues, proposals, accounts{% else %}{{ hits.len() + repos.len() + accounts.len() }} results for “{{ q }}”{% endif %}</small></h1></div>
5 5
<form class="card wide" method="get" action="/search"><div class="row"><div><input type="search" name="q" value="{{ q }}" autofocus placeholder="words, or label:bug in a repository's issue list"></div><div><button type="submit">SEARCH</button></div></div></form>
6 -
{% if !repos.is_empty() %}<h2>Repositories</h2><ul class="rows">{% for r in repos %}<li><div><a class="name" href="/{{ r.owner }}/{{ r.name }}"><span class="mute">{{ r.owner }} /</span> <strong>{{ r.name }}</strong></a><p class="small">{{ r.description }}</p></div></li>{% endfor %}</ul>{% endif %}
7 -
{% if !accounts.is_empty() %}<h2>Accounts</h2><ul class="plain">{% for a in accounts %}<li><a href="/{{ a }}">{{ a }}</a></li>{% endfor %}</ul>{% endif %}
8 -
{% if !hits.is_empty() %}<h2>Issues and proposals</h2><ul class="rows">{% for h in hits %}<li><div><a class="name" href="{{ h.url }}"><span class="mute">{{ h.kind }} {{ h.slug }}#{{ h.number }}</span> <strong>{{ h.title }}</strong></a><p class="small">{{ h.snippet|safe }}</p></div></li>{% endfor %}</ul>{% endif %}
6 +
{% if !repos.is_empty() %}<section class="card-box"><div class="card-head"><h2>Repositories</h2><span class="count-chip">{{ repos.len() }}</span></div><ul class="rows">{% for r in repos %}<li><div><a class="name" href="/{{ r.owner }}/{{ r.name }}"><span class="mute">{{ r.owner }} /</span> <strong>{{ r.name }}</strong></a><p class="small">{{ r.description }}</p></div></li>{% endfor %}</ul></section>{% endif %}
7 +
{% if !accounts.is_empty() %}<section class="card-box"><div class="card-head"><h2>Accounts</h2><span class="count-chip">{{ accounts.len() }}</span></div><ul class="plain card-list">{% for a in accounts %}<li><a href="/{{ a }}">{{ a }}</a></li>{% endfor %}</ul></section>{% endif %}
8 +
{% if !hits.is_empty() %}<section class="card-box"><div class="card-head"><h2>Issues and proposals</h2><span class="count-chip">{{ hits.len() }}</span></div><ul class="rows">{% for h in hits %}<li><div><a class="name" href="{{ h.url }}"><span class="mute">{{ h.kind }} {{ h.slug }}#{{ h.number }}</span> <strong>{{ h.title }}</strong></a><p class="small">{{ h.snippet|safe }}</p></div></li>{% endfor %}</ul></section>{% endif %}
9 9
{% endblock %}
modified templates/style.css +20 −1
…
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) 360px; }
150 +
.side-layout.rev { grid-template-columns: minmax(0, 1fr) 380px; }
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. */
…
988 unchanged lines
1142 1142
ul.df-list .df-row .df-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1143 1143
ul.df-list .df-row .df-pill { flex: none; }
1144 1144
ul.df-list .df-sub { display: flex; gap: 10px; margin-top: 2px; font-size: 12px; color: var(--mute); }
1145 +
1146 +
/* Every boxed section's heading as a shaded bar (#164): a panel's or a
1147 +
   form card's leading heading (after its hidden CSRF field, in a form)
1148 +
   runs across the box's top, as the cards' headings do. */
1149 +
.panel { --box-py: 14px; --box-px: 16px; }
1150 +
form.card { --box-py: 18px; --box-px: 20px; }
1151 +
:is(.panel, form.card) > h2:first-child,
1152 +
:is(.panel, form.card) > input[type=hidden]:first-child + h2 {
1153 +
  margin: calc(-1 * var(--box-py)) calc(-1 * var(--box-px)) 14px;
1154 +
  padding: 10px var(--box-px);
1155 +
  background: var(--soft);
1156 +
  border-bottom: 1px solid var(--line);
1157 +
  font-size: 15px;
1158 +
}
1159 +
/* A sidebar section with no heading (a wiki's own sidebar) keeps its
1160 +
   padding at the top. */
1161 +
.side-sections .side-sec:not(:has(> .side-head)) { padding-top: 10px; }
1162 +
.card-box > ul.card-list { margin: 0; padding: 10px 16px; }
1163 +
.who-card .side-card { margin-top: 14px; }
modified templates/wiki.html +1 −1
…
24 unchanged lines
25 25
  </section>
26 26
  {% endif %}
27 27
</div>
28 -
<aside class="side">
28 +
<aside class="side side-sections">
29 29
  {% if let Some(s) = sidebar %}<section class="side-sec"><div class="doc wiki-side">{{ s|safe }}</div></section>{% endif %}
30 30
  <section class="side-sec"><div class="side-head"><h3>Clone this wiki</h3></div><pre class="snippet small">oxy clone {{ shell.base_url }}/{{ repo.owner }}/{{ repo.name }}.wiki</pre></section>
31 31
</aside>
…
2 unchanged lines