# Sirv AI Studio

A workspace for merchants' product photos and product data, where almost every screen is a long, configurable list.

- Author: [Max Vyshniakov](https://maxvyshniakov.com/about/)
- Role: Design Engineer
- Period: 20 February – 18 June 2026
- Status: My part ended in June 2026, in the middle of the rework
- Models: GPT-5.5 (29.8M output tokens, 84%), GPT-5.4 (5.7M output tokens, 16%), Claude Opus 4.6 (not logged). Only sessions kept on this machine count: Codex from 10 April, Claude Code from 14 July. Claude Code work from February to April (about 2,600 prompts, mostly in March) isn't counted.
- Stack: React, TypeScript, TanStack Start, TanStack Table, TanStack Virtual, Tailwind CSS, Sid-Kit, Data Grid, Storybook, React Compiler, Next.js, TanStack Router, TanStack Query, shadcn, Base UI, dnd-kit, XState, Zustand, React Flow, Playwright, Vitest, Sentry, PostHog
- Category: Data-dense product UI
- Topics: Product design, Interaction design, Data interfaces
- Web page: https://maxvyshniakov.com/work/sirv-studio/

On 9 June I decided that Studio's saved views, shipped two weeks earlier as tabs, shouldn't be tabs at all: tabs show content, and a view is something you press, drag and edit. A month earlier that would have meant a new mockup, a handover and a polite request to redo the work. In Studio it meant an evening. It was my first project as a developer and the first where I didn't design a screen in Figma. Studio is a workspace for merchants' photos and product data, so almost every screen is a long list with configurable columns, filters and views. I started with the foundation, a clean component kit and one grid for every list, and then reworked the screens built on it. Most of the decisions below were made the second time.

![Studio's products table in the dark theme over Fernhill Home, a fictional furniture catalog. A row of coloured saved views, Default view, Recents, Ready for Shopify, Missing photos, New this week and Sofas, sits above columns for product, SKU, status, source, assets, filled slots, readiness bars and brand, with thumbnails of chairs and desks.](https://maxvyshniakov.com/media/54b6399d352a5c24f3ce9c00e3a20df11810b062d9822491c9d3ff0774632334.webp)

Products in June, over Fernhill Home, a fictional catalog.

## At a glance

* 5 → 1: list virtualizers, replaced by one grid for tables and galleries
* 79 of 91: UI components moved onto Sid-Kit by May
* 784: commits in four months, 256 of them in June
* 30: screen recordings turned into transcripts and frames in two days

- Studio had tables in 35 files, five list virtualizers and hard-coded colours in 57 components, and every new screen copied one of them. → [Rebuild the foundation before polishing any screen](https://maxvyshniakov.com/work/sirv-studio/#a-product-made-of-lists-starts-with-its-foundation)
- Views were tabs that saved only filters, while columns and layout lived outside them, and a tab read as content rather than a control. → [Saved views are buttons in the page header, not tabs](https://maxvyshniakov.com/work/sirv-studio/#a-saved-view-is-a-button-not-a-tab)
- A shot list was a fixed list of pictures, while a catalog often needs any two of four angles rather than all four. → [A media template groups slots and says how many of each group must be filled](https://maxvyshniakov.com/work/sirv-studio/#templates-describe-the-shoot-and-its-file-names)
- Screens slid in from arbitrary directions, with JavaScript fades and a delay that lagged behind the route. → [A screen slides from where it sits in the sidebar, and only CSS animates it](https://maxvyshniakov.com/work/sirv-studio/#a-screen-slides-from-where-it-sits)

## A product made of lists starts with its foundation

Studio is lists all the way down, so the first thing to fix was what every list is made of. Igor Varyvoda, who created Studio, had grown it fast with agents, and by early March it had the code you get when a product outruns its kit. On 4 March it had 35 files with their own tables, five separate list virtualizers and hard-coded colours in 57 components. The products table and the asset gallery were different components that behaved differently under the same hand.

Turning Studio into a DAM/PIM was my idea: build on the customers and image CDN the company already had, and grow standalone AI image tools into a workspace for merchants' assets, then for their product data and sales channels. A workspace like that lives or dies by its lists, and I didn't want to polish screens that would be rebuilt anyway.

> We started this project without shadcn and migrated later. I suspect a lot of it was migrated badly. My goal now is to align our UI kit with canonical shadcn where possible, and list every change so we can decide what to keep.
>
> — Me to Claude, 5 March at 23:53

The first attempt went wrong the next morning, and I threw it away and started again from a fresh branch, with a clean shadcn install kept beside the project as the reference. For a week components went back to canonical one by one: checkbox, dropdown, dialog, tabs, the sidebar's tokens. On that base the kit grew into Sid-Kit, and by 14 May 79 of the 91 files in Studio's `ui` folder were thin re-exports of it.

Problem: Studio had tables in 35 files, five list virtualizers and hard-coded colours in 57 components, and every new screen copied one of them. **Solution: Rebuild the foundation, canonical components first, then one kit and one grid, before polishing any screen.** Cost: Weeks where the product barely changed on the surface, and a first attempt that had to be thrown away. Result: By May one grid served every list where five virtualizers had been, 79 of the 91 files in Studio's ui folder were Sid-Kit, and a fix to a control or a list reached every screen.

How the kit decides colours, depth and states is a story of its own, told in [the Sid-Kit case](https://maxvyshniakov.com/work/sid-kit/).

## Every list in Studio is one grid

The library, products, vendors and activity were the same problem, so they got the same component.

> Let's plan a nice grid component. It should support virtualization, customizable columns, grouping, sorting and so on. It will be used on the library and products mostly, also for vendors, and activity. Heck, we have many applications, actually.
>
> — Me to Claude, 4 March at 22:34

An hour later the library ran on grid primitives, with tests. Two days later a separate table and gallery for products appeared beside it; three days after that they were gone, folded into the same grid. By May the grid was a package, and Studio's screens switched to it on 1 May.

Products before the grid, on its first day and in June.

* **4 March**
  ![Products on 4 March: a list of wide cards, each with a thumbnail, a name, status and source badges, tags, a SKU, the brand, the product type twice and a count of filled photos such as 2/6, under filters for brands, types, tags and products.](https://maxvyshniakov.com/media/8841a7a4fe7ee549683b662c90440034c49ac58ba90df9ceaf890f8580478975.webp)
* **10 March**
  ![Products on 10 March: a table on the first grid, with checkboxes, product, SKU, status, source, assets, slots, brand, category, tags, updated and created, where most cells are cut off, such as FH-CHR-0… and Dining c….](https://maxvyshniakov.com/media/8bdf80a72f89e4adc1f25cbecc4d8b621850e8f9ce322938bdb2561388b58e25.webp)
* **18 June**
  ![Products in June: the grid with fitted columns for product, SKU, status, source, assets, filled slots, readiness bars and brand, under a row of coloured saved views.](https://maxvyshniakov.com/media/54b6399d352a5c24f3ce9c00e3a20df11810b062d9822491c9d3ff0774632334.webp)

A grid is hard in a way a mockup doesn't show. The one time I opened Figma for Studio, it was to cut cells out of paper, in effect: which line belongs to which cell, which border covers which, what a row is when its cells are pinned to different edges. After that the design happened in code.

Most of what makes a list feel right is small rules. Double-clicking a column's edge resets its width and a second double-click lets it fill the free space. Pinned columns stay put while the rest slide underneath, with a shadow only where something is hidden. The keyboard and selection follow a file manager, and when the spec I'd written for that was wrong, I said so: Escape should leave a clean slate from where the cursor is.

Moving a column was the hardest of these. The first version, on 5 March, used a drag-and-drop library. It couldn't move pinned columns or show the whole column in flight, so on 3 April it was replaced with plain pointer events and one rule: a column moves when its leading edge crosses the centre of its neighbour. On 8 April a full-column ghost and an animated landing came in, and on 9 April I reverted them: too much motion for something the hand does dozens of times a day. The ghost came back on its own on 28 April, without the choreography.

![The products table while the Brand column is dragged left: its header floats as a small label with a grip under the pointer, the Brand cells and the Readiness column slide past each other mid-move, and a strip of the dark canvas under the table shows where the columns have not settled yet.](https://maxvyshniakov.com/media/bcb49d58e11de109885f09acd8a2b176d7b52b96f36e6a51a35917d30965fa54.webp)

A column in flight.

Problem: The drag-and-drop library couldn't move pinned columns or show the whole column in flight. **Solution: Reorder columns with pointer events: the dragged column's leading edge crossing a neighbour's centre moves it.** Cost: A drag-and-drop library's accessibility and edge cases had to be rebuilt by hand, and an animation I'd asked for was thrown away. Result: Pinned and scrolling columns move by the same rule, and after 28 April column moving needed no more rework in Studio.

Sorting came last. On 7 June Igor's review blocked my secondary sort because it existed only in the interface. So sorting by several fields became part of the grid's contract, which each screen's data source implements, and its levels became a list in the view sheet with directions in words, "A–Z" or "Newest first", rather than arrows.

The surprise was width. A table in Studio never knows how wide it will be: the sidebar can be dragged wider or folded away with an animation, a details panel or the view sheet can open beside it, and every column can be fitted, fixed or set to fill. Each of those changes the width of every column at once, and early on each one made the table stutter. Making it keep up took more work than any feature here. Underneath it is one geometry for the table: column widths, pins and fills worked out together and cached, so a view that comes back unchanged recomputes nothing. Fitting a column to its content then needed to know how wide text really is, which no library measured well enough, and middle truncation, which keeps both ends of a file name or an ID, needed the same measurer. Both became packages of their own.

The grid's internals, the geometry cache, measuring and truncation, selection and performance, are in [the Data Grid case](https://maxvyshniakov.com/work/data-grid/).

## Loading keeps the shape of the table

A list that is loading should already look like the list. In April Studio's grid flickered between rows, empty space and a spinner whenever it scrolled fast or refetched.

On 23 April I asked for a ghost grid that was more than cell borders, with skeleton chunks where the values would be. The first version painted them with a repeating CSS gradient, and I asked whether that could round the chunks' corners. It couldn't.

> Nah. Try SVG. You get animation for free there too.
>
> — Me to Codex, 23 April at 23:01

The result is what we called the carpet. Its geometry comes from the live grid: column widths, flexible columns, pinned edges, the header and the row height. Each column draws one SVG strip of chunks under every row at once, and real rows simply cover it as they mount, so there's nothing to swap and nothing to time. Ten minutes later I asked for random widths for the text chunks, so the carpet read as text rather than as a pattern. A week later the gallery got its own carpet from the same numbers: gaps, card sizes and how cards stretch.

![The products table held in its loading state: under the real header, every column shows rounded grey chunks of different widths in place of names, SKUs, statuses, sources, counts, readiness bars and brands, with a square where each thumbnail will be.](https://maxvyshniakov.com/media/deb8535b447b4883542d87bd846b8d3832846d196b0c68407eae81540f2ee21d.webp)

The carpet, held in place from the bug button.

Problem: Fast scrolling and refetching flickered between rows, empty space and a spinner. **Solution: One SVG carpet per column, drawn from the same geometry as the table, always under the real rows.** Cost: The carpet has to follow every geometry change, so it shares one geometry runtime with the grid. Result: Fast scrolling, refetching and first load all show the same shape, and the real rows land on it without a shift.

Loading itself stopped blocking. There's no overlay; the scrollbar knows the server's full total from the start, a jump far down loads that range directly, and the end of a list fades into the background with a spinner instead of ending in a hard edge.

## A bug button puts the app in any state

A loading state that lasts 200 ms can't be designed by refreshing the page and hoping to catch it. The tool for that started as something else. In April lazy loading took weeks of experiments with scroll speed, overscan and when to preload the next page, and the numbers had to be watched on a live list. So on 20 April the grid got a corner of plain text on development builds: rows loaded, rows mounted above and below the viewport, the visible range and the preload threshold.

Two days later the numbers turned out to be the smaller part.

> Let's widen the role of the virtualizer panel and make it a debug tool. I want to drag it around the screen by hand. And I want a switch for the page's loading states, to look at the table's skeleton and poke at it with the inspector.
>
> — Me to Codex, 22 April at 12:46

By the evening it was a small window floating over the app, which folds into a round button with a bug on it and can be dragged anywhere on the screen. From that button any list screen can be held in one state while the real page stays mounted behind it: the route's first shell, the carpet alone, the overlay that covers the list while its layout catches up, a refetch over the body or over the whole table, the indicator for a range still loading, or ready. The carpet chapter above was designed this way, in each state side by side with the real rows, until rhythm and optical weight matched.

The inspector also reads out what the grid is doing. It shows which rows are mounted, and whether the column geometry was measured again or taken from the cache, with the keys that decided it. It switches off React's StrictMode, Sentry, session replay and tracing one by one, names the commands for a local production build and for a dev server without hot reload, and turns on routes still in development. Those switches came out of late April, when part of what we'd been fixing turned out to be the dev build itself; the geometry readout is how the column-width cache was checked in June.

The bug button, folded and opened.

* **Folded**
  ![The products table in June with a small round button with a bug icon in the bottom right corner, over the rows.](https://maxvyshniakov.com/media/6a91470f480e915084f346bc95d01d644afa774b4fcbd7c01b367f488e85993b.webp)
* **Opened, holding the carpet**
  ![The Debug Inspector opened over the products table. Its Loading phase section lists Route initial shell, Layout sync overlay, Retained body skeleton (selected), Range load indicator, Dataset update overlay, Full-table dataset update and Ready, above Runtime, Dev routes and Stats; behind it every column of the table shows the carpet.](https://maxvyshniakov.com/media/0e66369cd5d2de93a34d33860ad0b606662bd3c71cefeb58998a7b0ceaa21fac.webp)

Problem: States that last a blink couldn't be designed or checked by refreshing the page and hoping to catch them. **Solution: Every loading state can be forced and inspected from a floating panel on development builds, with the same handles open to scripts.** Cost: A second, private interface to build and keep in step with the grid's states. Result: The carpet was tuned in each state beside the real rows, the June width cache was checked from the geometry readout, and an agent could photograph any state by script.

The handles are the same for a script as for my hand, so an agent could put a screen into a state and photograph it. The pictures of loading states in this case were captured the same way.

## A saved view is a button, not a tab

A view is a way of looking at the same list, so it has to be cheap to make, obvious to change and impossible to lose. Igor built the first saved views in late May: saved filters shown as tabs above products and assets, which could be pinned and reordered. Column layout was left for later.

On 5 June I took them over with one idea: one sheet configures the whole view, filters, sort, grouping and columns, and the grid behind it changes as you go. Simple filters come first, laid out like an online store's, with a query builder for the rest; switching to the builder loses nothing, and switching back warns when it would.

Four days in, the views themselves had to change.

> And these shouldn't be tabs at all. Tabs aren't an interactive component. This is something button-like.
>
> — Me to Codex, 9 June at 04:39

A view is a whole way of looking at the list, not a saved filter: its filters, sort, columns, their order and widths travel with it, so switching from Ready for Shopify to Missing photos changes what the table shows as well as which rows. Views moved into a second row of the page header as small buttons, plain for the others and flat for the current one, with actions on right-click instead of a menu on every button. Each custom view takes its colour from its own key, so the same view always has the same colour. I tried tinted shadows and coloured dots first and threw them out.

Tabs in May, then two views in June with columns of their own.

* **25 May**
  ![Saved views on 25 May: plain tabs above the products table, a home icon, Ready for Shopify, Missing photos, New this week and Sofas, with a plus for a new view; the table shows product, SKU, status and modified.](https://maxvyshniakov.com/media/35958a846d3b4f1d1cd3e30610294552758372216187091d3890388c550b49fa.webp)
* **Ready for Shopify**
  ![June, the Ready for Shopify view: a filled cyan button among coloured view buttons, 107 products, and its own columns: product, SKU, source, brand, tags and modified.](https://maxvyshniakov.com/media/82c817681776cc388ab653e05175c1b90820ba33f8edbf023b5c5d76e9a63c82.webp)
* **Missing photos**
  ![June, the Missing photos view: 55 products and different columns of its own, product, assets, filled slots such as 0/6, category and supplier.](https://maxvyshniakov.com/media/98d2529a93d8d635a58f4d2314121c59469ca63883cf0e1af7a4d7fad1c36dbd.webp)

Problem: Views were tabs that saved only filters, while columns and layout lived outside them, and a tab read as content rather than a control. **Solution: Saved views are coloured buttons in the page header, edited live in one sheet.** Cost: Two weeks on views that already worked, and a dirty state that has to compare against two baselines. Result: A changed view keeps its dot even after you leave it, Update and Revert work against what was saved, and the table or gallery choice survives a refresh in the address.

The hard part was state. A view you've changed but not saved keeps a dot, even after you leave it for another one. Changes apply live, so Done just closes the sheet and Cancel reverts. A changed view compares itself with what's applied for its dot and with what's saved for Update. Reset returns to defaults and Revert to what was saved, and they're different buttons because they're different promises. Whether a list is a table or a gallery lives in the address, not in the view: it's the reader's choice, not the view's. Column widths, fixed, fitted or filling, joined the view in the last week.

One sheet for the whole view, applied as you go.

1) **Filters**: Simple filters first, a query builder a tab away.
   ![The view sheet for Ready for Shopify on its Filters tab in Basic mode: name, SKU, status set to Active, source, brand, product type, category, tags, supplier, images, channel set to the Shopify web shop and readiness set to Ready to publish.](https://maxvyshniakov.com/media/601932bbdbaf0876c8b56326065a81c4360cabc13f50a3464412a9575d0e1cb4.webp)
2) **Sort**: Levels in order, directions in words.
   ![The Sort tab: name as the primary sort, modified as secondary and created as tertiary, each with its direction in words, A to Z or Newest, above the fields still available.](https://maxvyshniakov.com/media/6c8d1a8a11f891377ae0e61e3ec4c5e8a5d05490270982b918157a401f89aca7.webp)
3) **Table**: Columns to show, fit, fill or pin.
   ![The Table tab: a list of columns to show, drag to reorder, and set to fit their content, fill the free space or pin, with the products table switched to a table behind it.](https://maxvyshniakov.com/media/c9b29939c9c769cda9839d01ec1b82d90a07a7d33b81e19e0be7529014bdb90d.webp)
4) **Gallery**: Card size and what a card shows.
   ![The Gallery tab: card sizes from 2XS to 2XL with LG selected and the fields to show on a card, with the products switched to a gallery of large cards behind it.](https://maxvyshniakov.com/media/16b8e03c1ef974242f640ab48a1eff111dded257b9c574c41f3222ca7353c194.webp)

The query builder was the other half of the Filters tab. Igor's first version in May was a fixed OR of AND groups that opened on a list of starter presets. I gave every group and the whole query an explicit ALL or ANY, so single conditions and groups can sit side by side, put each condition on two rows, the field and operator above and the value below, and removed the presets: the builder opens empty, with only the buttons to add a condition or a group. Conditions and groups drag between each other. Each field brings its own operators and its own picker, so a category is chosen from the tree and a date from a range with presets such as the last 30 days, and on the server each condition becomes a piece of SQL. Basic mode and the builder edit the same rules, so a view built in one opens in the other.

![The query builder in the view sheet. Conditions are joined by ALL: Status equals Active; a condition group set to ANY with Category equals Seating and Tags equals Bestseller or Gift idea; and the start of a second group set to ALL with Channel equals a value. Each condition has a drag handle, field and operator on one row and the value below.](https://maxvyshniakov.com/media/ba0c507f176a9c474b03660ac49291750f4c6a6a82c8de893d09fda20c8c7d45.webp)

Active products in Seating or tagged as gifts, checked for Shopify.

About 100 of my commits in June were views, filters and sort.

## Templates describe the shoot and its file names

A product page needs particular pictures: a front view, a back, a detail, maybe a video. Studio's first answer was Igor's slot templates, where each template holds an ordered list of slots with a role, a label, an example picture and whether it's required, and vendors' uploads land in slots by the SKU in their file names. By March they had grown into a long settings page for vendor shot lists.

In mid-March I started two prototypes side by side, in a new Templates section: naming conventions and media templates.

Photos arrive from studios and suppliers named by someone else's rules, and Studio has to know which product and which shot each file is. On 13 and 14 March I built naming conventions: a pattern such as `{product_id}[-{row}]-{frame}.{ext}`, where braces mark a variable and square brackets an optional part. While you type, the pattern is parsed and each variable is drawn as a chip coloured by its type: digits, a fixed set of values, a sequence or free text. Typing a brace opens a list of the variables you can use. The same pattern compiles into a matcher, so the field that defines a mask also shows how it reads a real file name: which part became which variable, what it was normalized to, and whether a whole list of pasted names matches. Conventions for Amazon, Walmart, Home Depot, GS1 and Sirv's own 360 spins come built in.

![The Naming Conventions list: Amazon Standard, Generic SKU, Grainger 360 Spin, GS1 Product Image, Home Depot, Lowe's 360 Spin, Shopify SEO, Sirv 360 Spin, Walmart and others, each pattern drawn with coloured variables such as {gtin}, {sequence}, {row}, {frame} and {ext} between plain separators.](https://maxvyshniakov.com/media/7d27feb6575ca4b8b20f20a538e64cd52eff57f45a3a131f15d4da7ca46b44b7.webp)

Patterns drawn as chips.

A naming convention, tab by tab.

1) **Summary**: The whole convention at a glance.
   ![A convention named Fernhill studio spins on its Summary tab: name, description, the filename pattern {product_id}\[-{row}\]-{frame}.{ext} in coloured chips, the path pattern {product_id}/, and short lists of validation and normalization per variable.](https://maxvyshniakov.com/media/6648b81f65f7b09b17b48beeb3a1893b76abc6243775bfbf10d5967442bbf310.webp)
2) **General**: The pattern, typed and parsed.
   ![The General tab: name and description fields, the filename pattern typed as text with chips for each variable below it, and a path pattern switched on.](https://maxvyshniakov.com/media/742c92dc83ee8129dfd430f51b883d58c7a2c690120ed793f06e3ffd7fdf748c.webp)
3) **Normalization**: Casing, aliases and separators.
   ![The Normalization tab: a switch to normalize separators, and per variable the casing and aliases, such as jpeg to jpg and tiff to tif for {ext}.](https://maxvyshniakov.com/media/6c191a45d5d3a66efdbe6946ad11dac548e06c5a56546dc9422f5e1bfb0127c7.webp)
4) **Validation**: What each variable may hold.
   ![The Validation tab: allowed extensions for {ext}, zero-padding and ranges for {frame} and {row}, and length limits for {product_id}, each overridable.](https://maxvyshniakov.com/media/06d4ae4f918c96d6e370b1144b1ecf7adc0164ca978aa75a2b040d3dcf2c9442.webp)
5) **Test**: One name, split into its parts.
   ![The Test tab: the file name ABC123-01-01.jpg matches, and a table splits it into {product_id} ABC123, {row} 01, {frame} 01 and {ext} jpg with their separators, each with a check.](https://maxvyshniakov.com/media/408e17f846e2886613dcc2aeacd3d0d5136c0b9b243b79f8d1553600574eba80.webp)
6) **Bulk test**: Four of six names match.
   ![Bulk test results for six pasted file names: four match with three or four variables each, and two, FH-CHR-01134_side.jpg and FH-ARM-01659-2-1.png, show no match.](https://maxvyshniakov.com/media/b01681cf9b01ca117f9cbe87878763a50d5b81a903b7ad7bca9df41e6e20f884.webp)

On 18 March I built the second prototype, Media Templates, without touching the vendor portal. Its list looked like the other template lists in Studio and its editor opened in a sheet. Within an hour I cut it down.

> Remove the tabs. There isn't enough content to justify this layout. Just the template's name, a description and the slot configurator.
>
> — Me to Claude, 18 March at 18:02

What made it different was groups. A slot has a media type, image, video, 3D, spin or document, its own specs and reference photos. Slots can sit in a group with a rule, "At least 1", "At least 2" or "All 3 required", because a catalog often needs any two of four angles rather than all four. Slots drag to reorder and between groups. I wanted no visual noise for a simple rearrange, so the target group was tinted only when a slot crossed into it; a day later I removed the tint altogether. Escape cancels the drag rather than closing the sheet. In the list, each template shows a strip with every slot, groups drawn as dashed outlines.

A shot list, then media templates.

1) **Shot lists, 17 March**: Igor's editor in the vendor portal.
   ![A shot list editor on 17 March in the vendor portal settings: a long page with a template brief, a naming convention with a filename pattern and placeholders, and side cards for template pulse counts and a vendor preview of file names.](https://maxvyshniakov.com/media/803fb3467a4d043058f5c0d40919450521425cc343644e64d47b13ec3472d751.webp)
2) **Media templates**: Every slot of every template in one strip.
   ![The Media Templates list: Lighting, Marketplace listing, Seating product page and Tables & storage, each with a strip of slot thumbnails where grouped slots sit inside dashed outlines, a description and a date.](https://maxvyshniakov.com/media/ca0cd5db17739f31ca6a25a4af91f0b18aec90f1f51fbcfbcf67f66877281bd2.webp)
3) **Template editor**: Slots in groups, with a rule per group.
   ![The Media Templates editor as a sheet: a name and description, then 8 slots in 2 groups. A required Hero slot, an Angles group set to At least 2 required with three-quarter, side and back slots, and the top of a Details group set to Not required, each slot with a drag handle and a reference thumbnail.](https://maxvyshniakov.com/media/3590afefac5d8b0461844e533cf06b7d7e41cd97e094b8b10acf3fadc546184b.webp)

Problem: A shot list was a fixed list of pictures, while a catalog often needs any two of four angles rather than all four. **Solution: A media template groups slots and says how many of each group must be filled.** Cost: A second template model beside the shot lists vendors already used, and rules a vendor has to understand. Result: Any two of four angles fits in one group with one rule, each slot carries its own media type and specs, and parts of the model went into Igor's shot lists.

Five days later the same prototype gained image specs: checks such as resolution, background or colour space with a warning value and a passing value, as a general idea for every check that has a threshold. My note to the agent was that if it worked, Igor would use it in the vendor portal.

Then I set both aside for more pressing work, the grid and the lists. Data storage and the vendor side were Igor's at the time, and he took parts of the prototypes into his shot lists.

## A screen slides from where it sits

Navigation is easier to follow when the motion says where you went. In March I moved Studio to a standard sidebar layout with settings and AI tools sliding in as sub-panels, and Igor added the first page transitions. In May, with collections arriving as a tree under assets, the motion needed rules.

The first rule was about the means.

> Drop our workarounds. If this navigation can't be animated with CSS, we don't animate it at all.
>
> — Me to Codex, 11 May at 21:17

The second was about direction. A section above the current one in the sidebar slides down into place, one below slides up. Opening a panel, settings, vendors or collections, slides right, and its title button slides back left. Tabs inside a panel follow their own order. Then, after one in the morning, I asked where that model came from.

> How do we build the model of which navbar button sends the transition where? Dynamically, from the taxonomy? What if someone reorders the navbar by drag and drop?
>
> — Me to Codex, 12 May at 01:34

So vertical direction comes from the order the sidebar actually renders, including pinned collections, not from a list written next to it. The next evening I asked whether this was a module or smeared across the app, and it became one registry with four rules. A panel slides in 240 ms on one curve. Every row in a tree keeps a chevron slot, even without children, so parents and leaves keep the same rhythm.

Folded, the sidebar keeps only its icons, and nothing becomes unreachable. Pointing at a section opens its contents as a menu beside it: the screens of Assets or Products, the grouped pages of the supplier portal, or anything else a section holds, down to a tree. The width is free as well: the sidebar can be dragged wider or narrower, and folds and unfolds with an animation while the table beside it keeps up.

The folded sidebar opens each section as a menu.

* **Assets, folded**
  ![The sidebar folded to a strip of icons beside the products table, with a menu open next to the Assets icon: All assets, Favorites, Collections with a chevron, and Trash.](https://maxvyshniakov.com/media/e305b01fec7e79ffc4bbf7f59d1cc5a21dce578a4b4772ccd2b7f6fd745c8ca4.webp)
* **Supplier portal, folded**
  ![The folded sidebar with the Supplier Portal menu open in groups: Start Here with Portal Home and Supplier Information, Submission Rules with Approval & Validation and Shot Lists, and Operations with Emails, Reviewers, Suppliers and Delivery.](https://maxvyshniakov.com/media/d320f2b44f46b7c6b56cc2a26066fd7526ddd67202799443eb2bb71f3877c71b.webp)

The sidebar from March to June.

1) **4 March**: Ten sections, collections cut off.
   ![Studio on 4 March: the library beside a sidebar of ten top-level items from Get Started to Trash, a Collections list with names cut off such as Autumn 20… and Marketpla…, then AI Tools and Settings; folders sit in the same grid as photos.](https://maxvyshniakov.com/media/e49b21b306c915af55b465fcf4fa9baa68d61aa9bc385d65e67c0ff0298b4f39.webp)
2) **10 March**: A standard sidebar with sliding panels.
   ![Studio on 10 March in the standard sidebar layout: Get Started, Products, Assets, Activity, Review, Analytics, Workflows, AI Tools, Trash, Vendors and Settings, with Collections folded below, beside the library's folders and photos.](https://maxvyshniakov.com/media/e8f485d5cf7b9c758d0dcf2c02a2a75fbcb4d4ba300f115834cbd14516dd08d4.webp)
3) **June**: Sections with their own screens.
   ![Studio in June: Overview, Assets with All assets, Favorites, Collections and Trash, Products with All products, Categories, Attributes and Channels, then Review, AI Tools, Activity, Analytics, Supplier Portal and Settings, beside the assets gallery with its saved views.](https://maxvyshniakov.com/media/5514e2fa36c99fa6035b461a4b829dbd323733dadc72910b9485f87762223311.webp)
4) **Collections**: A panel slides in from the right.
   ![The Collections panel slid in over the sidebar, with a back arrow, add and search buttons and five collections with thumbnails and counts, beside the assets gallery.](https://maxvyshniakov.com/media/643eac47562e91b953b2282cdd99dd11dc07ea385cb3aa56447b34e450fe0d92.webp)

Problem: Screens slid in from arbitrary directions, with JavaScript fades and a delay that lagged behind the route. **Solution: A screen's direction comes from its place in the rendered sidebar, and only CSS animates it.** Cost: Some navigations don't animate at all, and every new section has to declare where it sits. Result: Four rules in one registry cover every section and panel, a panel slides in 240 ms, and reordering the sidebar turns the directions with it.

## Some controls didn't exist yet

A few controls Studio needed weren't in shadcn or any library in the form it needed. Product types form a tree, and choosing one in a filter, a channel's settings or a product group meant choosing from a tree: so Sid-Kit's option list learned to render trees with search, nesting and selection that work the same as its flat lists. Sort levels became an option list with a direction toggle in each row. The query builder's conditions read as two rows, the field and operator above the value, instead of one long line.

![A category picker open inside a query builder condition: a search field over a tree of categories, with Furniture expanded, Seating checked and its children Armchairs and Benches checked with it, each with its path such as Furniture > Seating > Armchairs, above Clear, Cancel and Apply selection.](https://maxvyshniakov.com/media/4907e866cd161754374f40ac6002698b93e672026a69e92cf0bb475baee74a27.webp)

Choosing a category from the tree.

## Storybook replaced Figma

For years my Figma files looked like a design system's: a page per component, its states laid out in a grid, a table of variables and tokens. My prototypes used everything Figma could offer and lagged for it; in screencasts they passed for a real app. In Studio I stopped. Figma is a way to build something and show it to others when you can't build it in code yet, and turning its pictures into code is the hard part that never quite works. I already knew how HTML and CSS are put together and what makes a design implementable, and with agents the code was no longer the slow part.

My design system in Figma, before Studio.

1) **Theme**: Primitive palettes, light and dark.
   ![A page of the SirvUI Figma file: primitive colour palettes from zinc to pink in ten steps each, on a light and a dark board, with text, colour and effect styles listed on the right.](https://maxvyshniakov.com/media/4e753f93353a0fae330cc750ac9d54f48fabbd70ca8584db342d7676f830cc4b.webp)
2) **Variables**: 227 variables for variants alone.
   ![Figma Variables for the SirvUI file: a $variant collection of 227 variables with modes default, faded and bordered, showing Button border width, focus outlines and neutral background colours for disabled, idle, hover and open states.](https://maxvyshniakov.com/media/4a0a23674728f64a60322d8a3dd6af4b4953194c2481cb0ecfc4f85788d1fcc2.webp)
3) **Button**: A component, its states, a showcase.
   ![The Button page in Figma: the Button component set with its states, icon buttons, reusable buttons and a showcase of OK, Save changes, Apply, Proceed and download buttons in every state.](https://maxvyshniakov.com/media/f05ecd9ddef8048b13323926eba538da751621e2fd8d3a6b4e04a3bc7dc70601.webp)
4) **Showcase**: Every size, variant and state laid out.
   ![A Figma board of button showcases in the light theme: one panel per size and variant, 2xs, xs and sm by bordered, flat, light, solid and vibrant, each with the same buttons in every state.](https://maxvyshniakov.com/media/329420cf83652a3a5979643fdcccbd64f1b36ccfaf8dffd153e514950006c131.webp)

How the components were used, in Figma.

1) **Form control**: Labels, hints and add-ons.
   ![The FormControl page in Figma: labels outside and inside fields, descriptions and error messages, and an Addon section with dropdown add-ons at the start and end of a field.](https://maxvyshniakov.com/media/2b5c03b7f1559f6bdc7833e9d1e7cc8c08909ad5981a1aa502120ce5f1c4073c.webp)
2) **Password input**: Every state, at every size.
   ![The PasswordInput page in Figma: md and sm sizes, with the label outside or inside, each in every state from empty and filled to focused, disabled and with add-ons.](https://maxvyshniakov.com/media/c6f17c8732b58f65eae149eb5e9f9ae137950f47e3b8f8d722aa8912c9e5349c.webp)
3) **Dialogs**: Real dialogs, state by state.
   ![A Figma board of real dialogs in the dark theme: two-factor setup steps with a QR code, recovery codes, changing an email, a name and a password, leaving an account, creating and renaming a sub-account, and managing auto-fetch files, each in several states.](https://maxvyshniakov.com/media/aa136179286257a769f23940e91634080ce54bab8b00614cd1a4bb011bc11be1.webp)
4) **All dialogs**: The whole board, zoomed out.
   ![The same Figma file zoomed out: a long board titled DialogBody content with hundreds of dialog frames in a row, too small to read.](https://maxvyshniakov.com/media/ca25f5fe1b5621fea4d6b73e2380f8f18767a60be65dc2c170badfa7b18ed0aa.webp)

Storybook does the same jobs live. A component's story lays out its main abilities and states, and they can be clicked and changed. The styling section isn't drawn by hand: it parses the real CSS and shows each token's name, how its parametric definition unfolds step by step, and the swatch it ends up as. A Figma table of variables is right until the code changes; this one can't be out of date. Studio's style pages appeared on 13 May, and Sid-Kit's token browser on 17 June.

The same jobs in Storybook, June 2026.

* **A story with controls**
  ![Studio's Storybook in June on Sid-Kit's Button: a grid of intents from neutral to a hue of 280 and a key of Ada Lovelace against variants solid, soft, ghost, ghost-accent, outline, dashed and link, above a Controls panel for icon, iconOnly, label and tooltip.](https://maxvyshniakov.com/media/e62b407156d1b232f7f53974d3f6d3be101b506986197e379ee7824e5f8ee987.webp)
* **Tokens read from the CSS**
  ![Studio's Storybook in the dark theme on Design Tokens: core tokens such as --background, --foreground, --muted and --muted-foreground with their swatches and definitions read from the stylesheet, for example oklch(var(--l-muted-foreground) 0 0) and var(--primary).](https://maxvyshniakov.com/media/344b5206031f1df05365bb3951496cf7f450cde878675ddb0a268c4604c62a59.webp)

Without a handover between a picture and the code, changing my mind cost what the change cost, and that is why most decisions here were made twice.

## An agent has to see the screen

Spring 2026 models wrote interfaces fluently and couldn't see them. The harnesses of the time had no browser of their own, so an agent would report a fix it had never looked at, and most of my corrections were about things on screen. In March I had Claude drive my browser through an extension and kept repeating the same request.

> Can you please use the browser for debugging like this? Remember it somehow.
>
> — Me to Claude, 14 March at 00:30

In April the grid got numbers: a baseline for switching between the table and the gallery, 1.3 s one way and 1.8 s the other, a profiler for the grid's interactions and the bug button. Numbers weren't enough, because they measured something other than what I saw.

> Do you understand that you need a way to see the same thing I see, for tests and gates?
>
> — Me to Codex, 30 April at 03:45

So the agent learned to look. In May it moved to an isolated browser of its own, with my browser as the last resort. In June a final screenshot became mandatory for any interface change, and empty frames and skeletons became something tests detect instead of something I report.

For everything else I recorded the screen, as I would for a person: what I do, what I expect, what's wrong, said aloud in Russian with English UI terms. On 17 June I turned that into a skill. It extracts the audio with ffmpeg and transcribes it with Whisper running locally, with a prompt that keeps interface terms in English, into a timeline with timestamps. The agent reads the timeline first, finds where I describe the problem, and only then pulls dense frame sheets around those moments, not the whole video. Its rules are short: never look at frames without the transcript when there is speech, never guess what I meant before reading it, and write a regression test before the fix. By the next day it had processed 30 recordings.

Problem: An agent couldn't see what I saw, and my written summaries of a problem lost the moment and the detail. **Solution: Feedback goes to the agent the way it would go to a person, as a screen recording with speech, turned locally into a timeline and frames.** Cost: A local toolchain to install, and recordings still take my time. Result: In its first two days the skill turned 30 recordings into timelines and frame sheets, and each fix started from the moment where I described the problem.

## How it was built

Studio was my first project as a developer. I'd designed interfaces for years and knew how they are built; I hadn't shipped them myself. The practices of development came to me as answers to problems I already had. When regressions kept coming back, tests were the answer someone had already worked out; when three people worked in one repository, Git was. For about a month the stream of new things was intense. By April I noticed the load had become normal.

Interface work still needs more of my attention than architecture does. An architecture can be designed once and left with an agent for hours. An interface has to be looked at after every change, because models still produce plausible screens that are wrong in ways only a person notices. From April to June I sent Codex about 5,500 messages in Studio, after some 2,600 prompts to Claude in February to April.

Studio was built by a team of three: Igor Varyvoda, Veniamin Krachun and me. I'm grateful to Igor. He was always reachable, and when I was out of my depth he answered to the point without spoon-feeding. He brought me into development, and into development with agents in particular.

Studio's grid and kit outgrew it: [read the Data Grid case](https://maxvyshniakov.com/work/data-grid/) and [the Sid-Kit case](https://maxvyshniakov.com/work/sid-kit/). Igor's own account of Studio is at [varyvoda.com](https://varyvoda.com/projects/sirv-studio).
