OXYGENHUB

feat(web): the dashboard in cards, like the repository page (closes #155) f8cf54e99879

Waiting for your review, Your proposals, Issues assigned to you and Recent
activity are each a rounded card with the heading and count in a shaded
bar and the list flush inside (card-box, the main column's counterpart of
side-card); Your repositories is a sidebar card with New in its heading.
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-13 17:05 UTC
Signature
✔ signed by Andy McCall
Hash
f8cf54e9987922e4f4ffa0c3dcbfa5118129edd5c3ac3d2a0f75ea51f36eb1fc
Depends on
1656dc14c2ac 18e381ea88c2 25ab8c73514a 749d9a5fcd9f b5668108487f c9aacc0e7dc1 d23464c0ac60 eb2a3502601d
Depended on by
8a48dd830178 7f283f8e8b97 6907a989e82a 33bd0bd45f8a 17ca6cddc534
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
22 atoms, 0 tree operations, 5 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 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.
9 10
- **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.
10 11
- **Admin › Runners › Images (#146):** every runner image, where it is and what uses it.
11 12
- **Faster pages after a restart (#145):** bookmark summaries are kept on disk; slow requests are logged.
…
163 unchanged lines
modified assets/github.css +2 −0
…
196 unchanged lines
197 197
:root[data-look="github"] .tree-box .tree-head + table.tree { border-top-left-radius: 0; border-top-right-radius: 0; }
198 198
:root[data-look="github"] .side-card > h3 { font-size: 14px; }
199 199
:root[data-look="github"] table.tree td.g.ic-dir { color: #54aeff; }
200 +
/* Lists inside a card (#155) take the card's border, not their own. */
201 +
:root[data-look="github"] .card-box > ul.rows, :root[data-look="github"] .card-box > ul.feed { border: 0; border-radius: 0; }
modified crates/hub/tests/issues.rs +4 −4
…
367 unchanged lines
368 368
    // Andy owns both repositories: both proposals wait for his review.
369 369
    let page = get(app, "/", Some(&andy)).await;
370 370
    assert_eq!(page.status, StatusCode::OK);
371 -
    assert!(page.body.contains("Waiting for your review <small>2</small>"), "{}", page.body);
371 +
    assert!(page.body.contains("Waiting for your review</h2><span class=\"count-chip\">2</span>"), "{}", page.body);
372 372
    assert!(page.body.contains("/andy/demo/proposal/1") && page.body.contains("/andy/secret/proposal/1"));
373 373
    assert!(page.body.contains("Your repositories"));
374 374
    assert!(page.body.contains("opened proposal"), "activity feed: {}", page.body);
…
2 unchanged lines
377 377
    let andy_id = db::accounts::account_by_name(&app.db, "andy").await.unwrap().unwrap().id;
378 378
    let p = db::proposals::get(&app.db, demo.id, 1).await.unwrap().unwrap();
379 379
    db::proposals::add_review(&app.db, p.id, andy_id, "andy", "approve", "").await.unwrap();
380 -
    assert!(get(app, "/", Some(&andy)).await.body.contains("Waiting for your review <small>1</small>"));
380 +
    assert!(get(app, "/", Some(&andy)).await.body.contains("Waiting for your review</h2><span class=\"count-chip\">1</span>"));
381 381
382 382
    // Bob sees his own proposal on the public repository, never the private one's.
383 383
    let page = get(app, "/", Some(&bob)).await;
384 -
    assert!(page.body.contains("Your proposals <small>1</small>"), "{}", page.body);
384 +
    assert!(page.body.contains("Your proposals</h2><span class=\"count-chip\">1</span>"), "{}", page.body);
385 385
    assert!(!page.body.contains("Hidden work"));
386 -
    assert!(page.body.contains("Waiting for your review <small>0</small>"));
386 +
    assert!(page.body.contains("Waiting for your review</h2><span class=\"count-chip\">0</span>"));
387 387
    assert!(page.body.contains("Faster startup"));
388 388
    let feed = &page.body[page.body.find("Recent activity").unwrap()..];
389 389
    assert!(!feed.contains("andy/secret"), "private activity leaked: {feed}");
…
520 unchanged lines
modified templates/dashboard.html +18 −9
…
3 unchanged lines
4 4
<div class="head">
5 5
  <h1>Dashboard <small>signed in as {% if let Some(u) = shell.user %}{{ u }}{% endif %}</small></h1>
6 6
</div>
7 -
<div class="side-layout dash">
7 +
<div class="side-layout rev dash">
8 8
  <div>
9 -
    <h2>Waiting for your review <small>{{ reviews.len() }}</small></h2>
9 +
    <section class="card-box">
10 +
    <div class="card-head"><h2>Waiting for your review</h2><span class="count-chip">{{ reviews.len() }}</span></div>
10 11
    {% if reviews.is_empty() %}
11 12
    <p class="empty small">Nothing waits on you. Proposals by others in your repositories land here until you review them.</p>
12 13
    {% else %}
…
10 unchanged lines
23 24
    {% endfor %}
24 25
    </ul>
25 26
    {% endif %}
27 +
    </section>
26 28
27 29
    {% if !authored.is_empty() %}
28 -
    <h2>Your proposals <small>{{ authored.len() }}</small></h2>
30 +
    <section class="card-box">
31 +
    <div class="card-head"><h2>Your proposals</h2><span class="count-chip">{{ authored.len() }}</span></div>
29 32
    <ul class="rows">
30 33
    {% for p in authored %}
31 34
      <li>
…
6 unchanged lines
38 41
      </li>
39 42
    {% endfor %}
40 43
    </ul>
44 +
    </section>
41 45
    {% endif %}
42 46
43 47
    {% if !assigned.is_empty() %}
44 -
    <h2>Issues assigned to you <small>{{ assigned.len() }}</small></h2>
48 +
    <section class="card-box">
49 +
    <div class="card-head"><h2>Issues assigned to you</h2><span class="count-chip">{{ assigned.len() }}</span></div>
45 50
    <ul class="rows">
46 51
    {% for i in assigned %}
47 52
      <li>
…
2 unchanged lines
50 55
      </li>
51 56
    {% endfor %}
52 57
    </ul>
58 +
    </section>
53 59
    {% endif %}
54 60
55 -
    <h2>Recent activity</h2>
61 +
    <section class="card-box">
62 +
    <div class="card-head"><h2>Recent activity</h2></div>
56 63
    {% if feed.is_empty() %}
57 64
    <p class="empty small">No activity yet. Push to a repository and it shows here.</p>
58 65
    {% else %}
…
11 unchanged lines
70 77
    {% endfor %}
71 78
    </ul>
72 79
    {% endif %}
80 +
    </section>
73 81
  </div>
74 -
  <aside class="side">
75 -
    <div class="bar"><h3>Your repositories</h3><a class="go small" href="/new">new</a></div>
82 +
  <aside class="side cards">
83 +
    <section class="side-card"><h3>Your repositories <span class="count-chip">{{ mine.len() }}</span><a class="more" href="/new">+ New</a></h3><div class="body">
76 84
    {% if mine.is_empty() %}
77 85
    <p class="small mute">None yet. <a href="/new">Create one</a>, or <a href="/explore">explore</a>.</p>
78 86
    {% else %}
79 -
    <ul class="plain">
87 +
    <ul class="plain repo-list">
80 88
    {% for r in mine %}
81 -
      <li><a href="/{{ r.owner }}/{{ r.name }}"><span class="mute">{{ r.owner }} /</span> {{ r.name }}</a>{% if r.visibility != "public" %} <span class="tag">{{ r.visibility }}</span>{% endif %}</li>
89 +
      <li><a href="/{{ r.owner }}/{{ r.name }}" title="{{ r.owner }}/{{ r.name }}"><span class="mute">{{ r.owner }} /</span> {{ r.name }}</a>{% if r.visibility != "public" %}<span class="tag">{{ r.visibility }}</span>{% endif %}</li>
82 90
    {% endfor %}
83 91
    </ul>
84 92
    {% endif %}
93 +
    </div></section>
85 94
  </aside>
86 95
</div>
87 96
{% endblock %}
modified templates/style.css +14 −0
…
1090 unchanged lines
1091 1091
ul.df-list .df-line { display: flex; align-items: center; gap: 8px; margin-top: 3px; font-size: 12px; }
1092 1092
ul.bookmarks li { display: flex; align-items: center; gap: 6px; }
1093 1093
ul.bookmarks li > a { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1094 +
1095 +
/* A main-column section as a card (#155), the counterpart of the
1096 +
   sidebar's side-card: a rounded box, its heading and count in a shaded
1097 +
   bar, its list flush inside. */
1098 +
.card-box { border: 1px solid var(--line); border-radius: 6px; background: var(--panel); overflow: hidden; margin-bottom: 16px; }
1099 +
.card-box > .card-head { display: flex; align-items: center; gap: 8px; padding: 8px 16px; background: var(--soft); border-bottom: 1px solid var(--line); }
1100 +
.card-box > .card-head h2, .card-box > .card-head h3 { margin: 0; font-size: 14px; font-weight: 600; }
1101 +
.card-box > .card-head .more { margin-left: auto; font-size: 12px; }
1102 +
.card-box > ul.rows, .card-box > ul.feed { border: 0; border-radius: 0; margin: 0; }
1103 +
.card-box > ul.rows li { margin: 0; border: 0; border-top: 1px solid var(--line); }
1104 +
.card-box > ul.rows li:first-child, .card-box > ul.feed li:first-child { border-top: 0; }
1105 +
.card-box > .empty { margin: 0; padding: 12px 16px; }
1106 +
ul.repo-list li { display: flex; align-items: center; gap: 6px; margin: 3px 0; }
1107 +
ul.repo-list li > a { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }