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 {
761 align-items: center;761 align-items: center;
762 gap: 0.6rem;762 gap: 0.6rem;
763 min-width: 0;763 min-width: 0;
764 /* Tight line boxes so the name and the role pill centre on each other. */
765 line-height: 1;
764}766}
765.collab-ident .entry-row-title {767.collab-ident .entry-row-title {
766 display: inline;768 display: inline;
@@ -1231,7 +1233,6 @@ input[type="number"],
1231textarea,1233textarea,
1232select {1234select {
1233 width: 100%;1235 width: 100%;
1234 /* Extra right padding so the native dropdown arrow isn't flush to the border. */
1235 padding: 0.5rem 0.75rem;1236 padding: 0.5rem 0.75rem;
1236 font: inherit;1237 font: inherit;
1237 color: var(--fb-fg);1238 color: var(--fb-fg);
@@ -1239,6 +1240,23 @@ select {
1239 border: 1px solid var(--fb-border);1240 border: 1px solid var(--fb-border);
1240 border-radius: var(--fb-radius);1241 border-radius: var(--fb-radius);
1241}1242}
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. */
1246select {
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}
1257select:disabled {
1258 background-image: none;
1259}
1242input:disabled,1260input:disabled,
1243textarea:disabled,1261textarea:disabled,
1244select:disabled {1262select:disabled {
@@ -2227,6 +2245,7 @@ pre.code {
2227}2245}
2228.release-actions {2246.release-actions {
2229 display: flex;2247 display: flex;
2248 align-items: center;
2230 gap: 0.5rem;2249 gap: 0.5rem;
2231 flex: none;2250 flex: none;
2232}2251}
crates/web/src/releases.rs +40 −23
@@ -214,12 +214,6 @@ async fn release_card(
214 (icon(Icon::File))214 (icon(Icon::File))
215 a class="asset-name" href=(format!("/release-asset/{}", a.id)) { (a.name) }215 a class="asset-name" href=(format!("/release-asset/{}", a.id)) { (a.name) }
216 span class="asset-size muted" { (fmt_bytes(a.size)) }216 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 }
223 }217 }
224 }218 }
225 li class="asset-row source" {219 li class="asset-row source" {
@@ -231,13 +225,6 @@ async fn release_card(
231 a class="asset-name" href=(format!("{base}/-/archive/{}.tar.gz", r.tag)) { "Source code (tar.gz)" }225 a class="asset-name" href=(format!("{base}/-/archive/{}.tar.gz", r.tag)) { "Source code (tar.gz)" }
232 }226 }
233 }227 }
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 }
241 }228 }
242 }229 }
243 }230 }
@@ -292,10 +279,12 @@ pub async fn edit_form(
292 .await?279 .await?
293 .map_or(repo.owner_id.clone(), |u| u.username);280 .map_or(repo.owner_id.clone(), |u| u.username);
294 let back = format!("/{owner}/{}/-/releases/{}", repo.path, release.tag);281 let back = format!("/{owner}/{}/-/releases/{}", repo.path, release.tag);
282 let assets = state.store.list_release_assets(&release.id).await?;
295 let (jar, chrome) = build_chrome(&state, jar, Some(user), uri.path().to_string());283 let (jar, chrome) = build_chrome(&state, jar, Some(user), uri.path().to_string());
296 let body = html! {284 let body = html! {
297 p { a class="back-link" href=(back) { "← Back to release" } }285 p { a class="back-link" href=(back) { "← Back to release" } }
298 (release_form(&format!("/release/{}/edit", release.id), &chrome.csrf, Some(&release), &[], &[]))286 (release_form(&format!("/release/{}/edit", release.id), &chrome.csrf, Some(&release), &[], &[]))
287 (assets_editor(&release.id, &chrome.csrf, &assets))
299 };288 };
300 Ok((jar, page(&chrome, "Edit release", body)).into_response())289 Ok((jar, page(&chrome, "Edit release", body)).into_response())
301}290}
@@ -351,6 +340,40 @@ fn release_form(
351 }340 }
352}341}
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.
345fn 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
354/// Create/edit form body.377/// Create/edit form body.
355#[derive(Debug, Deserialize)]378#[derive(Debug, Deserialize)]
356pub struct ReleaseForm {379pub struct ReleaseForm {
@@ -565,7 +588,7 @@ pub async fn asset_upload(
565 return AppError::Forbidden.into_response();588 return AppError::Forbidden.into_response();
566 }589 }
567 let result = async {590 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?;
569 if let Some((tmp_name, name, size, ctype)) = saved {592 if let Some((tmp_name, name, size, ctype)) = saved {
570 let asset = state593 let asset = state
571 .store594 .store
@@ -579,10 +602,7 @@ pub async fn asset_upload(
579 return Err(AppError::internal("storage error"));602 return Err(AppError::internal("storage error"));
580 }603 }
581 }604 }
582 Ok::<String, AppError>(format!(605 Ok::<String, AppError>(format!("/release/{}/edit", release.id))
583 "/{}/{}/-/releases/{}",
584 user.username, repo.path, release.tag
585 ))
586 }606 }
587 .await;607 .await;
588 match result {608 match result {
@@ -643,14 +663,11 @@ pub async fn asset_delete(
643 .release_asset_by_id(&id)663 .release_asset_by_id(&id)
644 .await?664 .await?
645 .ok_or(AppError::NotFound)?;665 .ok_or(AppError::NotFound)?;
646 let (release, repo, user) =666 let (release, _repo, _user) =
647 writer_release(&state, viewer.as_ref(), &asset.release_id).await?;667 writer_release(&state, viewer.as_ref(), &asset.release_id).await?;
648 let _ = tokio::fs::remove_file(assets_dir(&state).join(&asset.id)).await;668 let _ = tokio::fs::remove_file(assets_dir(&state).join(&asset.id)).await;
649 state.store.delete_release_asset(&asset.id).await?;669 state.store.delete_release_asset(&asset.id).await?;
650 Ok::<String, AppError>(format!(670 Ok::<String, AppError>(format!("/release/{}/edit", release.id))
651 "/{}/{}/-/releases/{}",
652 user.username, repo.path, release.tag
653 ))
654 }671 }
655 .await;672 .await;
656 crate::issues::respond_redirect(result)673 crate::issues::respond_redirect(result)
data/repos/01/01kye56ybp94wrec3fm02e9ge8.git/refs/tags/v0.0.0 +1 −0
@@ -0,0 +1 @@
137157e7f0e8a390c3579e4bfc14dbbbf1d06a711