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"><></span> Code</summary> |
|
| 17 | + | <summary class="go small primary" aria-haspopup="true"><span aria-hidden="true"><></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); } |