Prima Get an API key

Module kit / Media, audio and design

Picasso Design Gallery

media-audio-design/ui/picasso-design-gallerylive

Kind frontend
Surface SPA
Maturity prod
Reusable extract
Design system alchemy-ui
Stack react-19, react-router-7, tailwindcss-3.4, lucide-react, axios-1
Depends on _shared/design-system-and-tokens, _shared/react-app-skeleton
Renders media-audio-design/design-template-crud-and-lifecycle, media-audio-design/design-canvas-crud-and-versioning, media-audio-design/brand-asset-resolution-for-designs, media-audio-design/prima-ai-design-chat-and-operations-dsl
Entry alchemy_labs/frontend/src/pages/PicassoGallery.tsx
Reference implementation
  • alchemy_labs/frontend/src/pages/PicassoGallery.tsx
  • alchemy_labs/frontend/src/components/Navigation.tsx
  • alchemy_labs/frontend/src/components/PrivateRoute.tsx
  • alchemy_labs/frontend/src/components/nav.tsx
  • alchemy_labs/frontend/src/services/picassoService.ts
  • alchemy_labs/frontend/src/App.tsx
  • alchemy_labs/backend/api/routers/design_router.py
  • alchemy_labs/backend/services/design/design_service.py
  • alchemy_labs/backend/services/design/template_service.py
  • alchemy_labs/backend/app_fastapi.py
Measured on alchemy_labs main 62ba4fc41009b454e1c033e5036c7e8577db2061 2026-09-12
Version 1

Purpose

A workspace's own Picasso home: /picasso shows every design the workspace has authored ("Meine Designs") and every reusable template ("Templates") in one tabbed grid-or-list view, narrowed by a client-side name search and, on the templates tab only, a category filter. The invariant that keeps it trustworthy: what's on screen is exactly the two arrays GET /api/designs and GET /api/designs/templates return for the caller's workspace (current-version designs only, non-archived templates only), fetched once together on mount and never locally mutated; every control on the page (tab, search, category, grid/list) narrows that same fetched pair in memory rather than re-querying or inventing a row.

It makes two of its four sibling backend modules visible directly: design-canvas-crud-and-versioning's GET /api/designs backs the designs tab, design-template-crud-and-lifecycle's GET /api/designs/templates backs the templates tab. The other two are reachable only by leaving this page: every card click and both "Neues ..." buttons navigate to /picasso/new, /picasso/edit/:designId or /picasso/template/:templateId, where Picasso.tsx (the Fabric.js editor, not this recipe) is what actually calls brand-asset-resolution-for-designs (GET /api/designs/brand-assets) and prima-ai-design-chat-and-operations-dsl (the chat panel and its operations DSL); this gallery only routes a user there, it never calls either surface itself.

When to use

Trigger phrases: "the Picasso gallery", "my designs", "design templates", "Meine Designs", "the template picker", "Picasso home", "the designs grid". Surface: SPA, a private Labs page behind PrivateRoute (sign-in and surface grant only, App.tsx 571 to 577), reached at /picasso; the "Studio" nav domain that links to it is adminOnly: true (nav.tsx 293 to 306) but the route itself enforces no such check (Security & tenancy).

