fabrica

hanna/fabrica

fix(web): avatar collaborator rows and tighter card top padding

52fd809 · hanna committed on 2026-07-26

Redesign collaborator rows as [avatar] [name] [role] with the remove button on
the right, sharing one `collaborators_card` helper between the repo and group
settings pages. Drop the doubled top padding on the first collaborator row and
on the first label of a card form so cards no longer open with an oversized gap.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
24 files changed · +77 −79UnifiedSplit
assets/base.css +31 −1
@@ -742,7 +742,8 @@ body.drawer-open .drawer-backdrop {
742742 margin-bottom: 0;
743743 }
744744
745-/* Collaborators: a bordered list inside the card, then an add row. */
745+/* Collaborators: a bordered list inside the card, then an add row. Each row is
746+ [avatar] [name] [role] on the left, remove button on the right. */
746747 .collab-list {
747748 margin-bottom: 1rem;
748749 }
@@ -750,6 +751,28 @@ body.drawer-open .drawer-backdrop {
750751 padding-left: 0;
751752 padding-right: 0;
752753 }
754+/* The first row abuts the card padding; drop its own top padding so the list
755+ doesn't sit with a doubled gap at the top of the card. */
756+.collab-list .entry-row:first-child {
757+ padding-top: 0;
758+}
759+.collab-ident {
760+ display: flex;
761+ align-items: center;
762+ gap: 0.6rem;
763+ min-width: 0;
764+}
765+.collab-ident .entry-row-title {
766+ display: inline;
767+}
768+.collab-perm {
769+ font-size: 0.8em;
770+ color: var(--fb-fg-muted);
771+ border: 1px solid var(--fb-border);
772+ border-radius: 999px;
773+ padding: 0.05rem 0.55rem;
774+ text-transform: capitalize;
775+}
753776 .collab-list .entry-row-actions .btn {
754777 padding: 0.25rem 0.6rem;
755778 font-size: 0.85em;
@@ -1192,6 +1215,13 @@ label {
11921215 margin: 0.75rem 0 0.25rem;
11931216 font-weight: 600;
11941217 }
1218+/* The first label in a card form abuts the card padding — drop its top margin so
1219+ the form doesn't sit with a doubled gap at the top. The CSRF hidden input is
1220+ usually the real first child, so also match the label right after it. */
1221+.card form > label:first-child,
1222+.card form > input[type="hidden"]:first-child + label {
1223+ margin-top: 0;
1224+}
11951225 input[type="text"],
11961226 input[type="password"],
11971227 input[type="email"],
crates/web/src/groups.rs +5 −49
@@ -287,7 +287,11 @@ pub async fn settings_view(
287287 }
288288 }
289289 }
290- (group_collaborators_section(gid, &chrome.csrf, &collaborators))
290+ section class="listing" {
291+ h2 { "Collaborators" }
292+ p class="muted field-hint" { "Group collaborators gain the same access to every repository within this group." }
293+ (crate::repo::collaborators_card(&format!("/group-settings/{gid}"), &chrome.csrf, &collaborators))
294+ }
291295 section class="listing" {
292296 h2 { "Danger zone" }
293297 div class="card danger-zone" {
@@ -312,54 +316,6 @@ pub async fn settings_view(
312316 result.unwrap_or_else(IntoResponse::into_response)
313317 }
314318
315-/// The group collaborators section: current members with remove buttons, add form.
316-fn group_collaborators_section(
317- id: &str,
318- csrf: &str,
319- collaborators: &[store::Collaborator],
320-) -> Markup {
321- let perm_option = |value: &str, label: &str| html! { option value=(value) { (label) } };
322- html! {
323- section class="listing" {
324- h2 { "Collaborators" }
325- p class="muted field-hint" { "Group collaborators gain the same access to every repository within this group." }
326- div class="card" {
327- @if collaborators.is_empty() {
328- p class="muted" { "No collaborators yet." }
329- } @else {
330- ul class="entry-list collab-list" {
331- @for c in collaborators {
332- li class="entry-row" {
333- div class="entry-row-body" {
334- a class="entry-row-title" href={ "/" (c.username) } { (c.username) }
335- div class="entry-row-meta muted" { (c.permission) }
336- }
337- div class="entry-row-actions" {
338- form method="post" action=(format!("/group-settings/{id}/collaborators/remove")) {
339- input type="hidden" name="_csrf" value=(csrf);
340- input type="hidden" name="user_id" value=(c.user_id);
341- button class="btn btn-danger" type="submit" { "Remove" }
342- }
343- }
344- }
345- }
346- }
347- }
348- form class="collab-add" method="post" action=(format!("/group-settings/{id}/collaborators")) {
349- input type="hidden" name="_csrf" value=(csrf);
350- input type="text" name="username" placeholder="Username" aria-label="Username" required;
351- select name="permission" aria-label="Permission" {
352- (perm_option("read", "Read"))
353- (perm_option("write", "Write"))
354- (perm_option("admin", "Admin"))
355- }
356- button class="btn btn-primary inline-btn" type="submit" { "Add" }
357- }
358- }
359- }
360- }
361-}
362-
363319 /// The group general-settings form (rename + visibility).
364320 #[derive(Debug, Deserialize)]
365321 pub struct GroupGeneralForm {
crates/web/src/repo.rs +41 −29
@@ -1504,44 +1504,56 @@ fn fmt_duration(secs: i64) -> String {
15041504 /// The Collaborators section of the repo settings page: the current list with
15051505 /// remove buttons, and the add form.
15061506 fn collaborators_section(id: &str, csrf: &str, collaborators: &[store::Collaborator]) -> Markup {
1507- let perm_option = |value: &str, label: &str| html! { option value=(value) { (label) } };
15081507 html! {
15091508 section class="listing" {
15101509 h2 { "Collaborators" }
1511- div class="card" {
1512- @if collaborators.is_empty() {
1513- p class="muted" { "No collaborators yet." }
1514- } @else {
1515- ul class="entry-list collab-list" {
1516- @for c in collaborators {
1517- li class="entry-row" {
1518- div class="entry-row-body" {
1519- a class="entry-row-title" href={ "/" (c.username) } { (c.username) }
1520- div class="entry-row-meta muted" { (c.permission) }
1521- }
1522- div class="entry-row-actions" {
1523- form method="post"
1524- action=(format!("/repo-settings/{id}/collaborators/remove")) {
1525- input type="hidden" name="_csrf" value=(csrf);
1526- input type="hidden" name="user_id" value=(c.user_id);
1527- button class="btn btn-danger" type="submit" { "Remove" }
1528- }
1510+ (collaborators_card(&format!("/repo-settings/{id}"), csrf, collaborators))
1511+ }
1512+ }
1513+}
1514+
1515+/// The collaborators card — an avatar/name/role list with remove buttons, then
1516+/// an add row. Shared by the repo and group settings pages; `base` is the action
1517+/// URL prefix (`/repo-settings/{id}` or `/group-settings/{id}`).
1518+pub(crate) fn collaborators_card(
1519+ base: &str,
1520+ csrf: &str,
1521+ collaborators: &[store::Collaborator],
1522+) -> Markup {
1523+ let perm_option = |value: &str, label: &str| html! { option value=(value) { (label) } };
1524+ html! {
1525+ div class="card" {
1526+ @if collaborators.is_empty() {
1527+ p class="muted" { "No collaborators yet." }
1528+ } @else {
1529+ ul class="entry-list collab-list" {
1530+ @for c in collaborators {
1531+ li class="entry-row" {
1532+ div class="collab-ident" {
1533+ img class="avatar avatar-sm" src=(format!("/avatar/{}", c.username)) alt="";
1534+ a class="entry-row-title" href={ "/" (c.username) } { (c.username) }
1535+ span class="collab-perm" { (c.permission) }
1536+ }
1537+ div class="entry-row-actions" {
1538+ form method="post" action=(format!("{base}/collaborators/remove")) {
1539+ input type="hidden" name="_csrf" value=(csrf);
1540+ input type="hidden" name="user_id" value=(c.user_id);
1541+ button class="btn btn-danger" type="submit" { "Remove" }
15291542 }
15301543 }
15311544 }
15321545 }
15331546 }
1534- form class="collab-add" method="post"
1535- action=(format!("/repo-settings/{id}/collaborators")) {
1536- input type="hidden" name="_csrf" value=(csrf);
1537- input type="text" name="username" placeholder="Username" aria-label="Username" required;
1538- select name="permission" aria-label="Permission" {
1539- (perm_option("read", "Read"))
1540- (perm_option("write", "Write"))
1541- (perm_option("admin", "Admin"))
1542- }
1543- button class="btn btn-primary inline-btn" type="submit" { "Add" }
1547+ }
1548+ form class="collab-add" method="post" action=(format!("{base}/collaborators")) {
1549+ input type="hidden" name="_csrf" value=(csrf);
1550+ input type="text" name="username" placeholder="Username" aria-label="Username" required;
1551+ select name="permission" aria-label="Permission" {
1552+ (perm_option("read", "Read"))
1553+ (perm_option("write", "Write"))
1554+ (perm_option("admin", "Admin"))
15441555 }
1556+ button class="btn btn-primary inline-btn" type="submit" { "Add" }
15451557 }
15461558 }
15471559 }
data/fabrica/trash/01kydt075vpteqnjzyms8v3vtm-1785061358405.git/HEAD +0 −0

No textual changes.

data/fabrica/trash/01kydt075vpteqnjzyms8v3vtm-1785061358405.git/config +0 −0

No textual changes.

data/fabrica/trash/01kydt075vpteqnjzyms8v3vtm-1785061358405.git/description +0 −0

No textual changes.

data/fabrica/trash/01kydt075vpteqnjzyms8v3vtm-1785061358405.git/hooks/README.sample +0 −0

No textual changes.

data/fabrica/trash/01kydt075vpteqnjzyms8v3vtm-1785061358405.git/hooks/post-receive +0 −0

No textual changes.

data/fabrica/trash/01kydt075vpteqnjzyms8v3vtm-1785061358405.git/hooks/pre-receive +0 −0

No textual changes.

data/fabrica/trash/01kydt075vpteqnjzyms8v3vtm-1785061358405.git/info/exclude +0 −0

No textual changes.

data/fabrica/trash/01kydta3a2b6gmfh0c4x7tx4b0-1785061363714.git/HEAD +0 −0

No textual changes.

data/fabrica/trash/01kydta3a2b6gmfh0c4x7tx4b0-1785061363714.git/config +0 −0

No textual changes.

data/fabrica/trash/01kydta3a2b6gmfh0c4x7tx4b0-1785061363714.git/description +0 −0

No textual changes.

data/fabrica/trash/01kydta3a2b6gmfh0c4x7tx4b0-1785061363714.git/hooks/README.sample +0 −0

No textual changes.

data/fabrica/trash/01kydta3a2b6gmfh0c4x7tx4b0-1785061363714.git/hooks/post-receive +0 −0

No textual changes.

data/fabrica/trash/01kydta3a2b6gmfh0c4x7tx4b0-1785061363714.git/hooks/pre-receive +0 −0

No textual changes.

data/fabrica/trash/01kydta3a2b6gmfh0c4x7tx4b0-1785061363714.git/info/exclude +0 −0

No textual changes.

data/fabrica/trash/01kydtah34meqbxcfatt6m90p3-1785061376374.git/HEAD +0 −0

No textual changes.

data/fabrica/trash/01kydtah34meqbxcfatt6m90p3-1785061376374.git/config +0 −0

No textual changes.

data/fabrica/trash/01kydtah34meqbxcfatt6m90p3-1785061376374.git/description +0 −0

No textual changes.

data/fabrica/trash/01kydtah34meqbxcfatt6m90p3-1785061376374.git/hooks/README.sample +0 −0

No textual changes.

data/fabrica/trash/01kydtah34meqbxcfatt6m90p3-1785061376374.git/hooks/post-receive +0 −0

No textual changes.

data/fabrica/trash/01kydtah34meqbxcfatt6m90p3-1785061376374.git/hooks/pre-receive +0 −0

No textual changes.

data/fabrica/trash/01kydtah34meqbxcfatt6m90p3-1785061376374.git/info/exclude +0 −0

No textual changes.