fabrica

hanna/fabrica

fix(web): select caret, pill alignment, and release asset editing

beaef11 · hanna committed on 2026-07-26

- Draw the select dropdown caret from two token-coloured gradients (the native
  arrow ignored padding and hugged the border) with room from the edge.
- Tighten the collaborator role pill's line box so it centres on the name.
- Even out the release Edit/Delete buttons (flex no longer stretches Edit).
- Move release asset management (upload + per-asset delete) off the public
  release view onto the edit page, where the release already exists; the view
  now just lists assets as downloads.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
3 files changed · +61 −24UnifiedSplit
assets/base.css +20 −1
@@ -761,6 +761,8 @@ body.drawer-open .drawer-backdrop {
761761 align-items: center;
762762 gap: 0.6rem;
763763 min-width: 0;
764+ /* Tight line boxes so the name and the role pill centre on each other. */
765+ line-height: 1;
764766 }
765767 .collab-ident .entry-row-title {
766768 display: inline;
@@ -1231,7 +1233,6 @@ input[type="number"],
12311233 textarea,
12321234 select {
12331235 width: 100%;
1234- /* Extra right padding so the native dropdown arrow isn't flush to the border. */
12351236 padding: 0.5rem 0.75rem;
12361237 font: inherit;
12371238 color: var(--fb-fg);
@@ -1239,6 +1240,23 @@ select {
12391240 border: 1px solid var(--fb-border);
12401241 border-radius: var(--fb-radius);
12411242 }
1243+/* Replace the native select arrow (which ignores padding and hugs the border)
1244+ with a token-coloured caret drawn from two gradients — no literal colours,
1245+ and given breathing room from the edge. */
1246+select {
1247+ appearance: none;
1248+ -webkit-appearance: none;
1249+ padding-right: 2rem;
1250+ background-image:
1251+ linear-gradient(45deg, transparent 50%, var(--fb-fg-muted) 50%),
1252+ linear-gradient(135deg, var(--fb-fg-muted) 50%, transparent 50%);
1253+ background-position: right 1.05rem center, right 0.7rem center;
1254+ background-size: 0.35rem 0.35rem;
1255+ background-repeat: no-repeat;
1256+}
1257+select:disabled {
1258+ background-image: none;
1259+}
12421260 input:disabled,
12431261 textarea:disabled,
12441262 select:disabled {
@@ -2227,6 +2245,7 @@ pre.code {
22272245 }
22282246 .release-actions {
22292247 display: flex;
2248+ align-items: center;
22302249 gap: 0.5rem;
22312250 flex: none;
22322251 }
crates/web/src/releases.rs +40 −23
@@ -214,12 +214,6 @@ async fn release_card(
214214 (icon(Icon::File))
215215 a class="asset-name" href=(format!("/release-asset/{}", a.id)) { (a.name) }
216216 span class="asset-size muted" { (fmt_bytes(a.size)) }
217- @if can_write {
218- form method="post" action=(format!("/release-asset/{}/delete", a.id)) class="inline-form" {
219- input type="hidden" name="_csrf" value=(csrf);
220- button class="icon-btn" type="submit" aria-label="Delete asset" title="Delete asset" { (icon(Icon::X)) }
221- }
222- }
223217 }
224218 }
225219 li class="asset-row source" {
@@ -231,13 +225,6 @@ async fn release_card(
231225 a class="asset-name" href=(format!("{base}/-/archive/{}.tar.gz", r.tag)) { "Source code (tar.gz)" }
232226 }
233227 }
234- @if can_write {
235- form method="post" action=(format!("/release/{}/asset", r.id)) enctype="multipart/form-data" class="asset-upload" {
236- input type="hidden" name="_csrf" value=(csrf);
237- input type="file" name="asset" required;
238- button class="btn inline-btn" type="submit" { "Upload asset" }
239- }
240- }
241228 }
242229 }
243230 }
@@ -292,10 +279,12 @@ pub async fn edit_form(
292279 .await?
293280 .map_or(repo.owner_id.clone(), |u| u.username);
294281 let back = format!("/{owner}/{}/-/releases/{}", repo.path, release.tag);
282+ let assets = state.store.list_release_assets(&release.id).await?;
295283 let (jar, chrome) = build_chrome(&state, jar, Some(user), uri.path().to_string());
296284 let body = html! {
297285 p { a class="back-link" href=(back) { "← Back to release" } }
298286 (release_form(&format!("/release/{}/edit", release.id), &chrome.csrf, Some(&release), &[], &[]))
287+ (assets_editor(&release.id, &chrome.csrf, &assets))
299288 };
300289 Ok((jar, page(&chrome, "Edit release", body)).into_response())
301290 }
@@ -351,6 +340,40 @@ fn release_form(
351340 }
352341 }
353342
343+/// The asset-management card shown on the edit page: existing assets with delete
344+/// buttons, then the upload form. Only reachable once a release exists.
345+fn assets_editor(release_id: &str, csrf: &str, assets: &[store::ReleaseAsset]) -> Markup {
346+ html! {
347+ section class="listing" {
348+ h2 { "Assets" }
349+ div class="card" {
350+ @if assets.is_empty() {
351+ p class="muted" { "No uploaded assets yet." }
352+ } @else {
353+ ul class="asset-list" {
354+ @for a in assets {
355+ li class="asset-row" {
356+ (icon(Icon::File))
357+ a class="asset-name" href=(format!("/release-asset/{}", a.id)) { (a.name) }
358+ span class="asset-size muted" { (fmt_bytes(a.size)) }
359+ form method="post" action=(format!("/release-asset/{}/delete", a.id)) class="inline-form" {
360+ input type="hidden" name="_csrf" value=(csrf);
361+ button class="icon-btn" type="submit" aria-label="Delete asset" title="Delete asset" { (icon(Icon::X)) }
362+ }
363+ }
364+ }
365+ }
366+ }
367+ form method="post" action=(format!("/release/{release_id}/asset")) enctype="multipart/form-data" class="asset-upload" {
368+ input type="hidden" name="_csrf" value=(csrf);
369+ input type="file" name="asset" required;
370+ button class="btn inline-btn" type="submit" { "Upload asset" }
371+ }
372+ }
373+ }
374+ }
375+}
376+
354377 /// Create/edit form body.
355378 #[derive(Debug, Deserialize)]
356379 pub struct ReleaseForm {
@@ -565,7 +588,7 @@ pub async fn asset_upload(
565588 return AppError::Forbidden.into_response();
566589 }
567590 let result = async {
568- let (release, repo, user) = writer_release(&state, viewer.as_ref(), &id).await?;
591+ let (release, _repo, _user) = writer_release(&state, viewer.as_ref(), &id).await?;
569592 if let Some((tmp_name, name, size, ctype)) = saved {
570593 let asset = state
571594 .store
@@ -579,10 +602,7 @@ pub async fn asset_upload(
579602 return Err(AppError::internal("storage error"));
580603 }
581604 }
582- Ok::<String, AppError>(format!(
583- "/{}/{}/-/releases/{}",
584- user.username, repo.path, release.tag
585- ))
605+ Ok::<String, AppError>(format!("/release/{}/edit", release.id))
586606 }
587607 .await;
588608 match result {
@@ -643,14 +663,11 @@ pub async fn asset_delete(
643663 .release_asset_by_id(&id)
644664 .await?
645665 .ok_or(AppError::NotFound)?;
646- let (release, repo, user) =
666+ let (release, _repo, _user) =
647667 writer_release(&state, viewer.as_ref(), &asset.release_id).await?;
648668 let _ = tokio::fs::remove_file(assets_dir(&state).join(&asset.id)).await;
649669 state.store.delete_release_asset(&asset.id).await?;
650- Ok::<String, AppError>(format!(
651- "/{}/{}/-/releases/{}",
652- user.username, repo.path, release.tag
653- ))
670+ Ok::<String, AppError>(format!("/release/{}/edit", release.id))
654671 }
655672 .await;
656673 crate::issues::respond_redirect(result)
data/repos/01/01kye56ybp94wrec3fm02e9ge8.git/refs/tags/v0.0.0 +1 −0
@@ -0,0 +1 @@
1+37157e7f0e8a390c3579e4bfc14dbbbf1d06a711