Not this recipe: the editor a card click or either "Neues ..." button actually lands on (Picasso.tsx at /picasso/new, /picasso/edit/:designId, /picasso/template/:templateId, and the separate manual editor PicassoManual.tsx at /picasso/manual), none of which are mounted here and none of which exist yet as a prima-modules frontend recipe (design-canvas-crud-and-versioning's own Frontend section says so); the four backend recipes this page is a window onto or a doorway to (backend_modules above), none of which this component calls beyond the two GETs named in Purpose.

Stack assumptions

S1 tokens and S2 skeleton assumed; pinned per the source at 62ba4fc: react 19, react-router-dom 7, tailwindcss 3.4, lucide-react (Plus, Search, Loader2, Filter, LayoutGrid, List, Image as ImageIcon, line 9), axios 1 underneath the api singleton picassoService.ts wraps. No component library import at all: every element in PicassoGallery.tsx is a bare <button>, <input>, <select>, <div> and Tailwind classes, not @prima-modules/alchemy-ui atoms (contrast subscription-manager and admin-users-console, which both import Button/Card/Heading from the design system); design_system: alchemy-ui above names the kit convention this demo is authored against, not a package the source itself imports. Data-fetch style: the api singleton (S2), Bearer header from accessToken in localStorage, WorkspaceID resolved server-side the same way as every route in this domain. Copy language: German, de-CH, every string on the page, no exception (contrast subscription-manager's one English voucher sentence, there is nothing analogous here since neither GET call's error body is ever read, Application logic).

Data model

Client state (PicassoGallery.tsx lines 20 to 27), all in the page component:

type ViewMode = 'grid' | 'list';
type TabType = 'designs' | 'templates';

activeTab: TabType = 'designs';
viewMode: ViewMode = 'grid';
designs: Design[] = [];
templates: DesignTemplate[] = [];
searchQuery: string = '';
categoryFilter: string | null = null;
isLoading: boolean = true;
error: string | null = null;

Design and DesignTemplate (services/picassoService.ts) are the two GET routes' response shapes unchanged: Design is to_dict(include_canvas=False) (design_router.py 322, so canvas_data is typed on the client interface but never actually populated by this call); DesignTemplate is the model's plain to_dict() with no such exclusion (design_router.py 108), so every template's full Fabric.js canvas_data JSON is downloaded into the browser just to paint a thumbnail grid (Gotchas).

Derived, recomputed on every render, not memoized: categories (54 to 56, Array.from(new Set(templates.map(t => t.category).filter(Boolean))), over the full fetched templates array, not the filtered one); filteredDesigns (59 to 64, case-insensitive name substring only); filteredTemplates (66 to 74, name substring, then an exact category !== categoryFilter check).

The demo's props (demo.tsx): { viewer, workspace, status: 'loading' | 'ok' | 'error', designs: Design[], templates: DesignTemplate[], initialTab?, initialViewMode?, initialSearchQuery?, initialCategoryFilter? }; status collapses the source's two independent booleans (isLoading, error) into one tri-state the same way subscription-manager's demo does, which is exact here because the source itself only ever has error set while isLoading is false (the finally block always clears isLoading, loadData, 45 to 47); the four initial* props seed local useState the same way subscription-manager's voucherCode prop does, since typing and clicking have to update controlled elements.

Browser storage: nothing of its own, relies on S2's accessToken/refreshToken in localStorage; currentWorkspaceId, if set, is attached to both GET requests by the API client's interceptor and is what scopes the two responses to one workspace.

API surface

Contracts consumed, two reads, both fired together on mount and never again:

Call (line)RouteAuth / tenancyParsedRendered as
listDesigns() (37) → api.get('/api/designs')GET /api/designs (design_router.py 294 to 322)get_current_user + WorkspaceID (required)the whole array into designs; each item already to_dict(include_canvas=False) server-sidethe "Meine Designs" tab's grid/list, or its empty-state CTA
listTemplates() (38) → api.get('/api/designs/templates')GET /api/designs/templates (design_router.py 77 to 108)get_current_user + WorkspaceID (required)the whole array into templates, full to_dict() including canvas_data per rowthe "Templates" tab's grid/list, its empty-state CTA, and the derived categories list that drives the category <select>

Both calls are unparameterized: neither ever sends search, category, tags, status, project_id, limit or offset even though both routes accept them server-side (design_router.py 80 to 85, 297 to 299) -- the component always fetches the first page's worth capped at the routes' own default limit=50 and refilters client-side, so a workspace with more than 50 designs or templates never sees the rest through this page at all, and every filter here is entirely client-side regardless of dataset size.

Error handling: Promise.all([listDesigns(), listTemplates()]) (36 to 39) is wrapped in one try; either call throwing lands in one catch that does console.error('Failed to load gallery:', err) and setError('Fehler beim Laden. Bitte versuche es erneut.') (42 to 44) -- the thrown value's body is never inspected, so unlike subscription-manager or admin-users-console there is no err.response?.data?.detail mismatch to document here: this page never reads the server's error shape at all, correct or not, it always shows the identical fixed sentence regardless of which of the two calls failed or why. No WebSocket, no postMessage.

Application logic

  1. PicassoGallery mounts with isLoading: true, error: null, both arrays empty; useEffect fires loadData() once (30, 50 to 51). While isLoading, the sticky header (title, both "Neues ..." buttons, the tab pair, search, view toggle) is already painted, since none of it reads isLoading; only the content area below it is a single centered spinner (209 to 213).
  2. loadData awaits both GETs together via Promise.all; on success both arrays are set and isLoading goes false in the finally; on either throwing, error is set to the one fixed sentence and both arrays stay whatever they were (empty, on a first load) -- isLoading still goes false either way (31 to 51).
  3. Tab switch (setActiveTab, 124, 134) toggles which of the two content blocks renders; it does not touch searchQuery or categoryFilter, so a category chosen on the templates tab stays set (silently, with no visible effect) while the designs tab is showing, and re-applies the moment the user switches back.
  4. Search typing (onChange, 154) filters the active tab's already-fetched array by name only, case-insensitive; template description and category text are not matched (contrast the templates route's own server-side search, which does match description too, template_service.py, never reached from this page per API surface above).
  5. The category <select> (159 to 176) renders only when activeTab === 'templates' and categories.length > 0; categories is derived from every fetched template regardless of the current search text, so a category can remain selectable, and selected, after search has hidden every template that carries it, filtering the visible grid to zero with the same "Keine Templates" copy a genuinely empty workspace shows (Gotchas).
  6. View toggle (178 to 200) swaps viewMode, which both changes the wrapper's class (grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 versus space-y-2, 242 to 247, 282 to 287) and which branch of DesignCard/TemplateCard renders (314 to 342 versus 344 to 370 for designs, 380 to 408 versus 409 to 435 for templates); it applies to whichever tab is active.
  7. Per-tab empty state: filteredDesigns.length === 0 or filteredTemplates.length === 0 (the filtered count, not the raw fetched count) renders one ImageIcon glyph, a heading, one line of copy and a single CTA button that calls the same handleNewDesign/handleNewTemplate the header's own buttons call (225 to 240, 265 to 280); a workspace with fifty designs that all get filtered out by an over-specific search sees the identical "Keine Designs / Erstelle dein erstes Design mit Prima AI" first-run copy a workspace with genuinely zero designs shows.
  8. Thumbnail fallback: DesignCard and TemplateCard, in both view modes, each independently render thumbnail_url ? <img src=...> : <ImageIcon/> in a flat gray box (321 to 333, 349 to 361 for designs; 386 to 398, 414 to 426 for templates) -- the same two-line conditional written out four times rather than shared.
  9. Navigation: a card's onClick calls handleEditDesign(design.id) or handleUseTemplate(template.id) (85 to 91), routing to /picasso/edit/:id or /picasso/template/:id; the header's two buttons and both empty-state CTAs call handleNewDesign/handleNewTemplate (77 to 83), routing to /picasso/new or /picasso/new?mode=template. None of the four handlers reads or mutates the page's own state first.

Invariants

  • The two lists are fetched once, together, on mount: nothing in this component re-fetches after a card click, a tab switch, a search keystroke, a category pick, or a view toggle; only remounting the page refreshes either array (steps 1 to 2).
  • Every filter is client-side against the already-fetched arrays: search, category, tab and view never become a query parameter on either GET, even though both routes accept several (API surface).
  • A filtered-to-zero result and a genuinely-empty fetch render the identical first-run CTA; the component has no way to distinguish "you have rows, none match" from "you have nothing yet" (step 7).
  • A load failure and a successful load of zero rows are visually similar underneath one difference: the error paragraph is its own independent {error && ...} block, not an if/else paired with the tab content blocks, so on a failure both the fixed error sentence AND the active tab's own empty-state CTA render at once, stacked, since designs/templates are simply never populated when loadData throws (steps 1 to 2, Frontend).
  • Nothing here computes a count or a filter result from the server: filteredDesigns.length/filteredTemplates.length, shown live in the tab labels, are always a client-side .filter().length over data already in memory (step 6 of Application logic, tab labels at 131, 141).

Frontend

The component, rebuilt from the demo in the kit with the design system the recipe names, one frame per state. What is in the frames is what the recipe describes and what Primacode builds from; it is not a picture of the running application.

Designs tab, grid view, both lists populated default server rendered, hydrated in view

Picasso

Templates tab: the category filter appears once a template has a category templates-tab server rendered, hydrated in view

Picasso

Designs tab in list view: date, dimensions, the row layout list-view server rendered, hydrated in view

Picasso

First fetch: the header is already painted, only the grid area is a spinner (line 209 to 213) loading server rendered, hydrated in view

Picasso

A fresh workspace: zero designs, the "Keine Designs" first-run CTA empty server rendered, hydrated in view

Picasso

Keine Designs

Erstelle dein erstes Design mit Prima AI.

The load failed: the fixed error sentence above the same "Keine Designs" CTA the empty state shows, since designs/templates never populate on a throw error server rendered, hydrated in view

Picasso

Fehler beim Laden. Bitte versuche es erneut.

Keine Designs

Erstelle dein erstes Design mit Prima AI.

