feat(web): rich reusable avatar picker replacing the native file input (BUNYIP-408) #399

Merged
longjacksonle merged 1 commit from feat/BUNYIP-408-avatar-picker into main 2026-07-28 22:08:12 +02:00

What

Replaces the unstyled native <input type="file"> + separate Upload button in Settings with a single reusable avatar picker component (views::avatar_picker), addressing every point raised: off-theme chrome, two-step interaction with no feedback, no preview, no client-side validation, non-interactive circle, and misaligned vertical rhythm.

Behaviour

  • Circle is the primary target. Hover/focus shows a "Change" overlay; click / Enter / Space open the file dialog. Built as a <label> around a visually-clipped-but-focusable input, so mouse and keyboard both work with no JS. A secondary "Change photo" button adds discoverability.
  • Drag and drop onto the circle with a visible drop-target ring; page-wide drop is prevented so a stray drop never navigates away.
  • Instant preview + auto-upload. Selection previews in the circle immediately and the upload fires automatically (no Upload button) with a determinate progress ring.
  • Client-side validation before any request. MIME sniffed from magic bytes (not extension); non-PNG/JPEG/WebP/GIF rejected; over-2-MB rejected with the file's actual size in the message. Errors are inline, in the theme error color, announced via aria-live="polite", and clear on the next valid pick.
  • Client-side downscale. Valid images are center-cropped to a square and downscaled to a 512px edge on a canvas (WebP), so a 1.9 MB photo becomes a few KB stored asset.
  • States: empty (letter fallback), preview-pending, uploading (progress + disabled), success (toast), error, and Remove with a confirm step (removal is irreversible).
  • Propagation. Every avatar surface renders [data-avatar-slot], so a successful upload/removal repaints the header user-menu avatar in place, no full-page reload; /me/avatar is cache-busted per change. (The header reads the same server source on next navigation, so there is no stale cache.)
  • Layout. The control aligns to the field stack width below; the error line reserves its height so states never shift the layout.
  • Theming + a11y. App reed focus-visible ring (never the browser default blue); overlay scrim clears WCAG AA over any image; hidden input keeps a real label + the image real alt text; keyed on theme tokens so light / dark / high-contrast all adapt.

Progressive enhancement

Without JS the control is a plain multipart form (choose + Upload) plus a remove form; JS hides those and drives the enhanced flow. The BFF /settings/avatar and /settings/avatar/remove handlers now content-negotiate: JSON ({ok} / {error}) for the picker's XHR, redirect + toast for the no-JS form.

Files

  • src/views/avatar_picker.rs - the component (Maud markup + the AVATAR_PICKER_JS controller) and render tests.
  • input.css - .avatar-picker* / .avatar-slot__img component styles keyed on theme tokens; assets/styles.css rebuilt with the pinned Tailwind toolchain.
  • src/views/layout.rs - mounts the controller; avatar_badge now renders [data-avatar-slot] for propagation.
  • src/handlers/dashboard.rs - Settings card uses the component; upload/remove handlers content-negotiate JSON vs redirect.

Verification

  • just check-container green: fmt + clippy -D warnings + all test binaries (bunyip-web 116 tests, incl. 3 new picker render tests for the empty/set states).
  • CSS rebuilt in the pinned rust-builder-glibc image; component + new utility classes confirmed present in assets/styles.css.

Note: interactive behaviour (drag/drop, canvas downscale, progress, propagation) is client-side JS and is not covered by the Rust test suite. Happy to spin up a live preview of the states if useful.

🤖 Generated with Claude Code

