diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md
new file mode 100644
index 00000000000..51d7dd419ed
--- /dev/null
+++ b/.claude/CLAUDE.md
@@ -0,0 +1,38 @@
+# Supabase Monorepo
+
+pnpm 10 + Turborepo monorepo. Requires Node >= 22.
+
+## Structure
+
+| Directory | Purpose |
+| ----------------- | ------------------------------------------------------------ |
+| `apps/studio` | Supabase Studio/Dashboard — Next.js (pages router), React 18 |
+| `apps/docs` | Documentation site |
+| `apps/www` | Marketing website |
+| `packages/ui` | Shared UI components (shadcn/ui based) |
+| `packages/common` | Shared utilities and telemetry constants |
+| `e2e/studio` | Playwright E2E tests for Studio |
+
+## Common Commands
+
+```bash
+pnpm install # install dependencies
+pnpm dev:studio # run Studio dev server
+pnpm test:studio # run Studio unit tests (vitest)
+pnpm --prefix e2e/studio run e2e # run Studio E2E tests (playwright)
+pnpm build --filter=studio # build Studio
+pnpm lint --filter=studio # lint Studio
+pnpm typecheck # typecheck all packages
+```
+
+## Conventions
+
+**UI** — import from `'ui'`, use `_Shadcn_` suffixed variants for form primitives. Check `packages/ui/index.tsx` before creating new primitives.
+
+**Styling** — Tailwind only, semantic tokens (`bg-muted`, `text-foreground-light`), no hardcoded colors.
+
+## Studio
+
+Pages router. Co-locate sub-components with parent. Avoid barrel re-export files.
+
+See studio-\* skills for detailed studio conventions.
diff --git a/.claude/skills/studio-best-practices/SKILL.md b/.claude/skills/studio-best-practices/SKILL.md
new file mode 100644
index 00000000000..62eca4c6d1d
--- /dev/null
+++ b/.claude/skills/studio-best-practices/SKILL.md
@@ -0,0 +1,175 @@
+---
+name: studio-best-practices
+description: React and TypeScript best practices for Supabase Studio. Use when writing
+ or reviewing Studio components — covers boolean naming, component structure, loading/error
+ states, state management, custom hooks, event handlers, conditional rendering,
+ performance, and TypeScript conventions.
+---
+
+# Studio Best Practices
+
+Applies to `apps/studio/**/*.{ts,tsx}`.
+
+## Boolean Naming
+
+Use descriptive prefixes — derive from existing state rather than storing separately:
+
+- `is` — state/identity: `isLoading`, `isPaused`, `isNewRecord`
+- `has` — possession: `hasPermission`, `hasData`
+- `can` — capability: `canUpdateColumns`, `canDelete`
+- `should` — conditional behavior: `shouldFetch`, `shouldRender`
+
+Extract complex conditions into named variables:
+
+```tsx
+// ❌ inline multi-condition
+{
+ !isSchemaLocked && isTableLike(selectedTable) && canUpdateColumns && !isLoading &&
+}
+
+// ✅ named variable
+const canShowAddButton =
+ !isSchemaLocked && isTableLike(selectedTable) && canUpdateColumns && !isLoading
+{
+ canShowAddButton &&
+}
+```
+
+Derive booleans — don't store them:
+
+```tsx
+// ❌ stored derived state
+const [isFormValid, setIsFormValid] = useState(false)
+useEffect(() => {
+ setIsFormValid(name.length > 0 && email.includes('@'))
+}, [name, email])
+
+// ✅ derived
+const isFormValid = name.length > 0 && email.includes('@')
+```
+
+## Component Structure
+
+See `vercel-composition-patterns` skill for compound component and composition patterns.
+
+Keep components under 200–300 lines. Split when you see:
+
+- Multiple distinct UI sections
+- Complex conditional rendering
+- Multiple unrelated `useState` calls
+- Hard to understand at a glance
+
+Co-locate sub-components in the same directory as the parent. Avoid barrel re-export files.
+
+Extract repeated JSX patterns into small components.
+
+## Data Fetching
+
+All data fetching uses TanStack Query (React Query). See `studio-queries` skill for query/mutation patterns and `studio-error-handling` skill for error display conventions.
+
+### Loading / Error / Success Pattern
+
+Top level:
+
+```tsx
+const { data, error, isLoading, isError, isSuccess } = useQuery(...)
+
+if (isLoading) return
+if (isError) return
+if (isSuccess && data.length === 0) return
+return
+```
+
+Use early returns — avoid deeply nested conditionals.
+
+Inline:
+
+```tsx
+
+ {isLoading && }
+ {isError && }
+ {isSuccess && data.length === 0 && }
+ {isSuccess && data.length > 0 && }
+
+```
+
+## State Management
+
+Keep state as local as possible; lift only when needed.
+
+Group related form state with `react-hook-form` rather than multiple `useState` calls. See `studio-ui-patterns` skill for form layout and component conventions.
+
+```tsx
+// ❌ multiple related useState
+const [name, setName] = useState('')
+const [email, setEmail] = useState('')
+
+// ✅ grouped with react-hook-form
+const form = useForm({ defaultValues: { name: '', email: '' } })
+```
+
+## Custom Hooks
+
+Extract complex or reusable logic into hooks. Return objects, not arrays:
+
+```tsx
+// ❌ array return (hard to extend)
+return [value, toggle]
+
+// ✅ object return
+return { value, toggle, setTrue, setFalse }
+```
+
+## Event Handlers
+
+- Prop callbacks: `on` prefix (`onClose`, `onSave`)
+- Internal handlers: `handle` prefix (`handleSubmit`, `handleCancel`)
+
+Use `useCallback` for handlers passed to memoized children; avoid unnecessary inline arrow functions.
+
+## Conditional Rendering
+
+```tsx
+// Simple show/hide
+<>{isVisible && }>
+
+// Binary choice
+<>{isLoading ? : }>
+
+// Multiple conditions — use early returns, not nested ternaries
+if (isLoading) return
+if (isError) return
+return
+```
+
+## Performance
+
+`useMemo` for genuinely expensive computations (measured, not assumed). Don't wrap everything — only optimize when you have a measured problem or are passing values to memoized children.
+
+## TypeScript
+
+Define prop interfaces explicitly. Use discriminated unions for complex state:
+
+```tsx
+type AsyncState =
+ | { status: 'idle' }
+ | { status: 'loading' }
+ | { status: 'success'; data: T }
+ | { status: 'error'; error: Error }
+```
+
+Avoid `as any` / `as Type` casts. Validate at boundaries with zod:
+
+```tsx
+// ❌ type cast
+const user = apiResponse as User
+
+// ✅ zod parse
+const user = userSchema.parse(apiResponse)
+// or safe:
+const result = userSchema.safeParse(apiResponse)
+```
+
+## Testing
+
+Extract logic into `.utils.ts` pure functions and test exhaustively. See the `studio-testing` skill for the full testing strategy and decision tree.
diff --git a/.claude/skills/e2e-studio-tests/SKILL.md b/.claude/skills/studio-e2e-tests/SKILL.md
similarity index 54%
rename from .claude/skills/e2e-studio-tests/SKILL.md
rename to .claude/skills/studio-e2e-tests/SKILL.md
index 6f9797f720d..1006981e42d 100644
--- a/.claude/skills/e2e-studio-tests/SKILL.md
+++ b/.claude/skills/studio-e2e-tests/SKILL.md
@@ -1,6 +1,9 @@
---
-name: e2e-studio-tests
-description: Run e2e tests in the Studio app. Use when asked to run e2e tests, run studio tests, playwright tests, or test the feature.
+name: studio-e2e-tests
+description: Write and run Playwright E2E tests for Supabase Studio. Use when asked
+ to run e2e tests, write new E2E tests, or debug flaky tests. Covers running commands,
+ avoiding race conditions, waiting strategies, selectors, helper functions, and CI
+ vs local differences.
---
# E2E Studio Tests
@@ -70,17 +73,20 @@ test.describe.configure({ mode: 'serial' })
### Selector priority (best to worst)
1. **`getByRole` with accessible name** - Most robust, tests accessibility
+
```typescript
page.getByRole('button', { name: 'Save' })
page.getByRole('button', { name: 'Configure API privileges' })
```
2. **`getByTestId`** - Stable, explicit test hooks
+
```typescript
page.getByTestId('table-editor-side-panel')
```
3. **`getByText` with exact match** - Good for unique text
+
```typescript
page.getByText('Data API Access', { exact: true })
```
@@ -93,12 +99,14 @@ test.describe.configure({ mode: 'serial' })
### Patterns to avoid
- **XPath selectors** - Fragile to DOM changes
+
```typescript
// BAD
locator('xpath=ancestor::div[contains(@class, "space-y")]')
```
- **Parent traversal with `locator('..')`** - Breaks when structure changes
+
```typescript
// BAD
element.locator('..').getByRole('button')
@@ -123,6 +131,7 @@ When a component lacks a good accessible name, add one in the source code:
```
Then use it in tests:
+
```typescript
page.getByRole('button', { name: 'Configure API privileges' })
```
@@ -141,6 +150,76 @@ const popover = page.locator('[data-radix-popper-content-wrapper]')
const roleSection = popover.getByText('Anonymous (anon)', { exact: true })
```
+## Avoiding Race Conditions
+
+**Set up API waiters BEFORE triggering actions.** This is the most common source of flaky tests.
+
+```ts
+// ❌ Race condition — response may complete before waiter is set up
+await page.getByRole('button', { name: 'Save' }).click()
+await waitForApiResponse(page, 'pg-meta', ref, 'query?key=table-create')
+
+// ✅ Waiter is ready before the action
+const apiPromise = waitForApiResponse(page, 'pg-meta', ref, 'query?key=table-create')
+await page.getByRole('button', { name: 'Save' }).click()
+await apiPromise
+```
+
+Same rule applies before navigation:
+
+```ts
+const loadPromise = waitForTableToLoad(page, ref)
+await page.goto(toUrl(`/project/${ref}/editor?schema=public`))
+await loadPromise
+```
+
+When an action triggers multiple API calls, wait for all of them:
+
+```ts
+const createTablePromise = waitForApiResponseWithTimeout(page, (r) =>
+ r.url().includes('query?key=table-create')
+)
+const tablesPromise = waitForApiResponseWithTimeout(page, (r) =>
+ r.url().includes('tables?include_columns=true')
+)
+
+await page.getByRole('button', { name: 'Save' }).click()
+await Promise.all([createTablePromise, tablesPromise])
+```
+
+## Waiting Strategies
+
+Playwright auto-waits for elements to be actionable — prefer this over manual timeouts.
+
+Use `expect.poll` for dynamic state changes:
+
+```ts
+await expect.poll(async () => await page.getByLabel(`View ${tableName}`).count()).toBe(0)
+```
+
+Use `waitForSelector` with state for element lifecycle:
+
+```ts
+await page.waitForSelector('[data-testid="side-panel"]', { state: 'detached' })
+```
+
+Avoid `networkidle` — use specific API waits instead:
+
+```ts
+// ❌ Unreliable and slow
+await page.waitForLoadState('networkidle')
+
+// ✅ Specific API response
+await waitForApiResponse(page, 'pg-meta', ref, 'tables')
+```
+
+Timeouts are acceptable only for client-side debounces:
+
+```ts
+await page.getByRole('textbox').fill('search term')
+await page.waitForTimeout(300) // allow debounce
+```
+
## Avoiding `waitForTimeout`
Never use `waitForTimeout` - always wait for something specific:
@@ -175,6 +254,129 @@ await expect(menuButton).toBeVisible()
await menuButton.click()
```
+## Test Structure
+
+Always import from the custom test utility:
+
+```ts
+import { test } from '../utils/test.js'
+```
+
+Use `withFileOnceSetup` for expensive setup that should run once per file:
+
+```ts
+test.beforeAll(async ({ browser, ref }) => {
+ await withFileOnceSetup(import.meta.url, async () => {
+ const ctx = await browser.newContext()
+ const page = await ctx.newPage()
+ await deleteTestTables(page, ref)
+ })
+})
+
+test.afterAll(async () => {
+ await releaseFileOnceCleanup(import.meta.url)
+})
+```
+
+Dismiss toasts before interacting — they can overlay buttons:
+
+```ts
+const dismissToastsIfAny = async (page: Page) => {
+ const closeButtons = page.getByRole('button', { name: 'Close toast' })
+ const count = await closeButtons.count()
+ for (let i = 0; i < count; i++) {
+ await closeButtons.nth(i).click()
+ }
+}
+
+await dismissToastsIfAny(page)
+await page.getByRole('button', { name: 'New table' }).click()
+```
+
+## Assertions
+
+Always include descriptive messages for easier debugging:
+
+```ts
+// ❌ No context on failure
+await expect(page.getByRole('button', { name: 'Save' })).toBeVisible()
+
+// ✅ Clear message on failure
+await expect(
+ page.getByRole('button', { name: 'Save' }),
+ 'Save button should be visible after form is filled'
+).toBeVisible()
+```
+
+Use explicit timeouts for slow operations:
+
+```ts
+await expect(
+ page.getByText(`Table ${tableName} is good to go!`),
+ 'Success toast should be visible after table creation'
+).toBeVisible({ timeout: 50000 })
+```
+
+## Helper Functions
+
+Extract reusable operations into domain helpers (e.g. `e2e/studio/utils/storage-helpers.ts`).
+Use the existing wait utilities:
+
+```ts
+import {
+ createApiResponseWaiter,
+ waitForApiResponse,
+ waitForGridDataToLoad,
+ waitForTableToLoad,
+} from '../utils/wait-for-response.js'
+```
+
+Use `expectClipboardValue` instead of manual clipboard reads with hardcoded timeouts:
+
+```ts
+// ❌ Brittle
+await page.evaluate(() => navigator.clipboard.readText())
+await page.waitForTimeout(500)
+
+// ✅ Uses Playwright auto-retries
+await expectClipboardValue({ page, value: 'expectedValue' })
+```
+
+## API Mocking
+
+```ts
+await page.route('*/**/logs.all*', async (route) => {
+ await route.fulfill({ body: JSON.stringify(mockAPILogs) })
+})
+```
+
+Use soft waits for optional API calls:
+
+```ts
+await waitForApiResponse(page, 'pg-meta', ref, 'optional-endpoint', {
+ soft: true,
+ fallbackWaitMs: 1000,
+})
+```
+
+## Cleanup
+
+Clean up test data in `beforeAll`/`beforeEach`. Check before deleting to handle existing state gracefully:
+
+```ts
+const bucketRow = page.getByRole('row').filter({ hasText: bucketName })
+if ((await bucketRow.count()) === 0) return
+// proceed with deletion
+```
+
+Reset local storage after tests that modify it:
+
+```ts
+import { resetLocalStorage } from '../utils/reset-local-storage.js'
+
+await resetLocalStorage(page, ref)
+```
+
## Debugging
### View trace
diff --git a/.claude/skills/studio-queries/SKILL.md b/.claude/skills/studio-queries/SKILL.md
new file mode 100644
index 00000000000..adf4e2e2b78
--- /dev/null
+++ b/.claude/skills/studio-queries/SKILL.md
@@ -0,0 +1,148 @@
+---
+name: studio-queries
+description: React Query conventions for data fetching in Supabase Studio. Use when
+ writing or reviewing query hooks, mutation hooks, or query keys in apps/studio/data/.
+ Covers queryOptions pattern, keys.ts structure, mutation hook template, and imperative
+ fetching.
+---
+
+# Studio Queries & Mutations (React Query)
+
+Follow the patterns in `apps/studio/data/`. Reference examples:
+
+- Query options: `apps/studio/data/table-editor/table-editor-query.ts`
+- Mutation hook: `apps/studio/data/edge-functions/edge-functions-update-mutation.ts`
+- Keys: `apps/studio/data/edge-functions/keys.ts`
+
+## Query Keys
+
+Define a `keys.ts` per domain. Export `*Keys` helpers using array keys with `as const`. Never inline query keys in components.
+
+```ts
+export const edgeFunctionsKeys = {
+ list: (projectRef: string | undefined) => ['projects', projectRef, 'edge-functions'] as const,
+ detail: (projectRef: string | undefined, slug: string | undefined) =>
+ ['projects', projectRef, 'edge-function', slug, 'detail'] as const,
+}
+```
+
+## Query Options (preferred pattern)
+
+Use `queryOptions` from `@tanstack/react-query`. This gives type safety and works with both `useQuery()` and `queryClient.fetchQuery()`.
+
+Rules:
+
+- Export `XVariables`, `XData`, and `XError` types (prefixed with the domain name)
+- Implement a **private** `getX(variables, signal?)` function:
+ - Throws if required variables are missing
+ - Passes `signal` for cancellation
+ - Calls `handleError(error)` on failure (which throws); returns `data` on success
+ - Not exported — use `queryClient.fetchQuery(xQueryOptions(...))` for imperative fetching
+- Export `xQueryOptions()` using `queryOptions`
+- Gate with `enabled` so the query doesn't run until required variables exist
+- Platform-only queries: include `IS_PLATFORM` from `lib/constants` in `enabled`
+- Don't add extra params to `xQueryOptions` — callers override by destructuring: `{ ...xQueryOptions(vars), enabled: true }`
+
+```ts
+import { queryOptions } from '@tanstack/react-query'
+
+import { xKeys } from './keys'
+import { get, handleError } from '@/data/fetchers'
+import { IS_PLATFORM } from '@/lib/constants'
+import { ResponseError } from '@/types'
+
+export type XVariables = { projectRef?: string }
+export type XError = ResponseError
+
+async function getX({ projectRef }: XVariables, signal?: AbortSignal) {
+ if (!projectRef) throw new Error('projectRef is required')
+ const { data, error } = await get('/v1/projects/{ref}/x', {
+ params: { path: { ref: projectRef } },
+ signal,
+ })
+ if (error) handleError(error)
+ return data
+}
+
+export type XData = Awaited>
+
+export const xQueryOptions = ({ projectRef }: XVariables) =>
+ queryOptions({
+ queryKey: xKeys.list(projectRef),
+ queryFn: ({ signal }) => getX({ projectRef }, signal),
+ enabled: IS_PLATFORM && typeof projectRef !== 'undefined',
+ })
+```
+
+## Using Query Options in Components
+
+```ts
+import { useQuery } from '@tanstack/react-query'
+
+import { xQueryOptions } from '@/data/x/x-query'
+
+const { data, isPending, isError } = useQuery(xQueryOptions({ projectRef: project?.ref }))
+```
+
+## Imperative Fetching (outside React or in callbacks)
+
+```ts
+const queryClient = useQueryClient()
+const { data: project } = useSelectedProjectQuery()
+
+const handleClick = useCallback(
+ async (id: number) => {
+ const data = await queryClient.fetchQuery(xQueryOptions({ id, projectRef: project?.ref }))
+ // use data...
+ },
+ [project?.ref, queryClient]
+)
+```
+
+## Mutation Hook
+
+- Export a `Variables` type with `projectRef`, identifiers, and `payload`
+- Implement a private `updateX(vars)` function with required variable validation and `handleError`
+- Wrap in `useXMutation()`:
+ - Accepts `UseMutationOptions` (omit `mutationFn`)
+ - Invalidates `list()` + `detail()` keys in `onSuccess` with `await Promise.all([...])`
+ - Defaults to `toast.error(...)` when `onError` isn't provided
+
+```ts
+import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
+import toast from 'react-hot-toast'
+
+import { xKeys } from './keys'
+
+type XUpdateVariables = { projectRef: string; slug: string; payload: XPayload }
+
+export const useXUpdateMutation = ({
+ onSuccess,
+ onError,
+ ...options
+}: UseMutationOptions = {}) => {
+ const queryClient = useQueryClient()
+ return useMutation({
+ mutationFn: updateX,
+ async onSuccess(data, variables, context) {
+ await Promise.all([
+ queryClient.invalidateQueries({
+ queryKey: xKeys.detail(variables.projectRef, variables.slug),
+ }),
+ queryClient.invalidateQueries({ queryKey: xKeys.list(variables.projectRef) }),
+ ])
+ await onSuccess?.(data, variables, context)
+ },
+ async onError(error, variables, context) {
+ if (onError === undefined) toast.error(`Failed to update: ${error.message}`)
+ else onError(error, variables, context)
+ },
+ ...options,
+ })
+}
+```
+
+## Component Usage
+
+- Use React Query v5 flags: `isPending` for initial load, `isFetching` for background refetches
+- Render states explicitly in order: pending → error → success
diff --git a/.claude/skills/studio-testing/SKILL.md b/.claude/skills/studio-testing/SKILL.md
index 3e7d16b8f5a..85e23d80969 100644
--- a/.claude/skills/studio-testing/SKILL.md
+++ b/.claude/skills/studio-testing/SKILL.md
@@ -68,36 +68,108 @@ Is the logic a pure transformation (parse, format, validate, compute)?
NO -> Write a component test
```
-## How to Use
+## 1. Extract Logic Into Utility Files (CRITICAL)
-Read individual rule files for detailed explanations and code examples:
+Remove as much logic from components as possible. Put it in co-located
+`.utils.ts` files as pure functions: arguments in, return value out.
-```
-rules/testing-extract-logic.md
-rules/testing-exhaustive-permutations.md
+**File naming:**
+
+- Utility: `ComponentName.utils.ts` next to the component
+- Test: `tests/components/.../ComponentName.utils.test.ts` mirroring the source path
+
+```tsx
+// ❌ Logic buried in component — hard to test without rendering
+function TaxIdForm({ taxIdValue, taxIdName }: Props) {
+ const handleSubmit = () => {
+ const taxId = TAX_IDS.find((t) => t.name === taxIdName)
+ let sanitized = taxIdValue
+ if (taxId?.vatPrefix && !taxIdValue.startsWith(taxId.vatPrefix)) {
+ sanitized = taxId.vatPrefix + taxIdValue
+ }
+ submitToApi(sanitized)
+ }
+ return
+}
+
+// ✅ Logic extracted to .utils.ts — trivially testable
+// TaxID.utils.ts
+export function sanitizeTaxIdValue({ value, name }: { value: string; name: string }): string {
+ const taxId = TAX_IDS.find((t) => t.name === name)
+ if (taxId?.vatPrefix && !value.startsWith(taxId.vatPrefix)) {
+ return taxId.vatPrefix + value
+ }
+ return value
+}
+
+// TaxIdForm.tsx — thin shell
+const handleSubmit = () => {
+ const sanitized = sanitizeTaxIdValue({ value: taxIdValue, name: taxIdName })
+ submitToApi(sanitized)
+}
```
-Each rule file contains:
+## 2. Test Every Permutation (CRITICAL)
-- Brief explanation of why it matters
-- Incorrect code example with explanation
-- Correct code example with explanation
-- Real codebase references
+Once logic is extracted, test exhaustively. Every code path needs a test:
-## Full Compiled Document
+- Valid inputs (happy path for each branch)
+- Invalid / malformed inputs
+- Empty values, null values, missing fields
+- Edge cases (timestamps with colons, special characters, boundary values)
-For the complete guide with all rules expanded: `AGENTS.md`
+```ts
+// ❌ Only happy path
+test('parses a filter', () => {
+ expect(formatFilterURLParams('id:gte:20')).toStrictEqual({ column: 'id', operator: 'gte', value: '20' })
+})
+
+// ✅ Every permutation
+test('parses valid filter', () => { ... })
+test('handles timestamp with colons in value', () => { ... })
+test('rejects malformed filter with missing parts', () => { ... })
+test('rejects unrecognized operator', () => { ... })
+test('allows empty filter value', () => { ... })
+```
+
+## 3. Component Tests for Complex UI Only (HIGH)
+
+Only write component tests when there is complex UI interaction logic that
+cannot be captured by testing utility functions alone.
+
+**Valid reasons:** conditional rendering from user interaction sequences,
+popover open/close with keyboard/mouse, multi-step form transitions.
+
+**Not valid:** testing a calculation or transformation that happens to live
+in a component — extract to `.utils.ts` and unit test instead.
+
+```tsx
+// Studio component test conventions
+import { fireEvent } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { customRender } from 'tests/lib/custom-render' // always use customRender, not raw render
+import { addAPIMock } from 'tests/lib/msw' // API mocking in beforeEach
+```
+
+## 4. E2E Tests for Shared Features (HIGH)
+
+If a feature exists in both self-hosted and platform, create an E2E test.
+Cover mouse clicks AND keyboard shortcuts (Tab, Enter, Escape, Arrow keys).
+
+Extract reusable interactions into `e2e/studio/utils/*-helpers.ts`. Use
+try/finally for resource cleanup. For E2E execution details, see the
+`studio-e2e-tests` skill.
## Codebase References
-| What | Where |
-| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
-| Util test examples | `tests/components/Grid/Grid.utils.test.ts`, `tests/components/Billing/TaxID.utils.test.ts`, `tests/components/Editor/SpreadsheetImport.utils.test.ts` |
-| Component test examples | `tests/features/logs/LogsFilterPopover.test.tsx`, `tests/components/CopyButton.test.tsx` |
-| E2E test example | `e2e/studio/features/filter-bar.spec.ts` |
-| E2E helpers pattern | `e2e/studio/utils/filter-bar-helpers.ts` |
-| Custom render | `tests/lib/custom-render.tsx` |
-| MSW mock setup | `tests/lib/msw.ts` (`addAPIMock`) |
-| Test README | `tests/README.md` |
-| Vitest config | `vitest.config.ts` |
-| Related skills | `e2e-studio-tests` (running E2E), `vitest` (API reference), `vercel-composition-patterns` (component architecture) |
+| What | Where |
+| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Util test examples | `apps/studio/tests/components/Grid/Grid.utils.test.ts`, `apps/studio/tests/components/Billing/TaxID.utils.test.ts`, `apps/studio/tests/components/Editor/SpreadsheetImport.utils.test.ts` |
+| Component test examples | `apps/studio/tests/features/logs/LogsFilterPopover.test.tsx`, `apps/studio/tests/components/CopyButton.test.tsx` |
+| E2E test example | `e2e/studio/features/filter-bar.spec.ts` |
+| E2E helpers pattern | `e2e/studio/utils/filter-bar-helpers.ts` |
+| Custom render | `apps/studio/tests/lib/custom-render.tsx` |
+| MSW mock setup | `apps/studio/tests/lib/msw.ts` (`addAPIMock`) |
+| Test README | `apps/studio/tests/README.md` |
+| Vitest config | `apps/studio/vitest.config.ts` |
+| Related skills | `studio-e2e-tests` (running E2E), `vitest` (API reference), `vercel-composition-patterns` (component architecture) |
diff --git a/.claude/skills/studio-testing/rules/testing-component-tests-ui-only.md b/.claude/skills/studio-testing/rules/testing-component-tests-ui-only.md
deleted file mode 100644
index 4ba7b3e6cb6..00000000000
--- a/.claude/skills/studio-testing/rules/testing-component-tests-ui-only.md
+++ /dev/null
@@ -1,86 +0,0 @@
----
-title: Component Tests Are for Complex UI Logic Only
-impact: HIGH
-impactDescription: prevents slow, brittle tests that should be unit tests
-tags: testing, components, ui, react
----
-
-## Component Tests Are for Complex UI Logic Only
-
-Only write component tests (`.test.tsx`) when there is complex UI interaction
-logic that cannot be captured by testing utility functions alone.
-
-**Valid reasons for a component test:**
-
-- Conditional rendering based on user interaction sequences
-- Popover/dropdown open/close behavior with keyboard and mouse
-- Form state transitions across multiple steps
-- Components that coordinate multiple async operations visually
-
-**Not a valid reason:** testing a calculation, transformation, parsing, or
-validation that happens to live inside a component. Extract that logic into a
-`.utils.ts` file and unit test it instead.
-
-**Incorrect (rendering a component just to test logic):**
-
-```tsx
-test('formats the display value correctly', () => {
- render()
- expect(screen.getByText('$12.34')).toBeInTheDocument()
-})
-```
-
-This is really testing a formatting function. Extract it:
-
-```ts
-// PriceDisplay.utils.ts
-export function formatPrice(amount: number, currency: string): string { ... }
-
-// PriceDisplay.utils.test.ts
-test('formats USD cents to dollars', () => {
- expect(formatPrice(1234, 'USD')).toBe('$12.34')
-})
-```
-
-**Correct (component test for real UI interaction logic):**
-
-```tsx
-// Testing popover open/close, filter application, keyboard dismiss
-describe('LogsFilterPopover', () => {
- test('opens popover and shows filter options', async () => {
- customRender()
- await userEvent.click(screen.getByRole('button'))
- expect(screen.getByText('Apply')).toBeVisible()
- })
-
- test('applies selected filters on submit', async () => {
- const onChange = vi.fn()
- customRender()
- // ... interact with UI ...
- await userEvent.click(screen.getByText('Apply'))
- expect(onChange).toHaveBeenCalledWith(expectedFilters)
- })
-
- test('closes on Escape key', async () => {
- customRender()
- await userEvent.click(screen.getByRole('button'))
- await userEvent.keyboard('{Escape}')
- expect(screen.queryByText('Apply')).not.toBeInTheDocument()
- })
-})
-```
-
-**Studio component test conventions:**
-
-```tsx
-// Always use customRender, not raw render
-import { fireEvent } from '@testing-library/react'
-// Use userEvent for popovers, fireEvent for dropdowns
-import userEvent from '@testing-library/user-event'
-import { customRender } from 'tests/lib/custom-render'
-// Use addAPIMock for API mocking in beforeEach
-import { addAPIMock } from 'tests/lib/msw'
-```
-
-See `tests/README.md` for full conventions on custom render, MSW mocking,
-and nuqs URL parameter testing.
diff --git a/.claude/skills/studio-testing/rules/testing-e2e-shared-features.md b/.claude/skills/studio-testing/rules/testing-e2e-shared-features.md
deleted file mode 100644
index 31945a4533f..00000000000
--- a/.claude/skills/studio-testing/rules/testing-e2e-shared-features.md
+++ /dev/null
@@ -1,98 +0,0 @@
----
-title: E2E Tests for Self-Hosted and Platform Features
-impact: HIGH
-impactDescription: ensures critical shared features work across deployment targets
-tags: testing, e2e, playwright, self-hosted, platform
----
-
-## E2E Tests for Self-Hosted and Platform Features
-
-If a feature exists in both self-hosted and the Supabase platform, create an
-E2E test to cover it. E2E tests live in `e2e/studio/features/*.spec.ts`.
-
-**What to cover in E2E tests:**
-
-- Mouse/click interactions AND keyboard shortcuts (Tab, Enter, Escape, Arrow keys)
-- Full user flows end-to-end
-- Both adding and removing/clearing state
-- Setup and teardown (create resources in `try`, clean up in `finally`)
-
-**Incorrect (only tests mouse clicks):**
-
-```ts
-test('can add a filter', async ({ page }) => {
- await page.getByRole('button', { name: 'Add filter' }).click()
- await page.getByRole('option', { name: 'id' }).click()
- // ... only click-based interactions
-})
-```
-
-**Correct (covers clicks AND keyboard shortcuts):**
-
-```ts
-test.describe('Basic Filter Operations', () => {
- test('can add a filter by clicking', async ({ page }) => {
- await addFilter(page, ref, 'id', 'equals', '1')
- await expect(page.getByTestId('filter-condition')).toBeVisible()
- })
-})
-
-test.describe('Keyboard Navigation - Freeform Input', () => {
- test('Enter selects column from suggestions', async ({ page }) => {
- await getFilterBarInput(page).press('Enter')
- await expect(page.getByTestId('operator-input')).toBeFocused()
- })
-
- test('Backspace on empty input highlights last condition', async ({ page }) => {
- await addFilter(page, ref, 'id', 'equals', '1')
- await getFilterBarInput(page).press('Backspace')
- await expect(page.getByTestId('filter-condition')).toHaveAttribute('data-highlighted', 'true')
- })
-
- test('Escape clears highlight', async ({ page }) => {
- // ...
- await getFilterBarInput(page).press('Escape')
- await expect(page.getByTestId('filter-condition')).toHaveAttribute('data-highlighted', 'false')
- })
-})
-```
-
-**E2E helper pattern:** Extract reusable interactions into helper files at
-`e2e/studio/utils/*-helpers.ts`:
-
-```ts
-// e2e/studio/utils/filter-bar-helpers.ts
-export async function addFilter(page, ref, column, operator, value) {
- await selectColumnFilter(page, column)
- await selectOperator(page, column, operator)
- // ... fill value, wait for API response
-}
-
-export async function setupFilterBarPage(page, ref, editorUrl) {
- await page.goto(editorUrl)
- await enableFilterBar(page)
- await page.reload()
-}
-```
-
-This keeps spec files focused on assertions while helpers handle the
-interaction mechanics.
-
-**Always use try/finally for resource cleanup:**
-
-```ts
-test('filters the table', async ({ page, ref }) => {
- const tableName = await createTable(page, ref)
- try {
- await setupFilterBarPage(page, ref, editorUrl)
- await navigateToTable(page, ref, tableName)
- await addFilter(page, ref, 'id', 'equals', '1')
- // assertions...
- } finally {
- await dropTable(page, ref, tableName)
- }
-})
-```
-
-For E2E execution details (running tests, selectors, debugging), use the
-`e2e-studio-tests` skill.
diff --git a/.claude/skills/studio-testing/rules/testing-exhaustive-permutations.md b/.claude/skills/studio-testing/rules/testing-exhaustive-permutations.md
deleted file mode 100644
index afa808d7ad7..00000000000
--- a/.claude/skills/studio-testing/rules/testing-exhaustive-permutations.md
+++ /dev/null
@@ -1,84 +0,0 @@
----
-title: Test Every Permutation of Utility Functions
-impact: CRITICAL
-impactDescription: catches edge cases and regressions in business logic
-tags: testing, utils, coverage, permutations
----
-
-## Test Every Permutation of Utility Functions
-
-Once logic is extracted into a pure function, test it exhaustively. Every code
-path should have a test. Don't just test the happy path.
-
-**What to cover:**
-
-- Valid inputs (happy path for each branch)
-- Invalid / malformed inputs
-- Empty values, null values, missing fields
-- Edge cases (timestamps with colons, special characters, boundary values)
-- Security-sensitive inputs (XSS payloads, external URLs) where relevant
-
-**Incorrect (only tests the happy path):**
-
-```ts
-describe('formatFilterURLParams', () => {
- test('parses a filter', () => {
- const result = formatFilterURLParams('id:gte:20')
- expect(result).toStrictEqual({ column: 'id', operator: 'gte', value: '20' })
- })
-})
-```
-
-**Correct (tests every permutation):**
-
-```ts
-describe('formatFilterURLParams', () => {
- test('parses valid filter', () => {
- const result = formatFilterURLParams('id:gte:20')
- expect(result).toStrictEqual({ column: 'id', operator: 'gte', value: '20' })
- })
-
- test('handles timestamp with colons in value', () => {
- const result = formatFilterURLParams('created:gte:2024-01-01T00:00:00')
- expect(result).toStrictEqual({
- column: 'created',
- operator: 'gte',
- value: '2024-01-01T00:00:00',
- })
- })
-
- test('rejects malformed filter with missing parts', () => {
- const result = formatFilterURLParams('id')
- expect(result).toBeUndefined()
- })
-
- test('rejects unrecognized operator', () => {
- const result = formatFilterURLParams('id:nope:20')
- expect(result).toBeUndefined()
- })
-
- test('allows empty filter value', () => {
- const result = formatFilterURLParams('name:eq:')
- expect(result).toStrictEqual({ column: 'name', operator: 'eq', value: '' })
- })
-})
-```
-
-**Another real example -- `inferColumnType` tests every data type:**
-
-```ts
-describe('inferColumnType', () => {
- test('defaults to text for empty data', () => { ... })
- test('defaults to text for missing column', () => { ... })
- test('defaults to text for null values', () => { ... })
- test('detects integer', () => { ... }) // "42" -> int8
- test('detects float', () => { ... }) // "161.72" -> float8
- test('detects boolean', () => { ... }) // "true"/"false" -> bool
- test('detects boolean with nulls', () => { ... })
- test('detects JSON object', () => { ... }) // "{}" -> jsonb
- test('detects timestamp', () => { ... }) // multiple formats -> timestamptz
-})
-```
-
-The goal: if someone changes the function, at least one test should break for
-any behavioral change.
diff --git a/.claude/skills/studio-testing/rules/testing-extract-logic.md b/.claude/skills/studio-testing/rules/testing-extract-logic.md
deleted file mode 100644
index 6c7d087fb2b..00000000000
--- a/.claude/skills/studio-testing/rules/testing-extract-logic.md
+++ /dev/null
@@ -1,94 +0,0 @@
----
-title: Extract Logic Into Utility Files
-impact: CRITICAL
-impactDescription: makes business logic trivially testable without rendering components
-tags: testing, utils, extraction, pure-functions
----
-
-## Extract Logic Into Utility Files
-
-Remove as much logic from components as possible. Put it in co-located
-`.utils.ts` files as pure functions: arguments in, return value out. No React
-hooks, no context, no side effects.
-
-**File naming convention:**
-
-- Utility file: `ComponentName.utils.ts` next to the component
-- Test file: `tests/components/.../ComponentName.utils.test.ts` mirroring the source path
-- Or under `tests/unit/` for non-component utilities
-
-**Incorrect (logic buried inside a component):**
-
-```tsx
-// components/Billing/TaxIdForm.tsx
-function TaxIdForm({ taxIdValue, taxIdName }: Props) {
- const handleSubmit = () => {
- // Logic buried in the component -- hard to test without rendering
- const taxId = TAX_IDS.find((t) => t.name === taxIdName)
- let sanitized = taxIdValue
- if (taxId?.vatPrefix && !taxIdValue.startsWith(taxId.vatPrefix)) {
- sanitized = taxId.vatPrefix + taxIdValue
- }
- submitToApi(sanitized)
- }
-
- return
-}
-```
-
-**Correct (logic extracted to a utility file):**
-
-```ts
-// components/Billing/TaxID.utils.ts
-import { TAX_IDS } from './TaxID.constants'
-
-// Pure function: args in, return out
-export function sanitizeTaxIdValue({ value, name }: { value: string; name: string }): string {
- const taxId = TAX_IDS.find((t) => t.name === name)
- if (taxId?.vatPrefix && !value.startsWith(taxId.vatPrefix)) {
- return taxId.vatPrefix + value
- }
- return value
-}
-```
-
-```tsx
-// components/Billing/TaxIdForm.tsx
-import { sanitizeTaxIdValue } from './TaxID.utils'
-
-function TaxIdForm({ taxIdValue, taxIdName }: Props) {
- const handleSubmit = () => {
- const sanitized = sanitizeTaxIdValue({ value: taxIdValue, name: taxIdName })
- submitToApi(sanitized)
- }
- return
-}
-```
-
-```ts
-// tests/components/Billing/TaxID.utils.test.ts
-import { sanitizeTaxIdValue } from 'components/.../TaxID.utils'
-
-describe('sanitizeTaxIdValue', () => {
- test('prefixes unprefixed EU tax ID', () => {
- expect(sanitizeTaxIdValue({ value: '12345678', name: 'AT VAT' })).toBe('ATU12345678')
- })
-
- test('passes through already-prefixed EU tax ID', () => {
- expect(sanitizeTaxIdValue({ value: 'ATU12345678', name: 'AT VAT' })).toBe('ATU12345678')
- })
-
- test('passes through non-EU tax ID unchanged', () => {
- expect(sanitizeTaxIdValue({ value: '12-3456789', name: 'US EIN' })).toBe('12-3456789')
- })
-})
-```
-
-The component becomes a thin shell that calls the utility. All business logic
-is testable without rendering anything.
-
-**Real codebase examples:**
-
-- `components/grid/SupabaseGrid.utils.ts` -- URL param parsing, used by 15+ components
-- `components/.../SpreadsheetImport/SpreadsheetImport.utils.tsx` -- CSV parsing, column type inference
-- `components/.../BillingCustomerData/TaxID.utils.ts` -- tax ID sanitization and comparison
diff --git a/.claude/skills/studio-ui-patterns/SKILL.md b/.claude/skills/studio-ui-patterns/SKILL.md
new file mode 100644
index 00000000000..85acab8a146
--- /dev/null
+++ b/.claude/skills/studio-ui-patterns/SKILL.md
@@ -0,0 +1,127 @@
+---
+name: studio-ui-patterns
+description: Design system UI patterns for Supabase Studio. Use when building or updating
+ pages, forms, tables, charts, empty states, navigation, cards, alerts, or side panels
+ (sheets). Covers layout selection, component choice, and placement conventions.
+---
+
+# Studio UI Patterns
+
+The Design System docs and demos are the source of truth. Always check the relevant
+demo file before composing new UI.
+
+## Layout
+
+Docs: `apps/design-system/content/docs/ui-patterns/layout.mdx`
+
+Build pages with `PageContainer`, `PageHeader`, and `PageSection`.
+
+| Content type | `size` |
+| ----------------- | ----------- |
+| Settings / config | `"default"` |
+| Lists / tables | `"large"` |
+| Full-screen views | `"full"` |
+
+- If filters/search exist on a list page, align table actions with the filters (don't use `PageHeaderAside`/`PageSectionAside` for those actions)
+- If no filters, actions can go in `PageHeaderAside` or `PageSectionAside`
+
+Demos: `page-layout-settings.tsx`, `page-layout-list.tsx`, `page-layout-list-simple.tsx`, `page-layout-detail.tsx`
+(all in `apps/design-system/registry/default/example/`)
+
+## Forms
+
+Docs: `apps/design-system/content/docs/ui-patterns/forms.mdx`
+
+- Use `react-hook-form` + `zod`
+- Use `FormItemLayout` instead of manually composing `FormItem`/`FormLabel`/`FormMessage`/`FormDescription`
+- Wrap inputs with `FormControl_Shadcn_`; use `_Shadcn_` imports from `ui` for primitives
+
+Layout selection:
+
+| Context | Layout | Container |
+| ------------------------------------------ | ------------------------------------------ | ---------------------------------------------------------- |
+| Page (settings/config) | `FormItemLayout layout="flex-row-reverse"` | `Card` (`CardContent` per field; `CardFooter` for actions) |
+| Side panel — wide | `FormItemLayout layout="horizontal"` | `SheetSection` |
+| Side panel — narrow (`size="sm"` or below) | `FormItemLayout layout="vertical"` | `SheetSection` |
+
+Dirty state / submit:
+
+- Destructure `isDirty` from `form.formState` to show Cancel and disable Save
+- Show loading on submit button via `loading` prop
+- If submit button is outside `