media-audio-design/ui/picasso-design-gallery/demo.tsx
/**
 * Live demo for media-audio-design/ui/picasso-design-gallery.
 *
 * Labs' PicassoGallery.tsx (main, 62ba4fc, 437 lines) with its one fetch
 * (`Promise.all([listDesigns(), listTemplates()])` on mount, lines 36 to 39)
 * replaced by props: a tri-state `status` standing in for the source's two
 * independent booleans (`isLoading`, `error`), and the two already-fetched
 * arrays. Rendered inside the shell demo's component the way every private
 * page is (`Navigation.tsx` is a thin delegator to `AppShell`, PicassoGallery
 * line 10), `activePath` set to `/studio` rather than the real `/picasso`:
 * the shared appshell demo's `nav.tsx` ships "a sample DOMAINS list of
 * generic module names in place of the real IA" (its own header comment) and
 * that sample list's `studio` domain carries `paths: ['/studio', ...]`, not
 * `/picasso`, so `/picasso` matches nothing in `activeDomain`'s prefix test
 * and would light no rail entry at all. `/studio` is the nearest match this
 * shared demo actually supports; the real IA's own `studio` domain (Labs
 * `nav.tsx` 293 to 306) is `adminOnly: true` with a `Picasso` item, which
 * this substitution preserves in spirit.
 *
 * What is kept from the source: the sticky header (title, the two "Neues
 * ..." buttons, the tab pair with live counts, the search input, the
 * category `<select>` that only appears on the templates tab once any
 * template has a category, the grid/list view toggle), the loading spinner,
 * the error paragraph, both tabs' grids (in both view modes) with the exact
 * thumbnail-or-ImageIcon fallback the source duplicates four times, and both
 * tabs' identical "Keine Designs" / "Keine Templates" first-run empty state.
 * All copy is the source's, German (de-CH).
 *
 * What is different, and why: `useNavigate()` and the four handlers it backs
 * (`handleNewDesign`, `handleNewTemplate`, `handleEditDesign`,
 * `handleUseTemplate`, lines 77 to 91) are not wired to real navigation — a
 * demo has no `/picasso/new`, `/picasso/edit/:id` or `/picasso/template/:id`
 * route to land on, so every button and card that would call one of them is
 * inert here, the same convention identity-auth/ui/admin-users-console and
 * billing-credits/ui/subscription-manager already use for a click that would
 * leave the frame. `new Date(design.updated_at).toLocaleDateString('de-CH')`
 * (line 337, the design list view's date) is replaced with a fixed
 * month-name table read with the UTC getters, the same fix those two
 * recipes' demos apply to their own locale- and timezone-dependent
 * formatting, since the source's call would render one date server-side and
 * a possibly different one after browser hydration. Sample `thumbnail_url`
 * values point at a `*.example` host (RFC 2606, guaranteed never to
 * resolve): roughly half the sample rows carry one, so the live docs page
 * shows the browser's own broken-image glyph in place of a real picture on
 * those cards — a real, if inelegant, stand-in for a design whose stored
 * thumbnail has expired or whose storage is unreachable, and visibly
 * distinct from the other half of the sample rows, which carry no
 * `thumbnail_url` at all and exercise the component's actual `ImageIcon`
 * fallback (the "thumbnail fallback" this recipe documents). Shipping a real
 * image asset into the demo folder was not an option (AUTHORING.md: no
 * `.png` under `modules/`), and no other demo in this kit renders an `<img>`
 * with a resolving `src`, so there is no existing convention this departs
 * from. `activeTab`, `viewMode`, `searchQuery` and `categoryFilter` are
 * still local `useState`, seeded from `initial*` props the way
 * subscription-manager's demo seeds its voucher input, so a viewer can still
 * click the tabs, the view toggle and type into search on a live frame.
 */
import React, { useState } from 'react';
import { Filter, Image as ImageIcon, LayoutGrid, List, Plus, Search } from 'lucide-react';
import { defineStates } from '@prima-modules/demo';
import { Shell, type ShellUser, type ShellWorkspace } from '../../../ui-design-system/ui/appshell-navigation/demo';
import { DOMAINS } from '../../../ui-design-system/ui/appshell-navigation/nav';

/* ---- props --------------------------------------------------------------- */

/** `DesignTemplate` as `GET /api/designs/templates` returns it: the model's own `to_dict()`, full `canvas_data` included on every row (design_router.py line 108, no `include_canvas` exclusion, contrast Design below — Gotchas). */
export interface DesignTemplate {
  id: string;
  workspace_id: string;
  created_by?: string;
  name: string;
  description?: string;
  category?: string;
  width: number;
  height: number;
  canvas_data: Record<string, unknown>;
  thumbnail_url?: string;
  element_mappings: Record<string, string>;
  brand_mappings: Record<string, string>;
  editable_elements: string[];
  locked_elements: string[];
  is_public: boolean;
  is_archived: boolean;
  tags: string[];
  created_at: string;
  updated_at: string;
}

/** `Design` as `GET /api/designs` returns it: `to_dict(include_canvas=False)` (design_router.py line 322), so `canvas_data` is typed here but never actually populated by this route. */
export interface Design {
  id: string;
  user_id: string;
  workspace_id: string;
  template_id?: string;
  project_id?: string;
  name: string;
  description?: string;
  width: number;
  height: number;
  canvas_data: Record<string, unknown>;
  version_number: number;
  is_current_version: boolean;
  previous_version_id?: string;
  thumbnail_url?: string;
  exported_url?: string;
  status: 'draft' | 'published' | 'archived';
  created_at: string;
  updated_at: string;
}

type ViewMode = 'grid' | 'list';
type TabType = 'designs' | 'templates';

export interface Props {
  /** The signed-in tenant, for the shell's account row. */
  viewer: ShellUser;
  workspace: ShellWorkspace;
  /** `loading` is the first fetch, before either array is known (lines 30, 209 to 213); `error` is `Promise.all` throwing (42 to 44), rendered as the source's one fixed sentence, never the underlying error's own message (the catch block never reads it). */
  status: 'loading' | 'ok' | 'error';
  /** Empty on `loading` and `error`; the two GET calls' bodies as-is on `ok`. */
  designs: Design[];
  templates: DesignTemplate[];
  /** Seeds the local tab/view/search/category state, same convention as subscription-manager's `voucherCode`. */
  initialTab?: TabType;
  initialViewMode?: ViewMode;
  initialSearchQuery?: string;
  initialCategoryFilter?: string | null;
}

/* ---- sample data ----------------------------------------------------------- */

const viewer: ShellUser = { name: 'Livia Amrein', email: 'livia@example.com', isAdmin: true };
const workspace: ShellWorkspace = { id: 'ws_2', name: 'Studio Amsel', slug: 'studio-amsel' };

