Files
site-builder/docs/superpowers/specs/2026-07-12-site-builder-asset-picker-design.md
2026-07-12 11:14:00 -07:00

7.2 KiB

Site Builder — unified image/asset picker (2026-07-12)

Wherever an image is entered by URL in the Craft.js site builder (/workspace/site-builder/craft/), the user should also be able to pick an image already uploaded to the site builder (and upload a new one). Today this "Browse uploaded assets" affordance exists but is copy-pasted into ~6 components and missing entirely from ~7 other image fields. Scope decisions were made with the user before writing this spec.

Problem

The "select an uploaded image" capability (a Browse button opening an inline thumbnail grid backed by ?action=list_assets) is implemented — six independent times:

  • craft/src/components/media/ImageBlock.tsx
  • craft/src/components/basic/Logo.tsx
  • craft/src/components/basic/Navbar.tsx (logo)
  • craft/src/panels/right/styles/ImageStylePanel.tsx
  • craft/src/panels/right/styles/HeroStylePanel.tsx (AssetBrowser sub-component)
  • craft/src/components/media/VideoBlock.tsx (video assets)

…and is absent (plain URL <input> only) from:

  • craft/src/panels/right/styles/BackgroundSectionStylePanel.tsx (bgImage)
  • craft/src/panels/right/styles/MediaStylePanel.tsx (two image fields)
  • craft/src/panels/right/styles/FeaturesEditor.tsx (per-feature image — has upload + URL, no browse)
  • craft/src/components/sections/FeaturesGrid.tsx (per-feature image, settings)
  • craft/src/components/sections/Gallery.tsx (per-image src)
  • craft/src/components/sections/ContentSlider.tsx (per-slide imageSrc)
  • craft/src/components/layout/Container.tsx (background image URL)

Same capability, inconsistent presence — a UX gap for users and 6 divergent copies to maintain.

Solution — one reusable component, two presentation variants

New: craft/src/ui/AssetPicker.tsx

Single source of truth for choosing an image (or, later, video). Lives in src/ui/ — the existing neutral home for reusable settings controls (BorderControl, SpacingInput, AnchorIdField, …), importable by BOTH src/components/** and src/panels/**. (No component currently imports from panels/right/styles, so the shared piece must not live there.)

It composes the three affordances that already exist today:

  1. Upload a new file (drag-drop zone + button)
  2. Browse uploaded site assets (inline thumbnail grid)
  3. Paste a URL (escape hatch for external images)
interface AssetPickerProps {
  value: string;                              // current url ('' = none)
  onChange: (url: string) => void;
  mediaType?: 'image' | 'video' | 'any';      // default 'image' — filters grid + file accept
  variant?: 'full' | 'compact';               // default 'full'
  placeholder?: string;                       // URL input placeholder
}
  • variant="full" — the rich layout: preview thumbnail with remove-✕, dashed drag-drop zone when empty, Upload + Browse buttons, inline grid, URL apply row. Mirrors today's ImageStylePanel layout. Consumers: ImageStylePanel, Logo settings, Navbar logo, BackgroundSectionStylePanel, MediaStylePanel, Hero bg image, Container bg image.
  • variant="compact" — one tight row (small thumbnail + Upload + Browse + inline URL input; grid expands below on Browse) that fits inside array-editor item cards. Consumers: FeaturesEditor, FeaturesGrid settings, Gallery rows, ContentSlider slides.

Both variants share ALL logic (upload, browse fetch + grid, URL state, WHP/ standalone gating); only the markup differs, selected by variant.

Supporting change: craft/src/utils/assets.ts (de-duplicate)

uploadToWhp is duplicated in at least panels/right/styles/shared.tsx, panels/right/styles/FeaturesEditor.tsx, and inline in several components; every Browse re-implements the list_assets fetch + type filter. Add a neutral util:

// src/utils/assets.ts
export async function uploadAsset(file: File): Promise<string | null>;
export async function listAssets(mediaType?: 'image' | 'video' | 'any'): Promise<Asset[]>;
export interface Asset { name: string; url: string; type: string; }
  • uploadAsset = the existing uploadToWhp body (POST upload_asset, blob fallback when no WHP_CONFIG).
  • listAssets = fetch list_assets, return [] when no WHP_CONFIG, filter by mediaType (type startsWith image/video; any = no filter).

AssetPicker uses both. shared.tsx keeps re-exporting uploadToWhp (thin wrapper delegating to uploadAsset) so nothing else breaks; local copies in components are removed as each is migrated to AssetPicker.

Behavior details

  • Standalone mode (no window.WHP_CONFIG): Browse is hidden (no assets API to call); Upload still works via blob: fallback. Matches today's behavior.
  • Video-ready: mediaType drives both the grid filter and the file-input accept. Image fields are wired now with the default 'image'. VideoBlock / Hero bg-video may adopt the same component with mediaType="video" — done in this work if cheap, otherwise a clean follow-up.
  • No toHtml changes. This is purely settings-panel input UX; published HTML is byte-for-byte unaffected, so all existing *.toHtml.test.ts stay green.
  • Prop wiring unchanged. Each consumer keeps writing to its own prop (src, imageSrc, logoImage, bgImage, per-item image/imageSrc) — only the input UI is swapped. AssetPicker is controlled via value/onChange.

Migration order (each independently verifiable)

  1. utils/assets.ts + AssetPicker.tsx + unit test (no consumers yet).
  2. Migrate the "full" panel fields: ImageStylePanel, MediaStylePanel, BackgroundSectionStylePanel, HeroStylePanel (image), Container bg.
  3. Migrate the "full" component settings: Logo, Navbar logo.
  4. Migrate ImageBlock to the shared component.
  5. Migrate the "compact" array editors: FeaturesEditor, FeaturesGrid, Gallery, ContentSlider.
  6. Remove now-dead duplicated upload/browse state and local uploadToWhp copies.

Testing

  • New craft/src/ui/AssetPicker.test.tsx:
    • renders the current value (thumbnail/URL shown),
    • onChange fires with the asset URL on grid-select and on URL "Apply",
    • grid requests + shows only assets matching mediaType,
    • Browse hidden when window.WHP_CONFIG is absent (mock fetch + toggle window.WHP_CONFIG).
  • New craft/src/utils/assets.test.ts: uploadAsset blob fallback + POST shape; listAssets filter + empty-without-config.
  • Regression: full cd craft && npx vitest run and npm run build (tsc) green.

Out of scope / follow-ups

  • Rewriting the asset grid UX itself (search, pagination, folders) — this reuses the current grid look, just makes it universal.
  • Migrating VideoBlock/Hero video pickers is optional in this pass (component is built video-ready; wire if cheap, else follow-up).
  • A separate, parallel Fable-agent audit of the whole site builder may surface additional improvements; those are tracked independently, not in this spec.

Deploy

Ships inside the WHP release, per the standard site-builder pipeline (build craft bundle → copy dist/{index.html,js,css} into whp/web-files/site-builder/build-release.shdownload-update.sh across the fleet). Handled at ship time via the whp-deploy-release skill, after implementation and review.