# Settings Workspace Logo Upload — Design

Audience: AI coding agents first.

**Date:** 2026-08-07  
**Slug:** `settings-logo-upload`  
**Status:** Approved for implementation

## Goal

Replace `/settings/account`'s editable Logo URL field with an accessible image-upload control. Reuse the existing tenant-logo upload, storage, validation, and delivery contracts. Do not introduce a second upload route, storage key, UI primitive, or persistence field.

## Scope

### Included

- Upload PNG or JPEG workspace logos from `/settings/account`.
- Enforce the existing 2 MiB client and server limits.
- Upload directly to R2 through the existing presigned PUT flow.
- Preview the current or newly uploaded logo.
- Remove the persisted logo by saving `logo_url: null`.
- Preserve workspace-name and Save changes behavior.
- Disable Save and logo actions while upload or save is pending.

### Excluded

- Image cropping, resizing, or format conversion.
- Drag-and-drop.
- New backend routes or database fields.
- Deleting an R2 object when the logo is removed; the existing fixed tenant key remains reusable.
- WebP support because the existing tenant-logo public route only permits PNG and JPEG.

## Existing Contracts to Reuse

### UI primitives

Use only existing `@zync/ui` primitives already used by Settings and profile/onboarding flows: `Stack`, `Button`, `Input`, `Separator`, `FormLabel`, `Text`, `Spinner`, and `toast`. A visually hidden native file input remains the browser file-selection seam. Do not add a design-system primitive for this single consumer.

### Upload API

`POST /api/onboarding/logo`

Request shape:

```ts
{
  filename: string
  content_type: 'image/png' | 'image/jpeg'
  file_size_bytes: number
}
```

Success shape:

```ts
{
  upload_url: string
  logo_url: string
}
```

The browser MUST PUT the selected file directly to `upload_url` with its validated `Content-Type`. The returned `logo_url` becomes pending Settings state only after that PUT succeeds.

### Persistence API

`PATCH /api/settings/account` remains authoritative. Save sends the current workspace name and `logo_url`; removal sends `logo_url: null`. Uploading does not silently persist the URL before Save changes.

### Delivery

The existing strict public proxy serves only `tenants/{tenantId}/logo.png` or `logo.jpg`. Preview MUST use the canonical `logo_url` returned by the upload API.

## UI Behavior

`AccountSettingsPage(): JSX`

- Label the field `Workspace logo`, not `Logo URL`.
- Show a bounded, object-contained preview when `logo_url` exists.
- After a successful replacement upload, cache-bust only the preview request so the newly uploaded bytes appear immediately; persist the canonical `logo_url` unchanged.
- Show `Upload logo`; selecting it opens the hidden file input.
- Show `Replace logo` when a logo exists.
- Show `Remove` only when a logo exists; removal updates pending form state and requires Save changes.
- Show the existing contract hint: `PNG/JPG, max 2MB`.
- During upload, show an explicit pending state, disable upload/remove/save actions, and expose `aria-busy`.
- Reset the native file input after every selection so re-selecting the same file works.
- Keep keyboard activation and screen-reader labeling through the existing Button and label primitives.

## Validation and Errors

- Reject any MIME type other than `image/png` or `image/jpeg` before requesting a presigned URL.
- Reject files larger than 2 MiB before requesting a presigned URL.
- Show existing toast-style validation, upload-success, and upload-failure feedback.
- Keep the previously persisted/pending logo unchanged when validation, presign, or PUT fails.
- Do not expose presigned URL details in error text or logs.

## Data Flow

1. Account query supplies persisted `logo_url`.
2. File selection validates MIME and size locally.
3. Existing logo API returns tenant-scoped presigned PUT and canonical public URL.
4. Browser PUTs bytes directly to R2.
5. Successful PUT updates pending `logoUrl`; preview refreshes.
6. Save changes persists `logo_url` through the existing audited account-settings mutation.

## Testing

- Component regression proves the account page no longer renders a URL textbox and does render upload, preview, remove, and pending states with existing primitives.
- Upload regression proves invalid MIME and oversize files never call the API.
- Upload regression proves presign then PUT ordering and that only a successful PUT updates pending state.
- Save regression proves uploaded URL and removal (`null`) use the existing account PATCH contract.
- Existing API tests remain authoritative for tenant authorization, server validation, signed upload generation, strict public-key serving, and account audit persistence.
- Production verification uploads a valid image, saves, reloads Settings, and confirms the canonical preview returns successfully.

## Architecture Decisions

- **Reuse `/api/onboarding/logo`: accepted.** It already owns the general tenant-logo storage contract despite its route name; duplicating or renaming it adds migration risk without new behavior.
- **Keep upload logic in the account-settings route module: accepted.** One additional consumer does not justify a new abstraction; the existing onboarding implementation remains the reference contract.
- **No new upload UI primitive: accepted.** Existing primitives plus a native hidden file input fully express this interaction; a single-adapter abstraction would be shallow.
- **No R2 delete on removal: accepted.** Removal is a persistence choice, while the fixed tenant object key is harmless and reused by the next upload.

## Acceptance

- `/settings/account` exposes no editable logo URL.
- A permitted user can upload PNG/JPEG up to 2 MiB, preview it, save it, reload, replace it, and remove it.
- Invalid or failed uploads do not mutate persisted Settings state.
- No new backend route, database column, storage bucket, or UI primitive exists.