const sampleDesigns: Design[] = [
  { id: 'design_1', user_id: 'user_1', workspace_id: 'ws_2', name: 'Sommer Kampagne Post', width: 1080, height: 1080, canvas_data: {}, version_number: 3, is_current_version: true, thumbnail_url: 'https://cdn.studio-amsel.example/designs/sommer-kampagne-post.png', status: 'published', created_at: '2026-08-02T09:00:00Z', updated_at: '2026-09-10T14:22:00Z' },
  { id: 'design_2', user_id: 'user_1', workspace_id: 'ws_2', name: 'Newsletter Header Herbst', width: 1200, height: 400, canvas_data: {}, version_number: 1, is_current_version: true, status: 'draft', created_at: '2026-09-05T11:00:00Z', updated_at: '2026-09-05T11:00:00Z' },
  { id: 'design_3', user_id: 'user_1', workspace_id: 'ws_2', name: 'Produktlaunch Banner', width: 1200, height: 628, canvas_data: {}, version_number: 5, is_current_version: true, thumbnail_url: 'https://cdn.studio-amsel.example/designs/produktlaunch-banner.png', status: 'published', created_at: '2026-07-18T08:30:00Z', updated_at: '2026-09-08T16:45:00Z' },
  { id: 'design_4', user_id: 'user_1', workspace_id: 'ws_2', name: 'Team Vorstellung Story', width: 1080, height: 1920, canvas_data: {}, version_number: 2, is_current_version: true, status: 'draft', created_at: '2026-08-29T10:15:00Z', updated_at: '2026-09-01T09:05:00Z' },
  { id: 'design_5', user_id: 'user_1', workspace_id: 'ws_2', name: 'Rabatt Aktion Quadrat', width: 1080, height: 1080, canvas_data: {}, version_number: 1, is_current_version: true, thumbnail_url: 'https://cdn.studio-amsel.example/designs/rabatt-aktion-quadrat.png', status: 'published', created_at: '2026-09-11T13:00:00Z', updated_at: '2026-09-12T07:40:00Z' },
];

const sampleTemplates: DesignTemplate[] = [
  { id: 'template_1', workspace_id: 'ws_2', created_by: 'user_1', name: 'Instagram Post Basis', category: 'Social Media', width: 1080, height: 1080, canvas_data: {}, thumbnail_url: 'https://cdn.studio-amsel.example/templates/instagram-post-basis.png', element_mappings: {}, brand_mappings: {}, editable_elements: ['headline', 'image'], locked_elements: ['logo'], is_public: true, is_archived: false, tags: ['social'], created_at: '2026-06-01T09:00:00Z', updated_at: '2026-08-14T10:00:00Z' },
  { id: 'template_2', workspace_id: 'ws_2', created_by: 'user_1', name: 'Newsletter Kopfzeile', category: 'Newsletter', width: 1200, height: 400, canvas_data: {}, element_mappings: {}, brand_mappings: {}, editable_elements: ['headline'], locked_elements: [], is_public: true, is_archived: false, tags: ['email'], created_at: '2026-06-10T09:00:00Z', updated_at: '2026-07-22T09:00:00Z' },
  { id: 'template_3', workspace_id: 'ws_2', created_by: 'user_1', name: 'Werbebanner Quer', category: 'Banner', width: 1200, height: 628, canvas_data: {}, thumbnail_url: 'https://cdn.studio-amsel.example/templates/werbebanner-quer.png', element_mappings: {}, brand_mappings: {}, editable_elements: ['headline', 'cta'], locked_elements: ['logo'], is_public: true, is_archived: false, tags: ['ads'], created_at: '2026-06-20T09:00:00Z', updated_at: '2026-09-02T09:00:00Z' },
  { id: 'template_4', workspace_id: 'ws_2', created_by: 'user_1', name: 'Pitch Deck Titel', category: 'Präsentation', width: 1920, height: 1080, canvas_data: {}, element_mappings: {}, brand_mappings: {}, editable_elements: ['headline', 'subhead'], locked_elements: [], is_public: false, is_archived: false, tags: [], created_at: '2026-07-01T09:00:00Z', updated_at: '2026-07-01T09:00:00Z' },
  { id: 'template_5', workspace_id: 'ws_2', created_by: 'user_1', name: 'Story Vorlage Rahmenlos', width: 1080, height: 1920, canvas_data: {}, thumbnail_url: 'https://cdn.studio-amsel.example/templates/story-vorlage-rahmenlos.png', element_mappings: {}, brand_mappings: {}, editable_elements: ['image'], locked_elements: [], is_public: true, is_archived: false, tags: ['social'], created_at: '2026-08-05T09:00:00Z', updated_at: '2026-08-05T09:00:00Z' },
  { id: 'template_6', workspace_id: 'ws_2', created_by: 'user_1', name: 'Karussell Zweitfolie', category: 'Social Media', width: 1080, height: 1080, canvas_data: {}, element_mappings: {}, brand_mappings: {}, editable_elements: ['image', 'headline'], locked_elements: ['logo'], is_public: true, is_archived: false, tags: ['social'], created_at: '2026-08-20T09:00:00Z', updated_at: '2026-09-06T09:00:00Z' },
];

const base: Props = {
  viewer,
  workspace,
  status: 'ok',
  designs: sampleDesigns,
  templates: sampleTemplates,
  initialTab: 'designs',
  initialViewMode: 'grid',
  initialSearchQuery: '',
  initialCategoryFilter: null,
};

export const states = defineStates<Props>({
  'default': { label: 'Designs tab, grid view, both lists populated', props: base, height: 900, viewport: 1440 },
  'templates-tab': { label: 'Templates tab: the category filter appears once a template has a category', props: { ...base, initialTab: 'templates' }, height: 900, viewport: 1440 },
  'list-view': { label: 'Designs tab in list view: date, dimensions, the row layout', props: { ...base, initialViewMode: 'list' }, height: 900, viewport: 1440 },
  'loading': { label: 'First fetch: the header is already painted, only the grid area is a spinner (line 209 to 213)', props: { ...base, status: 'loading', designs: [], templates: [] }, height: 640, viewport: 1440 },
  'empty': { label: 'A fresh workspace: zero designs, the "Keine Designs" first-run CTA', props: { ...base, designs: [], templates: [] }, height: 640, viewport: 1440 },
  'error': { label: 'The load failed: the fixed error sentence above the same "Keine Designs" CTA the empty state shows, since designs/templates never populate on a throw', props: { ...base, status: 'error', designs: [], templates: [] }, height: 720, viewport: 1440 },
});

/* ---- helpers --------------------------------------------------------------- */

/** Fixed month table read with UTC getters, in place of `toLocaleDateString('de-CH', …)` (line 337): the source's call
 * depends on the runtime's ICU data and the viewer's timezone, so a server-rendered date can read differently once the
 * same markup hydrates in the browser, the same fix billing-credits/ui/subscription-manager's demo applies. */
const MONTHS_DE = ['Jan', 'Feb', 'Mär', 'Apr', 'Mai', 'Jun', 'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dez'];
function formatDate(dateString: string): string {
  const d = new Date(dateString);
  return `${d.getUTCDate()}. ${MONTHS_DE[d.getUTCMonth()]} ${d.getUTCFullYear()}`;
}

function Thumb({ url, alt, size }: { url?: string; alt: string; size: 'sm' | 'lg' }) {
  if (url) {
    return <img src={url} alt={alt} className={size === 'lg' ? 'w-full h-full object-cover group-hover:scale-105 transition-transform' : 'w-full h-full object-cover'} />;
  }
  return (
    <div className="w-full h-full flex items-center justify-center">
      <ImageIcon className={size === 'lg' ? 'w-8 h-8 text-gray-400' : 'w-6 h-6 text-gray-400'} />
    </div>
  );
}