## What Replaces the unstyled native `<input type="file">` + separate Upload button in Settings with a single reusable avatar picker component (`views::avatar_picker`), addressing every point raised: off-theme chrome, two-step interaction with no feedback, no preview, no client-side validation, non-interactive circle, and misaligned vertical rhythm. ## Behaviour - **Circle is the primary target.** Hover/focus shows a "Change" overlay; click / Enter / Space open the file dialog. Built as a `<label>` around a visually-clipped-but-focusable input, so mouse and keyboard both work with no JS. A secondary "Change photo" button adds discoverability. - **Drag and drop** onto the circle with a visible drop-target ring; page-wide drop is prevented so a stray drop never navigates away. - **Instant preview + auto-upload.** Selection previews in the circle immediately and the upload fires automatically (no Upload button) with a determinate progress ring. - **Client-side validation before any request.** MIME sniffed from magic bytes (not extension); non-PNG/JPEG/WebP/GIF rejected; over-2-MB rejected with the file's actual size in the message. Errors are inline, in the theme error color, announced via `aria-live="polite"`, and clear on the next valid pick. - **Client-side downscale.** Valid images are center-cropped to a square and downscaled to a 512px edge on a canvas (WebP), so a 1.9 MB photo becomes a few KB stored asset. - **States:** empty (letter fallback), preview-pending, uploading (progress + disabled), success (toast), error, and Remove with a confirm step (removal is irreversible). - **Propagation.** Every avatar surface renders `[data-avatar-slot]`, so a successful upload/removal repaints the header user-menu avatar in place, no full-page reload; `/me/avatar` is cache-busted per change. (The header reads the same server source on next navigation, so there is no stale cache.) - **Layout.** The control aligns to the field stack width below; the error line reserves its height so states never shift the layout. - **Theming + a11y.** App reed focus-visible ring (never the browser default blue); overlay scrim clears WCAG AA over any image; hidden input keeps a real label + the image real alt text; keyed on theme tokens so light / dark / high-contrast all adapt. ## Progressive enhancement Without JS the control is a plain multipart form (choose + Upload) plus a remove form; JS hides those and drives the enhanced flow. The BFF `/settings/avatar` and `/settings/avatar/remove` handlers now content-negotiate: JSON (`{ok}` / `{error}`) for the picker's XHR, redirect + toast for the no-JS form. ## Files - `src/views/avatar_picker.rs` - the component (Maud markup + the `AVATAR_PICKER_JS` controller) and render tests. - `input.css` - `.avatar-picker*` / `.avatar-slot__img` component styles keyed on theme tokens; `assets/styles.css` rebuilt with the pinned Tailwind toolchain. - `src/views/layout.rs` - mounts the controller; `avatar_badge` now renders `[data-avatar-slot]` for propagation. - `src/handlers/dashboard.rs` - Settings card uses the component; upload/remove handlers content-negotiate JSON vs redirect. ## Verification - `just check-container` green: fmt + clippy `-D warnings` + all test binaries (bunyip-web 116 tests, incl. 3 new picker render tests for the empty/set states). - CSS rebuilt in the pinned `rust-builder-glibc` image; component + new utility classes confirmed present in `assets/styles.css`. Note: interactive behaviour (drag/drop, canvas downscale, progress, propagation) is client-side JS and is not covered by the Rust test suite. Happy to spin up a live preview of the states if useful. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(web): rich reusable avatar picker replacing the native file input (BUNYIP-408)
All checks were successful
E2E / Playwright against deployment (pull_request) Successful in 48s
Check / fmt + clippy + build + tests (pull_request) Successful in 4m14s
Create release / Create release from merged PR (pull_request) Has been skipped
2a91c056d5
The Settings avatar control was an unstyled native <input type="file"> plus a separate Upload button: off-theme chrome and focus ring, a two-step interaction with no feedback, no preview, no client-side validation, and a non-interactive avatar circle. This replaces it with a single reusable component (`views::avatar_picker`) used anywhere an image upload appears.

Interaction: the avatar circle is the primary target - hovering (or focusing) shows a "Change" overlay and it opens the file dialog on click, Enter, or Space. The circle is a <label> wrapping a visually-clipped-but-focusable file input, so click and keyboard both work with no JS, and a secondary "Change photo" button adds discoverability. Drag and drop onto the circle is supported with a visible drop-target ring, and page-wide drop is prevented so a stray drop never navigates away. Selection previews immediately in the circle and the upload fires automatically (no Upload button) with a determinate progress ring.

Client-side validation before any request: the MIME is sniffed from magic bytes (not the extension), anything outside PNG/JPEG/WebP/GIF is rejected, and over-2-MB files are rejected with the file's actual size in the message. Valid images are center-cropped to a square and downscaled to a 512px edge on a canvas (exported as WebP), so a 1.9 MB photo is stored as a few KB. Errors render inline in the theme error color, are announced via aria-live=polite, and clear on the next valid pick. The error line reserves its height so states never shift the fields below.

States covered: empty (letter fallback), preview-pending, uploading (progress ring + disabled controls), success (toast), error, and a Remove action with a confirm step (removal is irreversible). Propagation: every avatar surface renders `[data-avatar-slot]`, so a successful upload/removal repaints the header user-menu avatar in place, with no full-page reload; the `/me/avatar` URL is cache-busted per change.

Theming and a11y: the focus ring is the app's reed focus-visible ring (never the browser default), the overlay scrim clears WCAG AA over any image, the hidden input keeps a real label association and the image real alt text, and the component is keyed on theme tokens so light, dark, and the high-contrast toggle all adapt.

Progressive enhancement: without JS the control is a plain multipart form (choose + Upload) plus a remove form; JS hides those and drives the enhanced flow. The BFF upload/remove handlers now content-negotiate - JSON (`{ok}`/`{error}`) for the picker's XHR, redirect + toast for the no-JS form.

Component styles live in `input.css` (`.avatar-picker*`, `.avatar-slot__img`); `assets/styles.css` was rebuilt with the pinned Tailwind toolchain. Verified: `just check-container` green (fmt + clippy -D warnings + all test binaries), including new render tests for the picker's empty/set states.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L5dcYueNHByRnWJDYoDX1W
longjacksonle scheduled this pull request to auto merge when all checks succeed 2026-07-28 22:05:35 +02:00
longjacksonle deleted branch feat/BUNYIP-408-avatar-picker 2026-07-28 22:08:13 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/bunyip!399
No description provided.