What broke
Prototype, 21 Aug
Not a taste dispute. The prototype wrote 577 lines of MockupsLibraryView.tsx and imported four things from the design system: Button, Dialog, Typography, Menu. It never touched Tabs, never touched EmptyState, never touched LayoutHeaderTabs. Every problem below follows from that one fact.
01
A pill switch where tabs belong
Browse / Library was hand-rolled as a full-width segmented control. The dashboard already has a route-driven tab system, centred in the header, and AI Studio uses it for exactly this job.
Fix → register a mockups tab set in LayoutHeaderTabs/configs.ts
02
"Only you" under every row
Visibility was rendered as a grey caption below each rail item, so the rail read as four labels and two links. Visibility is a property of a folder, not a paragraph about it.
Fix → lock / people icon on the card, tooltip carries the words
03
One sentence where a screen belongs
The zero state was a centred line of bold text in an otherwise blank column. The kit ships an EmptyState module with illustration, description and an action slot.
Fix → never be empty — see the three variants below
Also stale: the prototype branched off main on 21 Aug, three days before Vasili's #14389 moved search into the taxonomy row. Anything rebuilt on top of it starts by rebasing past that, or the search argument gets re-litigated for no reason.
The screen that has to teach
Three treatments, in the order they should be reached for.
Projects has no designed empty state at all — it renders a dashed New tile into the grid and lets that carry the meaning. That is the precedent worth copying, and it is why option A's library root can never be blank.
1 · Never empty
Library root. Likes, Your mockups and the dashed New folder tile always render, so the model is legible before anything exists. No empty state needed.
2 · EmptyState module
Inside a folder that really is empty, and inside Likes before anything is liked. Illustration, one instruction, one action — the kit component, not a sentence.
Library / Summer tees
□
Nothing in this folder yet
Pick mockups while browsing and add them here. Select several at once to add them together.
Browse mockups
3 · First-run strip
Shown above the grid on a first visit only, dismissible, gone forever after. Three steps because collecting genuinely is a sequence — like, group, apply. This is the one place numbering earns itself.
Like what you'd reuseTap the heart on any mockup while browsing.
Group them into foldersSelect several, then add them to a folder.
Apply a folder to a designPut one design on every mockup in the folder at once.
Cut from the prototype: the full-bleed upload banner and the "Use your own photo" tile at the head of the first lane. The tile competed with the mockups it sat beside and promised something that opens a dialog and stops. Your own photos get a home in the Library, and nothing above the fold until the core works.
Folders, for real
The part that has to exist before any of the above is more than a sketch.
Two populations, one folder
A mockup on the dashboard is one of two completely different things, and this is the fact the whole model turns on.
| Catalog mockups | Your own photos |
| Identity | MockupTemplateId = number, CMS-backed | Upload cuid, UploadType.MOCKUP |
| Database row | none — there is no Mockup table | Upload |
| Owned by the user | no | yes |
| Already foldered | no | yes — a reserved "Mockups" UploadFolder |
| Can be "moved" | no — you cannot move what you don't own | yes |
So folders cannot be containment. Folder and UploadFolder both work by a single-valued FK on the child — a design is in one folder. A catalog mockup has no row to carry that FK, and belongs in as many folders as you like. The model has to be membership, with a source-qualified reference. Call it folders in the UI; build it as membership underneath.
Schema
Conventions copied verbatim from UploadFolder: cuid id, workspace FK authoritative, creator recorded, soft delete, denormalized children timestamp, unique name per workspace.
// apps/api/prisma/schema/editor/mockup.prisma (new file)
model MockupFolder {
/// @zod.cuid()
id String @id @default(cuid())
name String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String
workspaceId String
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
entries MockupFolderEntry[]
createdAt DateTime @default(now())
deletedAt DateTime?
entriesUpdatedAt DateTime @default(now())
sortOrder Int @default(0)
@@unique([workspaceId, name], name: "workspaceIdMockupFolderName")
@@index([workspaceId])
}
model MockupFolderEntry {
/// @zod.cuid()
id String @id @default(cuid())
folder MockupFolder @relation(fields: [folderId], references: [id], onDelete: Cascade)
folderId String
source MockupSource // the load-bearing column
referenceId String // CMS numeric id as string, or Upload cuid
sortOrder Int @default(0)
placement Json? // normalized intent, nullable, P5
createdAt DateTime @default(now())
@@unique([folderId, source, referenceId], name: "folderSourceReference")
@@index([folderId])
}
enum MockupSource { CATALOG UPLOAD }
source ships in the first migration or never. Mockup identity is a bare number in 154 occurrences across 43 files today. Adding the discriminator later means a migration over every row plus a second pass through every card, lane and selection call site.
referenceId is a String, matching Bookmark.referenceId, which already stores mixed-type references this way.
placement stores intent, not a rect. MockupDesignRect is {x,y,width,height} and gets cleared whenever the design changes, because it is aspect-dependent. Store normalized centre, scale and rotation instead — or leave it null until P5.
Endpoints
Express router → controller → service, mounted beside /folders, following folders.router.ts exactly. Typed client regenerated with pnpm generate:api-client; hooks land in @repo/resources/src/queries/mockupFolders/.
| Route | Does |
| GET /mockups/folders | List for the workspace, excluding soft-deleted. Returns counts and up to four cover references per folder. |
| POST /mockups/folders/create | Create. Unique name per workspace, same 409 handling as folders. |
| PATCH /mockups/folders/:id | Rename, reorder. |
| DELETE /mockups/folders/:id | Soft delete via deletedAt. |
| POST /mockups/folders/:id/restore | Undo, because soft delete without restore is just a bug. |
| POST /mockups/folders/:id/entries | Batch add [{source, referenceId}]. Idempotent on the unique key. |
| DELETE /mockups/folders/:id/entries | Batch remove. |
| POST /mockups/resolve | The one genuinely new thing. Hydrate many mixed-source mockups in one call. |
Why /mockups/resolve is not optional. There is no batch endpoint today: useMockupById is one GET /api/mockups/{id} per card, and the Likes page already pays that — listBookmarks runs Promise.all and calls ElementsService.getMockup once per bookmark. A 15-entry folder plus four covers on each of six folder cards is 39 CMS round trips for one screen. Measured usage says a realistic folder is about 15 entries, so this is a real page, not a hypothetical.
Likes stay where they are
Liking is Bookmark with BookmarkType.MOCKUP, per user, already shipped server-side. It does not move into folders and it is not a folder — it is the flat list you collect from. One catch: createBookmark validates the id against the CMS and throws 404 otherwise, with an integration test asserting exactly that. Liking your own uploaded photo therefore needs a server change.
Recommendation: don't. In v1 likes are catalog-only. Your own photos already have a home — the Your mockups tile — and they do not need a second one. This reverses v2 and matches what the editor spec already does.
Order of work
P0
Server: folders, entries, batch resolveInert without callers, so it can land early and alone.
new
P1
Heart on the dashboard cardPR 13913 already does this. Rebase it behind 14322 and add guest suppression.
exists — do not rebuild
P2
Library tab, folder grid, folder routetabSet entry, two routes, folder card, EmptyState. Option A.
reuses MockupsGrid
P3
Add to folder in the selection toolbarThe collect gesture. One action on a toolbar that already ships.
small
P4
Your mockups tileReads existing Upload rows of type MOCKUP. Convert-to-mockup feeds it.
depends on editor track
P5
Placement per entryNormalized intent on the entry row. Needs a live placement callback that does not exist yet.
blocked