/* ---- cards (PicassoGallery.tsx 307 to 435) ---------------------------------- */

function DesignCard({ design, viewMode, onClick }: { design: Design; viewMode: ViewMode; onClick: () => void }) {
  if (viewMode === 'list') {
    return (
      <button onClick={onClick} className="w-full flex items-center gap-4 p-4 bg-white rounded-lg border border-gray-200 hover:border-blue-500 transition-colors text-left">
        <div className="w-16 h-16 bg-gray-100 rounded flex-shrink-0 overflow-hidden">
          <Thumb url={design.thumbnail_url} alt={design.name} size="sm" />
        </div>
        <div className="flex-1 min-w-0">
          <h3 className="font-medium text-gray-900 truncate">{design.name}</h3>
          <p className="text-sm text-gray-500">{design.width} × {design.height} • {formatDate(design.updated_at)}</p>
        </div>
      </button>
    );
  }
  return (
    <button onClick={onClick} className="group bg-white rounded-lg border border-gray-200 hover:border-blue-500 overflow-hidden transition-colors text-left">
      <div className="aspect-square bg-gray-100 overflow-hidden">
        <Thumb url={design.thumbnail_url} alt={design.name} size="lg" />
      </div>
      <div className="p-3">
        <h3 className="font-medium text-gray-900 truncate">{design.name}</h3>
        <p className="text-xs text-gray-500">{design.width} × {design.height}</p>
      </div>
    </button>
  );
}

function TemplateCard({ template, viewMode, onClick }: { template: DesignTemplate; viewMode: ViewMode; onClick: () => void }) {
  if (viewMode === 'list') {
    return (
      <button onClick={onClick} className="w-full flex items-center gap-4 p-4 bg-white rounded-lg border border-gray-200 hover:border-blue-500 transition-colors text-left">
        <div className="w-16 h-16 bg-gray-100 rounded flex-shrink-0 overflow-hidden">
          <Thumb url={template.thumbnail_url} alt={template.name} size="sm" />
        </div>
        <div className="flex-1 min-w-0">
          <h3 className="font-medium text-gray-900 truncate">{template.name}</h3>
          <p className="text-sm text-gray-500">{template.category || 'Allgemein'} • {template.width} × {template.height}</p>
        </div>
      </button>
    );
  }
  return (
    <button onClick={onClick} className="group bg-white rounded-lg border border-gray-200 hover:border-blue-500 overflow-hidden transition-colors text-left">
      <div className="aspect-square bg-gray-100 overflow-hidden">
        <Thumb url={template.thumbnail_url} alt={template.name} size="lg" />
      </div>
      <div className="p-3">
        <h3 className="font-medium text-gray-900 truncate">{template.name}</h3>
        <p className="text-xs text-gray-500">{template.category || 'Allgemein'} • {template.width} × {template.height}</p>
      </div>
    </button>
  );
}

/* ---- the page (PicassoGallery.tsx 16 to 305) --------------------------------- */

function Gallery({
  status,
  designs,
  templates,
  initialTab = 'designs',
  initialViewMode = 'grid',
  initialSearchQuery = '',
  initialCategoryFilter = null,
}: Omit<Props, 'viewer' | 'workspace'>) {
  const [activeTab, setActiveTab] = useState<TabType>(initialTab);
  const [viewMode, setViewMode] = useState<ViewMode>(initialViewMode);
  const [searchQuery, setSearchQuery] = useState(initialSearchQuery);
  const [categoryFilter, setCategoryFilter] = useState<string | null>(initialCategoryFilter);

  const isLoading = status === 'loading';
  const error = status === 'error' ? 'Fehler beim Laden. Bitte versuche es erneut.' : null;

  // Line 54 to 56: every category any fetched template carries, not the filtered set.
  const categories = Array.from(new Set(templates.map((t) => t.category).filter(Boolean))) as string[];

  // Line 59 to 64: name only, case-insensitive.
  const filteredDesigns = designs.filter((d) => !searchQuery || d.name.toLowerCase().includes(searchQuery.toLowerCase()));
  // Line 66 to 74: name, then an exact category match.
  const filteredTemplates = templates.filter((t) => {
    if (searchQuery && !t.name.toLowerCase().includes(searchQuery.toLowerCase())) return false;
    if (categoryFilter && t.category !== categoryFilter) return false;
    return true;
  });

  const gridClass = viewMode === 'grid' ? 'grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4' : 'space-y-2';

  return (
    <div className="min-h-screen">
      <div className="sticky top-0 z-10 bg-white border-b border-gray-200">
        <div className="max-w-7xl mx-auto px-6 py-4">
          <div className="flex items-center justify-between mb-4">
            <h1 className="text-2xl font-bold text-gray-900">Picasso</h1>
            <div className="flex items-center gap-2">
              <button className="flex items-center gap-2 px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors">
                <Plus className="w-5 h-5" />
                Neues Template
              </button>
              <button className="flex items-center gap-2 px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors">
                <Plus className="w-5 h-5" />
                Neues Design
              </button>
            </div>
          </div>

          <div className="flex items-center justify-between">
            <div className="flex gap-1 p-1 bg-gray-100 rounded-lg">
              <button
                onClick={() => setActiveTab('designs')}
                className={`px-4 py-2 text-sm font-medium rounded-md transition-colors ${activeTab === 'designs' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-600 hover:text-gray-900'}`}
              >
                Meine Designs ({filteredDesigns.length})
              </button>
              <button
                onClick={() => setActiveTab('templates')}
                className={`px-4 py-2 text-sm font-medium rounded-md transition-colors ${activeTab === 'templates' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-600 hover:text-gray-900'}`}
              >
                Templates ({filteredTemplates.length})
              </button>
            </div>

            <div className="flex items-center gap-3">
              <div className="relative">
                <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
                <input
                  type="text"
                  placeholder="Suchen..."
                  value={searchQuery}
                  onChange={(e) => setSearchQuery(e.target.value)}
                  className="pl-10 pr-4 py-2 w-64 bg-gray-100 border-0 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:ring-2 focus:ring-blue-500"
                />
              </div>

              {activeTab === 'templates' && categories.length > 0 && (
                <div className="relative">
                  <Filter className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
                  <select
                    value={categoryFilter || ''}
                    onChange={(e) => setCategoryFilter(e.target.value || null)}
                    className="pl-10 pr-8 py-2 bg-gray-100 border-0 rounded-lg text-sm text-gray-900 focus:ring-2 focus:ring-blue-500 appearance-none"
                  >
                    <option value="">Alle Kategorien</option>
                    {categories.map((cat) => (
                      <option key={cat} value={cat}>{cat}</option>
                    ))}
                  </select>
                </div>
              )}

              <div className="flex p-1 bg-gray-100 rounded-lg">
                <button onClick={() => setViewMode('grid')} className={`p-2 rounded ${viewMode === 'grid' ? 'bg-white shadow-sm' : 'hover:bg-gray-200'}`}>
                  <LayoutGrid className="w-4 h-4 text-gray-600" />
                </button>
                <button onClick={() => setViewMode('list')} className={`p-2 rounded ${viewMode === 'list' ? 'bg-white shadow-sm' : 'hover:bg-gray-200'}`}>
                  <List className="w-4 h-4 text-gray-600" />
                </button>
              </div>
            </div>
          </div>
        </div>
      </div>

      <div className="max-w-7xl mx-auto px-6 py-6">
        {isLoading && (
          <div className="flex items-center justify-center py-20">
            <div className="w-8 h-8 rounded-full border-2 border-blue-500 border-t-transparent animate-spin" />
          </div>
        )}

        {error && (
          <div className="text-center py-20">
            <p className="text-red-500">{error}</p>
          </div>
        )}

        {!isLoading && activeTab === 'designs' && (
          filteredDesigns.length === 0 ? (
            <div className="text-center py-20">
              <ImageIcon className="w-12 h-12 mx-auto mb-4 text-gray-300" />
              <h3 className="text-lg font-medium text-gray-900 mb-2">Keine Designs</h3>
              <p className="text-gray-500 mb-4">Erstelle dein erstes Design mit Prima AI.</p>
              <button className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600">Neues Design erstellen</button>
            </div>
          ) : (
            <div className={gridClass}>
              {filteredDesigns.map((design) => (
                <DesignCard key={design.id} design={design} viewMode={viewMode} onClick={() => {}} />
              ))}
            </div>
          )
        )}

        {!isLoading && activeTab === 'templates' && (
          filteredTemplates.length === 0 ? (
            <div className="text-center py-20">
              <ImageIcon className="w-12 h-12 mx-auto mb-4 text-gray-300" />
              <h3 className="text-lg font-medium text-gray-900 mb-2">Keine Templates</h3>
              <p className="text-gray-500 mb-4">Erstelle dein erstes Template als Vorlage für Designs.</p>
              <button className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600">Neues Template erstellen</button>
            </div>
          ) : (
            <div className={gridClass}>
              {filteredTemplates.map((template) => (
                <TemplateCard key={template.id} template={template} viewMode={viewMode} onClick={() => {}} />
              ))}
            </div>
          )
        )}
      </div>
    </div>
  );
}

export default function Demo({ viewer: v, workspace: ws, ...page }: Props) {
  return (
    <Shell domains={DOMAINS} user={v} workspaces={[ws]} currentWorkspaceId={ws.id} activePath="/studio" hasTour={false}>
      <Gallery {...page} />
    </Shell>
  );
}
Component tree, from the recipe
<Route /picasso> PrivateRoute (children, sign-in + surface only, no isAdmin -- Security & tenancy)
  Navigation = AppShell
    PicassoGallery
      header (sticky)
        h1 "Picasso" + Button "Neues Template" + Button "Neues Design"                (99 to 116)
        tabs: "Meine Designs (n)" | "Templates (n)", n = filtered counts               (122 to 143)
        controls: search Input (searchQuery)
                  [activeTab === templates && categories.length > 0] category select   (159 to 176)
                  view toggle: grid | list                                              (178 to 200)
      content
        [isLoading]                     spinner, centered, header still painted        (209 to 213)
        [error]                         centered red paragraph, fixed sentence          (216 to 220)
        [!isLoading && designs tab]
          [filteredDesigns.length === 0]   ImageIcon + "Keine Designs" + CTA            (225 to 240)
          [else]                           grid|list of DesignCard                      (241 to 258)
        [!isLoading && templates tab]
          [filteredTemplates.length === 0] ImageIcon + "Keine Templates" + CTA          (265 to 280)
          [else]                           grid|list of TemplateCard                    (281 to 298)
      DesignCard / TemplateCard: thumbnail_url ? <img> : ImageIcon fallback, duplicated
        four times (grid + list, design + template), identical logic each time          (307 to 435)
Design system
alchemy-ui
Packages
@prima-modules/tokens 0.1.0, from alchemy_labs fd8558f, @prima-modules/alchemy-ui 0.1.0, from alchemy_labs fd8558f, @prima-modules/demo 0.1.0, from prima-modules 5d496a7
Content
Content synced from the prima-modules repository at 0df2ec8 on 2026-09-14.

The rendering is the live rebuild picasso-design-gallery/demo.tsx, built with plain elements and Tailwind classes (no @prima-modules/alchemy-ui atoms, matching the source, Stack assumptions) and rendered by the docs host as one frame per state. The console is rendered inside the shell rebuild (ui-design-system/ui/appshell-navigation/demo.tsx, imported relatively the way subscription-manager's demo imports it) with activePath="/studio": the shared shell demo's own sample nav.tsx ships "a sample DOMAINS list of generic module names in place of the real IA" (its header comment) whose studio domain carries paths: ['/studio', ...], not /picasso, so /picasso matches no prefix in activeDomain and would light no rail entry at all; /studio is the nearest path this shared demo actually supports, and the real IA's own studio domain (nav.tsx 293 to 306) is adminOnly: true with a Picasso item, which the substitution preserves in spirit even though the path string differs. The viewer is isAdmin: true, since a non-admin session would not see this rail entry at all in the real app (Security & tenancy).

States (rendering.states, in order): default (designs tab, grid view, five sample designs, three with a thumbnail and two showing the ImageIcon fallback); templates-tab (templates tab, grid view, six sample templates across four categories plus one with no category, so the category <select> is visible and "Allgemein" appears once in the grid); list-view (designs tab, list view, the row layout with the formatted date); loading (the header fully painted, the content area a single spinner, no cards at all, step 1); empty (both arrays genuinely empty, the "Keine Designs" first-run CTA on the designs tab); error (status: 'error', both arrays empty, the fixed error sentence stacked directly above the same "Keine Designs" CTA the empty state shows, proving the Invariant above rather than inventing a visually distinct failure state the source does not have). The filtered-to-zero-by-search gap (Application logic step 7) and the stale-category-after-tab-switch gap (step 3) are both real, documented branches this recipe does not give their own state, matching the convention subscription-manager's Frontend section sets for its own undemoed isNearLimit gradient: named and explained here, not invented as a seventh frame.

Tokens and classes as the source uses them: plain Tailwind gray/blue/red palette throughout (bg-gray-100, text-blue-500, border-blue-500, text-red-500), not S1 design tokens, the same "not tokens" flag admin-users-console's and subscription-manager's own Gotchas raise for their amber palettes; dark: variants on nearly every className in the source (dark:bg-gray-800, dark:text-white, and on) with no toggle anywhere in Labs, the same dead-code trap those two recipes already name, dropped here since the docs host renders one committed light theme.

Responsive: the header's flex row (tabs left, search/filter/view right) and both grids (grid-cols-2 md:grid-cols-3 lg:grid-cols-4) reflow at the standard Tailwind breakpoints; below md the grid drops to two columns, the search input's fixed w-64 does not shrink, so on a narrow viewport the header row wraps rather than the input resizing (untested at 390px in the source, no sm: override on the search box). Empty state: empty (and the equivalent, undemoed templates-tab CTA, same structure). Error state: error, and, per the Invariant above, visually the same tab-content CTA a genuinely empty workspace shows, plus one red sentence. Loading state: the bare spinner in the content area only, on first mount; there is no refetch-in-flight indicator anywhere on this page since nothing ever refetches after mount (Application logic).

What the demo deliberately does not render: real navigation for any of the four handlers (handleNewDesign, handleNewTemplate, handleEditDesign, handleUseTemplate) -- every button and card that would call one is inert, since the demo package ships no /picasso/new//picasso/edit//picasso/template route to land on, the same convention subscription-manager and admin-users-console use for a click that would leave the frame; new Date(...).toLocaleDateString('de-CH')'s exact runtime/locale behavior, replaced with a fixed month-name table read with UTC getters (Gotchas); a resolving thumbnail image, since no .png may ship under modules/ and no other demo in this kit renders an <img> with a live src -- sample thumbnail_url values point at a *.example host and render as the browser's own broken-image glyph where present, which is documented in the demo's own header comment as a deliberate, if inelegant, stand-in, distinct from the ImageIcon fallback the no-thumbnail sample rows genuinely exercise.

Security & tenancy

What the browser may hold: S2's two tokens only; both fetched arrays (design names, dimensions, thumbnail URLs, and, for templates, full canvas_data JSON, Data model) stay in memory, not persisted. import.meta.env.VITE_API_URL is the only baked value. Origin and CSRF: the api singleton's Bearer header, same as S2. Surface gating: both routes are mounted under LABS_SURFACES (app_fastapi.py 974, mount_router(app, design_router.router, prefix="/api", ..., surfaces=LABS_SURFACES)), so a prima_* session gets the surface-mismatch 403 before either handler runs, the same gate the sibling backend recipes document for these same two routes.

Tenancy here is workspace-scoped like the rest of this domain: WorkspaceID on both GETs (design_router.py 296, 79), resolved from X-Workspace-ID/request.state.workspace_id the same way as every route the sibling backend recipes cover, membership-only, no role distinction (those recipes' own Security & tenancy sections). What is hidden by nav versus enforced by the route: the "Studio" nav domain is adminOnly: true (nav.tsx 298), so a non-admin member's rail never shows the Picasso entry -- but /picasso's own PrivateRoute (App.tsx 571 to 577) checks only user and hasSurface (PrivateRoute.tsx), never isAdmin; a non-admin, non-owner member of a workspace with a valid Labs session can navigate to /picasso directly by URL and see, and start creating, every design and template in whatever workspace their session is currently scoped to. This is a UI-only gate, the same class of gap PrivateRoute.tsx's own doc comment names for the surface check one level up ("This is a UX gate, not the security boundary"), except here there is no backend-level role check standing behind it either, since the two routes this page calls enforce workspace membership only (Security & tenancy of the sibling backend recipes).

Attacker-controlled input the page sends: searchQuery, entirely client-side, never reaches either request; nothing else is sent beyond the two parameterless GETs. What the page renders from the server unescaped: design.name/template.name/template.category and the thumbnail_url as an <img src>; React escapes every string it renders as text and no dangerouslySetInnerHTML is used, so a template or design name containing markup renders as literal text, not markup -- an attacker-supplied thumbnail_url (from a compromised or malicious workspace member with template-create access) is rendered as an image source with no scheme or host allow-list, though a browser's own <img src> handling limits what that can do beyond loading an arbitrary URL.

Tests

  • Colocated with the demo: picasso-design-gallery/demo.test.tsx (vitest, jsdom, @testing-library/react, afterEach(cleanup) per test-layout's globals: false): default renders five design cards and the tab labels read "Meine Designs (5)" / "Templates (6)"; templates-tab renders the category <select> with five options (four real categories plus "Alle Kategorien") and one card reading "Allgemein"; list-view renders formatDate's fixed-format string, not a raw ISO string, for every design; loading renders no card and no empty-state CTA; empty renders exactly the "Keine Designs" heading and one button; error renders both the red sentence and the "Keine Designs" CTA at once, proving the stacked-render Invariant; no state's markup contains an access- or refresh-token shape.
  • The kit's render smoke: node tooling/verify/render-smoke.mjs media-audio-design/ui/picasso-design-gallery renders the six states with renderToString and fetch stubbed (Acceptance checks).
  • Labs at 62ba4fc has no unit test of PicassoGallery.tsx and no Playwright spec for /picasso: grep -rl "PicassoGallery" frontend/src --include='*.test.*' returns nothing (verified directly, not assumed). Backend coverage of the two routes this page calls belongs to the design-template-crud-and-lifecycle and design-canvas-crud-and-versioning recipes' own Tests sections, both of which document 0% router coverage at this same commit; this recipe adds nothing on top of that gap, it only names the one frontend surface reading the uncovered routes.
  • What a new app adds: a jsdom test asserting the two GETs fire exactly once per mount regardless of tab/search/category/view changes (Invariants); a test that a search string matching zero rows renders the identical CTA copy an empty workspace renders, so a future fix (a distinct "no matches" state) has something to replace; a Playwright spec that seeds more than 50 designs in a workspace and asserts the 51st never appears through this page, the pagination gap API surface documents.

Deploy & ops

Build target: the Labs Vite bundle (S2), a lazy chunk (App.tsx line 169, const PicassoGallery = lazy(() => import('./pages/PicassoGallery'))). Env baked at build: VITE_API_URL, S2's own; no page-specific env. CSP and CORS: nothing of its own; both routes answer on the API origin under the SPA's existing allowance, gated by LABS_SURFACES as above. Deploy path: S13's frontend rsync with the bundle; rollback is the bundle's. Nothing to migrate here directly; the schema the two GETs read belongs to the design-template-crud-and-lifecycle and design-canvas-crud-and-versioning recipes. Argus: a synthetic tenant session calling GET /api/designs and GET /api/designs/templates, both returning 200 with a JSON array, is this page's liveness; the page has no health signal of its own beyond the top-level fallback sentence.

Gotchas

  • 2026-09-12 (measured at 62ba4fc, design_router.py 108 vs 322): list_design_templates returns the model's full to_dict(), canvas_data included, for every row; list_designs explicitly excludes it (include_canvas=False). This page fetches both lists on every mount just to paint a thumbnail grid, so opening /picasso downloads every template's complete Fabric.js canvas JSON up front, a payload that grows without bound as a workspace's template library grows, while the designs half of the same page stays lean by comparison. The design-template-crud-and-lifecycle recipe names the same asymmetry from the router's side; this is its one real frontend cost.
  • 2026-09-12 (measured at 62ba4fc, lines 54 to 56, 66 to 74): categories is derived from every fetched template, not the currently filtered set, so the category <select> can offer, and keep selected, a category that the active search text has filtered every matching template out of -- the visible grid reads zero, the copy reads "Keine Templates", and nothing on screen explains that a category is still silently applied.
  • 2026-09-12 (measured at 62ba4fc, lines 209 to 220 and 223 to 300): the error paragraph and each tab's content block are three independent {condition && ...} expressions, not one if/else chain; on a load failure, designs/templates stay [] (they are only ever set inside the try), so the fixed red sentence and the identical "Keine Designs"/"Keine Templates" first-run CTA render stacked on the same screen, indistinguishable in every way from a genuinely fresh workspace except for that one sentence above it.
  • 2026-09-12 (measured at 62ba4fc, lines 36 to 44): the catch block never reads err's response body at all (console.error('Failed to load gallery:', err) only); unlike subscription-manager's err.response?.data?.detail mismatch, there is no server message this page even attempts to surface, so there is nothing to fix at the parsing level, only the fact that any distinguishing detail (a 401 versus a 500, a workspace-access refusal versus a transient failure) is discarded identically.
  • 2026-09-12 (measured at 62ba4fc, line 337): new Date(design.updated_at).toLocaleDateString('de-CH') in the design list view is locale- and timezone-dependent, the same server/client hydration trap subscription-manager's and admin-users-console's Gotchas already flag for their own date and number formatting.
  • 2026-09-12 (measured at 62ba4fc, full-file grep): dark: classes are present on nearly every element (dark:bg-gray-800, dark:text-white, and so on) with no toggle anywhere in Labs that switches a dark class on, the same "dark: dead" trap already documented repeatedly in this kit; a comparison against this page in a dark-mode browser shows nothing different from light mode.
  • 2026-09-12 (measured at 62ba4fc, App.tsx 571 to 577 vs nav.tsx 298): /picasso's PrivateRoute checks sign-in and surface grant only, never isAdmin; the nav rail hides the entry behind adminOnly: true on the "Studio" domain, which is a display-only gate, not an access check, see Security & tenancy.
  • 2026-09-12 (measured at 62ba4fc, lines 321 to 333, 349 to 361, 386 to 398, 414 to 426): the thumbnail_url ? <img> : <ImageIcon/> fallback is written out four times verbatim (two components, two view modes each) rather than factored into one shared cell, so a future change to the fallback (a different icon, a placeholder color) has to be made in four places to stay consistent.

Reference implementation

Branch: alchemy_labs main at 62ba4fc41009b454e1c033e5036c7e8577db2061 (2026-09-12). Paths relative to the repo root:

  • frontend/src/pages/PicassoGallery.tsx (437 lines): imports and types (1 to 14), the component and its state (16 to 27), loadData (30 to 51), categories (54 to 56), filteredDesigns/filteredTemplates (58 to 74), the four navigation handlers (76 to 91), the sticky header (96 to 204), the loading/error/content blocks (206 to 301), DesignCard (307 to 370), TemplateCard (372 to 435).
  • frontend/src/components/Navigation.tsx (22 lines): the delegator to AppShell.
  • frontend/src/components/PrivateRoute.tsx: the sign-in and surface-grant gate this page sits behind, no isAdmin check (Security & tenancy).
  • frontend/src/components/nav.tsx: the studio domain, adminOnly: true, the Picasso item (293 to 306).
  • frontend/src/services/picassoService.ts: DesignTemplate/Design types (16 to 53), listTemplates (166 to 179), listDesigns (247 to 256).
  • frontend/src/App.tsx: the lazy import (169), the /picasso route (570 to 577).
  • backend/api/routers/design_router.py: list_design_templates (77 to 108), list_designs (294 to 322), the router mount context (39).
  • backend/services/design/design_service.py, backend/services/design/template_service.py: the two services these routes call, out of this recipe's own scope (owned by the sibling backend recipes), cited for the response shapes only.
  • backend/app_fastapi.py 974: mount_router(app, design_router.router, prefix="/api", ..., surfaces=LABS_SURFACES).
  • The demo: modules/media-audio-design/ui/picasso-design-gallery/demo.tsx; the shell it renders in: modules/ui-design-system/ui/appshell-navigation/demo.tsx and nav.tsx.

Deviations of the reference from this recipe, all dated 2026-09-12: the full-canvas_data template list payload; the stale-category-after-search gap; the stacked error-and-empty-CTA render; the discarded error body; the locale-dependent date format; dark: classes with no toggle; the nav-only admin gate with no route-level enforcement; the four-times-duplicated thumbnail fallback; no pagination past either route's default limit=50.

Acceptance checks

From alchemy_labs/frontend at 62ba4fc with the harness up (tooling/verify/labs-local.sh up, a tenant session with at least one design and one template seeded), or from a new app built to this recipe:

  1. grep -n "await Promise.all" frontend/src/pages/PicassoGallery.tsx prints line 36, confirming the two GETs fire together, not sequentially.
  2. grep -c "navigate(" frontend/src/pages/PicassoGallery.tsx prints 4 (the four handlers), and grep -n "api\.\(get\|post\)(" frontend/src/services/picassoService.ts | grep -n "designs'\|templates'" | head -2 (or an equivalent read of listDesigns/listTemplates) confirms the two routes this page actually calls are /api/designs and /api/designs/templates, nothing else.
  3. Browser walk-through, signed in as a tenant with a mixed workspace (some designs, some templates, at least two template categories): (a) open /picasso, observe the header paint immediately and the content area show a single spinner, then the designs grid; (b) type a search string matching zero designs, observe the "Keine Designs" CTA appear with the same copy a genuinely empty workspace would show; (c) switch to the templates tab, pick a category from the select, switch back to designs and back to templates again, observe the category selection persisted through the round trip; (d) toggle to list view, observe the date column read a formatted date, not an ISO string; (e) with the network offline or the API stopped, reload /picasso, observe the red "Fehler beim Laden. Bitte versuche es erneut." sentence appear directly above the same "Keine Designs" CTA, stacked, confirming the Invariant.
  4. Demo gate: node tooling/verify/render-smoke.mjs media-audio-design/ui/picasso-design-gallery renders all six states with renderToString, 0 failures.
  5. Verifier: node tooling/verify/compare.mjs media-audio-design/ui/picasso-design-gallery --state default --docs http://127.0.0.1:4321 --app http://127.0.0.1:3020 --session-file tooling/verify/.labs-local/session.json (a tenant session with designs and templates seeded) writes a report with page_errors: []; mismatch_ratio is read for the header and the grid layout only, since the app's actual design/template rows are the harness's seeded data and the demo's are sample data, and any card carrying a *.example thumbnail renders a browser broken-image glyph the seeded app's real thumbnails will not, excluded by name from the comparison.

Content synced from the prima-modules repository at 0df2ec8 on 2026-09-14. The recipe file is the source; this page is a rendering of it.