# AGENTS.md - AI Agent Guidelines for Sanity Monorepo This document helps AI agents work successfully with the Sanity monorepo. > **Self-Improvement:** If you discover undocumented requirements, commands, or workflows during your work (e.g., a reviewer asks you to run something not covered here), update this file on the same PR. Keep this guide accurate and helpful for future agents. ## Prerequisites - **Node.js**: v24 or latest LTS. Published packages must declare `"engines": { "node": ">=22.12" }` (`pnpm normalize-pkgfields` keeps this in sync; publint warns if the field is missing). - **Package Manager**: pnpm v10+ (exact version managed via `packageManager` field in package.json) ## Quick Reference ```bash # Install dependencies (pnpm ONLY - enforced) pnpm install # Build all packages (required before testing) pnpm build # Run dev studio (requires auth, see below) pnpm dev # Format code (MUST pass CI) pnpm chore:format:fix # Fix all lint issues (MUST pass CI) — includes TypeScript type checking via oxlint pnpm lint:fix # Run tests pnpm test # Update snapshots if tests fail due to expected changes pnpm test -- -u # Lint + type check (oxlint typeAware + typeCheck; no separate tsc step) pnpm check:oxlint ``` ## CI Checks - What Must Pass These checks run on every PR and **must pass**: | Check | Command | Notes | | ---------------- | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Format** | `pnpm check:format` | Uses oxfmt. Fix with `pnpm chore:format:fix` | | **Oxlint** | `pnpm check:oxlint` | Rust linter with type-aware rules and TypeScript type checking via tsgolint (`options.typeCheck`). Fix with `pnpm chore:oxlint:fix` | | **Unit Tests** | `pnpm test` | Vitest, sharded in CI | | **Export Tests** | `pnpm test:exports` | Ensures ESM/CJS/DTS work | | **Dep Check** | `pnpm depcheck` | Finds unused/missing deps | | **Zizmor** | `pnpm lint:workflows` | Audits `.github/workflows/` for security issues. Fails CI on high-severity findings. Local run needs [`zizmor`](https://docs.zizmor.sh/installation/) on `PATH`. | | **PR Title** | Conventional commits | e.g., `feat(scope): description` | ### Before Committing Run these commands to avoid CI failures: ```bash # Fix all formatting and lint issues pnpm lint:fix # Verify tests pass (build first if needed) pnpm build && pnpm test ``` If tests fail due to **expected snapshot changes**, update them: ```bash pnpm test -- -u ``` Snapshot files are located in `__snapshots__` directories alongside test files. ## Project Structure ``` sanity/ ├── packages/ │ ├── sanity/ # Main Sanity studio package │ ├── @sanity/ # Scoped packages (cli, types, schema, etc.) │ └── @repo/ # Internal tooling (test-config, tsconfig, etc.) ├── dev/ # Development studios for testing │ ├── test-studio/ # Primary dev studio (pnpm dev runs this) │ ├── studio-diagnostics-viewer/ # Standalone viewer for pasted diagnostics JSON │ └── preview-iframe/ # Presentation preview iframe (vanilla Vite, port 3334) ├── e2e/ # End-to-end Playwright tests ├── perf/ # Performance testing └── examples/ # Example studios ``` ### Key Packages - **`packages/sanity`** - Core studio package with all UI components - **`packages/@sanity/types`** - TypeScript type definitions - **`packages/@sanity/schema`** - Schema compilation - **`packages/@sanity/mutator`** - Document mutation logic ## Build System - **Package Manager**: pnpm (version 10.x, enforced via `preinstall`) - **Build Orchestration**: Turbo (caches builds) - **Versioning**: Lerna-lite with conventional commits ### Build Commands ```bash pnpm build # Build all packages pnpm watch # Watch mode for development ``` ### Running the Dev Studio ```bash pnpm dev # Starts dev studio at http://localhost:3333 ``` **Note:** The dev studio requires Sanity user authentication in the browser. It's a Vite application that communicates with Sanity API endpoints, so you'll need to log in with a Sanity account when you access `http://localhost:3333` to use the studio. ## Local Development This section clarifies what requires authentication and what doesn't—critical for AI agents to avoid getting stuck on auth flows. ### Running Tests (No Auth Required) Unit tests run in jsdom with mocks and **do not require any authentication**: ```bash # Build first (required), then run all tests pnpm build && pnpm test # Run a single test file (IMPORTANT: use vitest directly with --project to avoid running all tests) pnpm vitest run --project=sanity packages/sanity/src/core/hooks/useClient.test.ts # Run a single test file with verbose output pnpm vitest run --project=sanity --reporter=verbose packages/sanity/src/core/hooks/useClient.test.ts # Watch mode for iterative development pnpm test -- --watch # Run tests for a specific package pnpm test -- --project=sanity ``` **Important:** Do NOT use `pnpm test -- path/to/file.test.ts` for running a single file — it runs all tests across all projects. Use `pnpm vitest run --project= ` instead. The headless validation package is registered as `@sanity/validation`: run its full suite with `pnpm vitest run --project=@sanity/validation`. When testing inherited validation, cover Studio's compilation path too: call `inferFromSchema` on the built-in schema before compiling custom types, then call it on the compiled custom schema. Studio inherits already-normalized built-in rules. Components that need auth context use `createMockAuthStore` in tests, so no real authentication is needed. This is the recommended way to verify most code changes. ### Running the Dev Studio (Auth Required) ```bash pnpm dev # Starts test-studio at http://localhost:3333 and preview-iframe at http://localhost:3334 ``` - **Requires browser authentication** on first visit—you'll be prompted to log in with a Sanity account - Connects to a real Sanity project (configured in `dev/test-studio/sanity.config.ts`) - Uses staging API by default (`api.sanity.work`) - Session persists in browser, so subsequent visits won't require re-authentication - `pnpm dev` / `pnpm dev:test-studio` also starts `dev/preview-iframe` (vanilla Vite on port 3334) so Presentation can load its cross-origin iframe. Studio-only: `pnpm dev:test-studio:studio`. Preview-only: `pnpm dev:preview-iframe`. - Deployed preview iframe: Sanity Sandbox Vercel project `test-studio-preview-iframe` (`https://test-studio-preview-iframe.sanity.dev`) Use the dev studio when you need to: - Visually verify UI changes - Test real document editing workflows - Debug issues that only appear with real data - Exercise Presentation / visual editing against the local preview iframe ### Inspecting Production Builds with Vite DevTools The test studio can run with [Vite DevTools](https://devtools.vite.dev) enabled, which lets you inspect the output of `sanity build` runs (module graph, chunks, plugin timings, bundle treemaps, session diffing) from inside a long-running `sanity dev` server—no restart needed. ```bash # Builds the test studio with devtools enabled, then starts the dev server # (so there's a build session to inspect right away) pnpm devtools:test-studio ``` Open `http://localhost:3333` and use the Vite DevTools dock to explore the recorded Rolldown build session. See the [DevTools for Rolldown features guide](https://devtools.vite.dev/rolldown/features.html) for how to use the module graph, chunk, asset, and plugin panels. To inspect a **new** build after making changes—while `pnpm devtools:test-studio` is still running—run in a second terminal: ```bash # Creates a fresh build session that shows up in the running DevTools dock pnpm devtools:test-studio:build ``` Sessions can be compared against each other in the DevTools UI to diff bundle changes. How it works: - Both commands set `ENABLE_VITE_DEVTOOLS=true`, which makes `dev/test-studio/sanity.cli.ts` add the `DevTools()` Vite plugin and enable `build.rolldownOptions.devtools` - Build sessions are written to `dev/test-studio/node_modules/.rolldown` (gitignored) - The flag is declared in `dev/test-studio/turbo.json` so turbo-cached builds are invalidated when it changes - Enabling devtools makes `sanity build` noticeably slower; that's why it's opt-in via the env flag ### Debugging presence with fake users **Field action (test studio).** Open a `Presence debug` document (`presenceDebug`, under Debug in the structure). Every field has a `Presence` button in its header with **Fake presence here**: pick a user in the dialog and they show up as present at that field. Inside a Portable Text field the user is placed at your last cursor position in that editor, so different users can be put at different spots. **Remove all fake presence** clears them. The plugin lives in `dev/test-studio/plugins/presence-debug` and uses the presence store's `@internal` `debug` API (`fakePresence`, `removeFakePresence`, `ownLocation$`), so the fake sessions go through the real presence code path (user lookup, form routing, overlay docking). **URL hash flags.** The presence store also reads two older debug flags from the URL hash (`packages/sanity/src/core/store/presence/presence-store.ts`), comma separated after `#_debug_presence=`: `fake_others` (random mock users at fixed paths in a document with `_id: "presence-debug"`) and `show_own` (also show your own session). ### Profiling studio re-renders with React DevTools (agent-react-devtools) The test studio can register with a local [agent-react-devtools](https://github.com/callstackincubator/agent-react-devtools) daemon, which exposes the React component tree and render profiling over a CLI — made for AI agents to inspect props/state/hooks and hunt unnecessary re-renders. The `react-devtools` skill (`.agents/skills/react-devtools/SKILL.md`) documents the CLI; read it before profiling. For `useObservable`, `useSyncObservable` and `useObservablePromise` call sites whose observable identity churns, the `react-rx-hook-audit` skill (`.agents/skills/react-rx-hook-audit/SKILL.md`) covers finding them, counting renders and subscriptions at runtime, and the `useValuePreview` refactor and test pattern. ```bash # 1. Start the daemon (port 8097) pnpm --filter sanity-test-studio exec agent-react-devtools start # 2. Start the studio with the connect script injected (implies ENABLE_REACT_DEVTOOLS=true) pnpm react-devtools:test-studio # 3. Open http://localhost:3333 in a browser, then verify the app registered pnpm --filter sanity-test-studio exec agent-react-devtools status # 4. Profile an interaction pnpm --filter sanity-test-studio exec agent-react-devtools profile start # ... interact with the studio ... pnpm --filter sanity-test-studio exec agent-react-devtools profile stop pnpm --filter sanity-test-studio exec agent-react-devtools profile rerenders --limit 10 ``` How it works and gotchas: - `ENABLE_REACT_DEVTOOLS=true` makes `dev/test-studio/sanity.cli.ts` add the `reactDevtools()` Vite plugin, which injects an `agent-react-devtools/connect` script that wires `react-devtools-core` to the daemon's WebSocket. Dev-server only (`apply: 'serve'`) — it can never reach `sanity build` output. - **The browser must be headed.** Headless Chromium does not execute the injected module script properly, so the app never registers with the daemon. On the Cloud VM, drive system Chrome via Playwright with `headless: false` (a display is available), and authenticate by appending `#token=$STUDIO_AUTH_TOKEN` to the URL from inside the script (keeps the secret out of logs). - Profile with StrictMode off (`SANITY_STUDIO_REACT_STRICT_MODE=false`) so dev double-rendering doesn't inflate durations — production studios don't run StrictMode. - Do not run `pnpm check:oxlint` while the dev studio and daemon are running (same memory constraint as noted in the Cursor Cloud gotchas). ### Analyzing the `sanity` package bundle The `sanity` package tsdown build can emit a Rolldown [bundle analyzer](https://rolldown.rs/builtin-plugins/bundle-analyzer) markdown report (module/chunk breakdown for humans and coding agents) when `ENABLE_BUNDLE_ANALYZER=true`: ```bash pnpm analyze:sanity ``` The report is written to `packages/sanity/lib/analyze-data.md` (gitignored with `lib/`). The flag is opt-in because analysis adds work to the package build; it is declared in `packages/sanity/turbo.json` so turbo-cached builds are invalidated when it changes. Wiring is `@sanity/tsdown-config`'s `bundleAnalyzer` option (`true` selects markdown). ### Auto-updating studio CSS (Lightning CSS `light-dark()`) The CDN / auto-update bundle Vite config lives in `@repo/package.bundle` (`createDefaultConfig`). Vite 8 minifies that CSS with Lightning CSS against `baseline-widely-available` (Chrome 111 / Safari 16.4), which down-transpiles `light-dark()` into `--lightningcss-light` / `--lightningcss-dark` toggled only by `prefers-color-scheme`. That breaks Studio theme colors when OS appearance ≠ Studio theme (ui5 sets `color-scheme` independently). The shared config excludes `Features.LightDark` so the function is left native — same workaround as Tailwind; see [lightningcss#873](https://github.com/parcel-bundler/lightningcss/issues/873). Do not re-enable that polyfill. The `sanity build` / `sanity preview` Vite config lives in `sanity-io/cli`, not this repo. ### Panda CSS The static styles of the `sanity` package are [Panda CSS](https://panda-css.com) `css()` calls in `.css.ts` modules next to the components, imported from the generated `#styled-system/css` runtime (`panda codegen` writes it to the gitignored `packages/sanity/styled-system/` directory on `pnpm install`, through the package's `prepare` script). `packages/sanity/panda.config.ts` is the **only Panda project in the repository** — the `} satisfies Config)` pattern, no presets, content-hashed class names with the `sanity-` prefix (`sanity-HgOzd`) and `strictPropertyValues` — and `#styled-system` is mapped in that package's manifest alone; nothing outside `packages/sanity` writes Panda CSS. The `migrate-styled-components-to-panda-css` skill (`.agents/skills/migrate-styled-components-to-panda-css/SKILL.md`) is the authoring guide: the `&&` specificity trick, the cascade model, descendant selectors and dynamic values. Outside the package, vanilla-extract stays where it is for now — the `.css.ts` modules of `@sanity/vision` (with its `vanillaExtract` tsdown option) and the test studio's `plugins/style-outline/styleOutline.css.ts` — until [#15406](https://github.com/sanity-io/sanity/pull/15406) moves them to CSS modules; the apps that build those from source keep `@sanity/vanilla-extract-vite-plugin` (the test studio's `sanity.cli.ts`, vision's vitest suite), wrapped as described below. Shared tooling versions live in the pnpm catalog (`@pandacss/dev`, `@pandacss/vite`, `@sanity/pandacss-tsdown-plugin`); the Vite apps that build the package from source get their Panda wiring from `@repo/pandacss` (`packages/@repo/pandacss`, its README says when an app needs it) instead of declaring those themselves. How the CSS reaches a page: - **The published package:** `panda: {minify: false, cascadeLayers: false}` in `tsdown.config.ts` (the `@sanity/pandacss-tsdown-plugin` options of `@sanity/tsdown-config`; unminified so the diff between two published versions stays readable, see below for the layers) rewrites every `css()` call into its class names at build time, extracts the CSS into `lib/bundle.css`, injects `import 'sanity/bundle.css'` into the entry chunks and publishes the conditional `./bundle.css` export (`browser`/`style` → the stylesheet, `node`/`default` → a no-op shim). The published code carries no Panda runtime, which is why the manifest resets `imports` under `publishConfig`. A `css()` call the build cannot resolve statically (computed values, loops) makes it warn and bundle the runtime — keep the style objects literal. - **Vite apps that build the package from source** (the dev studios through `sanity.cli.ts`, Storybook, the vitest browser suite, `dev/studio-diagnostics-viewer`) resolve the workspace packages to their sources (the `monorepo` export condition), so they bundle the package's `.css.ts` modules themselves and register `sanityPandaCss()` from `@repo/pandacss/vite` (`packages/@repo/pandacss`): `pandacss({cwd: , transform: true})` from `@pandacss/vite`, pointed at the package's Panda project (`cwd` resolves the config, its `include` globs and the codegen `outdir` there), and `removeCascadeLayers(['reset', 'base', 'tokens', 'recipes', 'utilities'])` from `@sanity/pandacss-tsdown-plugin/vite`, which gives the generated CSS the shape of `bundle.css` (below). `@pandacss/vite` appends the generated CSS to the CSS module of the build that declares Panda's layers, which is the one-line `@repo/pandacss/panda.css` with the `@layer reset, base, tokens, recipes, utilities;` statement — the dev counterpart of the `sanity/bundle.css` import the built package carries. Storybook's `.storybook/preview.tsx` and `packages/sanity/test/setup/browser.ts` import it directly; a studio's `sanity.cli.ts` (`studioPandaCss()`) and the viewer's `vite.config.ts` (the `entry` option) have the import injected into their entry (`.sanity/runtime/app.js`, `src/main.tsx`) by a tiny transform plugin, registered for dev only. A `sanity build` (and the viewer's build) resolves the built package and its `bundle.css`, so nothing Panda runs there — which is also why a studio that installs `sanity` from npm needs none of this: `bundle.css` is a plain stylesheet that Vite's built-in CSS support loads. The CDN bundle in `@repo/package.bundle` builds the package for publishing and keeps its own `pandacss()` call, `panda.css` and import plugin (it imports the stylesheet from every library entry). Where vanilla-extract `.css.ts` modules are built in the same graph (the test studio's `sanity.cli.ts`: its own style-outline plugin under both commands and vision's modules under `sanity dev`; vision's vitest suite), `@sanity/vanilla-extract-vite-plugin` is wrapped so that its transform's hook filter excludes `packages/sanity/src/` — it would otherwise compile the Panda modules as vanilla-extract and fail on the missing `@vanilla-extract/css` import; the wrapper throws if the plugin's name or hook shape changes. The jsdom suites of `sanity` and `@sanity/vision` register `@pandacss/vite` directly, with the same `cwd` and `transform: true`, but load no stylesheet: no jsdom test asserts on styles. - **No cascade layers.** Panda writes its rules into `@layer` blocks (`reset`, `base`, `tokens`, `recipes`, `utilities`), and the studio's CSS ships without them: CSS outside any layer beats every layered rule regardless of specificity, so a layered stylesheet loses its hover, focus and selected states to any unlayered rule that also matches — styled-components output, a reset like Tailwind's preflight, the studio's own `GlobalStyle` (the splash tests and the ui5 team's write-up confirmed it). `cascadeLayers: false` removes the blocks and statements after Lightning CSS has run and orders the rules the way the layers did (a layer's sublayers before its own rules, unlayered rules last), so every selector stays as specific as Panda wrote it and the studio's rules cascade like the vanilla-extract output did: by specificity, then source order. The same pass runs on the dev stylesheets and the CDN bundle through `removeCascadeLayers()`, which removes the named layers (and their sublayers) from every CSS module after `@pandacss/vite` has filled `@repo/pandacss/panda.css` (the CDN bundle's own `panda.css`), and leaves other layers alone — which is why that file declares Panda's default layer names (how `@pandacss/vite` recognizes its root stylesheet) and still ends up without a single `@layer`. Panda's `polyfill` option is not an alternative: it emulates the layer order with `:not(#\#…)` selector chains that inflate every rule's specificity. The studio declares no `@layer` statement of its own either: with `bundle.css` unlayered and `@sanity/ui` ≥ 4.4.8 (the catalog version) shipping its static styles unlayered too ([ui#3235](https://github.com/sanity-io/ui/pull/3235)), the only layer left on a studio page is `ui5`'s `sui`, which unlayered rules beat whatever the order, so there is nothing to order it against — the same situation as before the migration. What this means when authoring: the studio's rules beat `@sanity/ui`'s on the same element by specificity and order alone — a plain class beats a styled-components base rule that loads later only when it is more specific, which is what the `&&` doubling is for. - **Atomic rules are ordered by value, not by declaration.** `bundle.css` holds each distinct `property: value` pair once, grouped by property with the values sorted; a modifier class that must override a base class on the same element doubles itself (`css({'&&': {opacity: 0.8}})`) instead of relying on being declared later, and parent-state selectors key on data attributes the parent renders (`'[data-change-bar-wrapper][data-focused] &'` in `ElementWithChangeBar.css.ts`), never on a class that exists only to be targeted. ### Studio performance benchmarks (perf/bench — No Auth Required) The `perf/bench` suite benchmarks a built studio against a **local mock** of the Sanity API — fully hermetic, no tokens, no network: ```bash pnpm build:bench # build packages + bench studio (required first) pnpm bench help # list all bench CLI commands pnpm bench run --scenario singleString # absolute interaction benchmark pnpm bench run --mode pageload --scenario singleString # load vitals + bundle size pnpm bench:unit # mock-contract + stats unit tests pnpm bench dev # mock + `sanity dev` for interactive debugging ``` See `perf/bench/README.md` for A/B comparisons, scenarios, and CI details. (The legacy `dev/efps` suite has been decommissioned; perf/bench replaces it.) Three skills cover this area: `sanity-bench` (`.agents/skills/sanity-bench/SKILL.md`, running and dispatching the suite, the `trigger:perf-bench` label, dispatch inputs), `sanity-radar` (`.agents/skills/sanity-radar/SKILL.md`, the Studio Radar dashboard in `dev/radar` and how to query its dataset) and `sanity-radar-investigate` (`.agents/skills/sanity-radar-investigate/SKILL.md`, the regression-hunting procedure from a drift flag or investigation prompt to a confirmed culprit). ### Engine confidence score (No Auth Required locally) The Engine confidence score measures how well the tests guard the document engine (`@sanity/mutator`, the document store, form patching, Portable Text sync). It is Stryker's mutation score across two mutation runs, stored as `score.ratio`. It is one field, so reporting that reads the dataset directly needs no understanding of how it is computed. A catalogue of known-bug patches is also replayed and stored as `knownBugs`, shown in Radar as supporting detail ("X of N known bugs caught"), but it never feeds the score. The producer lives in `engine-confidence/` and `engine-confidence/README.md` carries the detail. ```bash pnpm engine-confidence stryker --target mutator --full # also: --target sanity; omit --full for an incremental run pnpm engine-confidence catalogue # --suites unit:mutator,unit:engine skips fuzz; --only ; --check-only pnpm engine-confidence score # writes engine-confidence/reports/engine-confidence-run.json pnpm engine-confidence store --dry-run # prints the document id and body, needs no token pnpm engine-confidence pin # recomputes config.json's pinnedHash and bumps version when it changed pnpm engine-confidence unit # the package's unit tests ``` - **Run it in a disposable checkout.** Stryker's in-place mode rewrites the tier-1 sources until it finishes, and the catalogue applies patches to real sources. A killed run leaves them mutated; the next run refuses a dirty tree. Recover with `git checkout -- packages/sanity/src packages/@sanity/mutator/src`. Run the two Stryker targets one after the other, never at once. - **Local recipe:** `pnpm build`, then `stryker --target mutator --full`, `stryker --target sanity --full`, `catalogue`, `score`, and `store reports/engine-confidence-run.json --dry-run` (`pnpm engine-confidence` runs from `engine-confidence/`, so paths are relative to it). `git status --short packages/` must be empty afterwards. - **`config.version` is the series-break marker.** Any change to `config.json`, `tier1.json`, `exclusions.json`, `engine-confidence/stryker/`, the vitest-runner patch (it works around a runner bug where tests inside a `describe` never run against a mutant under Vitest 5) or the four `@stryker-mutator/*` specifiers changes the pinned hash. Run `pin` and commit the result; a unit test fails on the pull request when the hash is stale. Catalogue growth is not hashed. - **Workflows.** `Engine confidence` (`.github/workflows/engine-confidence.yml`) runs Thursdays 03:00 UTC on `main` only, plus manual dispatch, and stores one document per ISO week. It never runs on pull requests, and only its `store` job holds `RADAR_SANITY_WRITE_TOKEN`. `Engine confidence catalogue` (`.github/workflows/engine-confidence-catalogue.yml`) runs on pull requests that touch engine files or the catalogue and checks that every patch still applies. It is not a required check. - **The exclusion register** (`engine-confidence/exclusions.json`) holds reviewed mutants that no test could ever catch. `score` exits 8 when an entry is stale (matches no mutant) or matches a mutant a test detected; fix the entry, never the check. Unreachable code and assumption-based equivalence do not qualify. The README has the rules. - **Refreshing a patch the PR check flagged:** rebase the patch onto the changed engine code, confirm with `git apply --check`, then run `catalogue --only ` to confirm the verdict. `engine-confidence/README.md` has the steps. - **Where it shows:** the Radar "Engine confidence" tab (`dev/radar`, `/trends?tab=engine`). `?source=demo` renders it offline. To see a real local run there, copy `engine-confidence/reports/engine-confidence-run.json` into the gitignored `dev/radar/tools/trends/__local__/`; demo mode uses it as the newest week. ### E2E Tests (Token Required) E2E tests require authentication tokens. Add these to `.env.local` in the repo root: ```bash SANITY_E2E_SESSION_TOKEN= SANITY_E2E_PROJECT_ID= SANITY_E2E_DATASET= ``` **How to get a token:** ```bash # Option 1: Use your CLI token sanity login sanity debug --secrets # Look for "Auth token" # Option 2: Create a project token at https://sanity.io/manage # Navigate to: Project Settings → API → Tokens → Add API token ``` Then run E2E tests: ```bash pnpm e2e:build # Build E2E studio pnpm test:e2e # Run E2E tests pnpm test:e2e --ui # Interactive mode ``` **Note:** E2E tests are typically run in CI, not locally during development. Most changes can be verified with unit tests. When CI e2e fails, the hosted Playwright report also serves a machine-readable digest at `/agent-report.md` (error messages, code snippets, and Playwright `error-context` page snapshots). The PR comment includes a **Share with an AI agent** fenced prompt pointing at that URL (GitHub's copy button copies the whole prompt). #### Diagnosing e2e flake: failure diagnostics and the flake report Every failed or timed-out e2e test attempt attaches `studio-diagnostics.json` — the same JSON the Studio Diagnostics dialog's "Copy output" produces (API latency probes, listen tests, the `x-sanity-shard` id, and the request-timing history recorded while the test ran). It is captured by the `_failureDiagnostics` auto fixture in `e2e/studio-test.ts` through `window.__sanityStudioDiagnostics`, which the e2e studio installs via the `StudioDiagnosticsBridge` component (`dev/studio-e2e-testing/diagnosticsBridge.tsx`). When the studio shell never mounted, `studio-diagnostics-fallback.json` holds plain-fetch probes instead, and `studio-request-error.txt` records the studio's request error dialog ("Too many requests", server error, network error) when it is showing — the request history only covers `/data/*` traffic, so this is how a 429 on `/users/me` becomes visible. Attachments show up per test in the Playwright HTML report and paste straight into `dev/studio-diagnostics-viewer`. To turn those captures into numbers ("how many failed runs were platform-caused?"), run the flake report: ```bash pnpm e2e:flake-report --days 7 --out flake-report.md # needs GITHUB_TOKEN / GH_TOKEN or `gh auth login` pnpm e2e:flake-report --help pnpm --filter e2e test:unit # classifier unit tests ``` It lists recent `End-to-End Tests` runs, downloads the blob reports of failed shards (cached under the OS temp dir), classifies each failed attempt as degraded / healthy / unknown from its capture, attributes every failed run to `platform`, `test-side`, `mixed`, or `unknown`, reads failed setup-job logs for rate-limit and network signatures, and flags correlated failure windows across unrelated branches. Thresholds and the verdict rules live in `e2e/scripts/flakeReport/classify.ts` and are restated in the report's Method section. The `E2E flake report` workflow (`.github/workflows/e2e-flake-report.yml`) runs it weekly and on demand, publishing the markdown as the job summary plus an artifact. Staging rate-limits per IP, so `pnpm e2e:setup` retries 429/5xx responses with backoff (`e2e/scripts/rateLimitRetry.ts`, honoring `Retry-After`) and sends the `x-sanity-ratelimit-bypass` header when `SANITY_CLI_API_RATE_LIMIT_BYPASS` is set, as the `dataset-setup` CI job does. ### Important Note for AI Agents **What requires authentication:** - Running the dev studio (`pnpm dev`) - E2E tests (`pnpm test:e2e`) - Any command that connects to Sanity APIs **What does NOT require authentication:** - Building packages (`pnpm build`) - Running unit tests (`pnpm test`) - Linting, formatting, and type checking (`pnpm lint`, `pnpm lint:fix`, `pnpm check:oxlint`) **Recommendation:** For most code changes, use `pnpm build && pnpm test` to verify correctness. This covers the vast majority of development tasks without any auth setup. Only use the dev studio when visual verification is specifically needed. ## Coding Standards Coding standards are enforced by **oxlint** (native Rust rules, type-aware rules via tsgolint, TypeScript type checking via `options.typeCheck`, and a few ESLint plugins loaded through oxlint's `jsPlugins`). TypeScript type checking is included in `pnpm lint` / `pnpm check:oxlint` — no separate `tsc` step. Check your code with: ```bash pnpm lint # Check for issues (oxlint, includes type checking) pnpm lint:fix # Auto-fix issues (oxfmt + oxlint --fix) ``` All packages use **ESM** (`"type": "module"`). TypeScript strict mode is enabled. Rules that the linter already enforces (restricted imports, type-aware rules, React Compiler rules, i18n rules, module boundaries) are not repeated in this guide — run `pnpm lint` and follow the reported messages, which explain the expected pattern. ### Do Not Weaken the Linter Fix the reported problem instead of silencing it. In order of preference: 1. **Fix the code** so the rule passes. This is almost always the right answer. 2. **Suppress the single line** as a last resort, when the rule is genuinely wrong for that one spot: `// oxlint-disable-next-line -- `. Always name the specific rule and explain the exception after `--`. Never suppress a rule merely to make CI green. 3. **Change `.oxlintrc.json` only when a human explicitly asks.** Do not turn rules off, downgrade severity, add `overrides` entries, or widen `ignorePatterns` on your own initiative — an override silences the rule for every current and future file it matches. If you think a rule is wrong, leave it failing and raise it in your summary or the PR description. File-wide `/* oxlint-disable */` is reserved for files that are an exception as a whole — vendored code, the `packages/sanity/src/ui-components` wrappers around raw `@sanity/ui`, CLI scripts that print to `console`. Follow that existing precedent rather than reaching for it to clear a handful of errors. `options.reportUnusedDisableDirectives` is `error`, so a suppression that stops being necessary fails CI — drop suppressions when the code underneath them changes. ### `sanity/_dangerously_use_private_internals_that_do_not_follow_semver` Every declaration that `sanity`, `sanity/structure` or `sanity/router` exports under the `@internal` TSDoc release tag is also exported from `sanity/_dangerously_use_private_internals_that_do_not_follow_semver` (`packages/sanity/src/_exports/_dangerously_use_private_internals_that_do_not_follow_semver.ts`), an entry whose name spells out its contract: anything on it can change or disappear in any release. The public entries still export those symbols today; the entry is the migration target for consumers that depend on them, ahead of the public entries dropping them in a future major. `packages/@repo/test-dts-exports/test/internals-entry-completeness.test.ts` (part of `pnpm test:exports`) fails when an `@internal` export of a public entry is missing from the entry, when the two entries export different declarations under one name, or when the entry exports something that is not tagged `@internal`. Rules that follow from this: - A new `@internal` export on a public barrel needs a matching line in the internals barrel (grouped by source module, `type` specifiers kept). Consumers outside `packages/sanity` (`@sanity/vision`, the dev studios, plugins) import internals from the entry, never from `sanity`. Inside the package, `src/core`, `src/structure` and `src/router` keep importing relatively: the entry re-exports modules from all three areas, so importing it from any of them is an import cycle, and a lazily loaded module that imports the entry drags the whole barrel in with it. The boundaries rules in `.oxlintrc.json` only allow the entry itself to import from the three areas. - The entry is generated from the tags, not curated: whether a symbol belongs there is decided by its `@internal` tag. Retagging a symbol (`@internal` → `@beta`/`@public`, or the other way) is the API decision; check sanity.io/docs and the published first-party plugins before demoting a symbol, and remember that `@sanity/cli`, `@sanity/cli-core` and `@sanity/cli-build` load the studio's local `sanity` package at runtime (`resolveLocalPackage('sanity')`) and destructure `renderStudio`, `resolveConfig`, `createSchema`, `SchemaError`, `createDefaultIcon`, `generateStudioManifest`, `uploadSchema` and `validateDocument` from the root. The one deliberate exception is `createAuthStore` (deprecated since v3.15.0): it keeps its `@internal @deprecated` tags because those tell customers to stop using it, it stays off the entry so the deprecated `sanity` import remains the only one, and it is listed in `PUBLIC_BY_USAGE` in the completeness test; its removal is governed by the deprecation, not by the internals moving. Do not retag it. - The CDN auto-update bundle (`packages/sanity/package.bundle.ts`) lists every entry explicitly; a new entry must be added there too, otherwise the `sanity/` import-map prefix resolves it to a 404 on the module host. `pnpm generate:dts-exports` in `packages/@repo/test-dts-exports` regenerates the d.ts fixture for the entry after its export list changes. - A change to any entry's export list also changes `packages/sanity/test/__snapshots__/exports.test.ts.snap`, a unit test in the `sanity` project rather than part of `pnpm test:exports`. It imports the entries from `src`, so `pnpm vitest run --project=sanity packages/sanity/test/exports.test.ts -u` regenerates it without a build. ### An entry's type surface is what its barrel exports, nothing more A bundled `.d.ts` can only name declarations the entry barrel actually exports. Until `rolldown-plugin-dts` 0.28.7 that was not true: a bundled `.d.ts` with no bare `export {…}` clause hit TypeScript's rule that every top-level declaration of such a declaration file is implicitly exported, so private helpers pulled in for a signature became part of the published type surface — including `const`s and `function`s that the emitted `.js` never exported, so importing them type-checked and then failed to link. 0.28.7 appends an `export {}` scope marker instead (["prevent implicit exports of private declarations"](https://github.com/sxzz/rolldown-plugin-dts/commit/004032f)), so only explicit exports are public. That makes `@repo/test-dts-exports` fixtures the authority on the surface, and a fixture listing a name no barrel exports is the bug, not the build. When a dts-tooling bump shrinks a fixture, check each dropped name before regenerating: - A **type** the declaring module exports and tags `@public` belongs on the barrel. Add it there as a type-only re-export (`export {type Foo} from '…'`), which leaves the emitted `.js` untouched. - A declaration the module keeps private stays private, and a `Name$1` suffix is a bundler dedup artifact that was never API — `sanity/media-library` emitted one because `VideoDefinition` is declared in both `plugin/schemas/index` and `plugin/schemas/types`. When a name is declared twice, pick which one is public by convention, not by which one the old output left unsuffixed: that was decided by bundler ordering. Here the public one is `plugin/schemas/types`, shaped like `ImageDefinition` and `FileDefinition` in `@sanity/types` and accepted by `defineVideoField`, while `plugin/schemas` declares a reduced one for the `IntrinsicDefinitions` augmentation. - A dropped **value** (`const`, `function`) was never importable: the emitted `.js` never exported it, so `import {name}` failed to link. Making it a real export is an API decision, and it can cost more than it looks — re-exporting `videoTypeName` pulled a 44 kB chunk of form and React code into `sanity/media-library`, which `sideEffects: true` keeps a consumer from tree-shaking away. - Load-bearing exceptions exist. `@sanity/util`'s `Options` and `Parser` are exported from `createSafeJsonParser` because `@sanity/migrate` imports `Parser` in its own `.d.ts`, and `lib-check.test.ts` runs with `skipLibCheck: false`. Regenerating needs a full `pnpm build` first: the generator reads each package's built `lib/**/*.d.ts`. ### Effect events: use `use-effect-event`, not React's native hook Import `useEffectEvent` from `use-effect-event`, never from `react`. On React 19.2 the native hook returns first-render values when the calling component is wrapped in `forwardRef` or `memo` ([facebook/react#34818](https://github.com/facebook/react/issues/34818), fixed in 19.3 canaries). `eslint/no-restricted-imports` in `.oxlintrc.json` enforces this. The bug reaches any dependency that wraps the native hook, so check the implementation before trusting one. ### react-rx: stable observables, explicit initial values `react-rx` v7 never subscribes during render. `useObservable` / `useSyncObservable` render the `initialValue` (required — pass `undefined` explicitly when there is nothing better) until the subscription started on commit delivers a value, and a synchronous emission arrives one pass later. Two rules follow: - **The observable identity must be stable across renders**: build it with `useMemo`, keep it on a store, or hoist it to module scope, and key memos on primitives (a path string, an id) rather than on arrays or objects recreated every render. An observable rebuilt each render is torn down and re-subscribed each commit; when it synchronously replays a value that differs from the `initialValue`, React aborts with "Maximum update depth exceeded" (`useDocumentValuesRenderLoop.repro.test.tsx` guards one such case). - **Never rely on a synchronous first emission.** `useSyncObservable(obs$, undefined)!` is a first-render crash. Pass the value the observable emits first (`GUARDED`, the releases store's exported `INITIAL_RELEASES_STATE`, `useVariantsStore().initialState`), or derive it per render when it depends on the observable's parameters (`useEditState`), since react-rx captures `initialValue` once per hook instance. ### Translate: never define `components` inline Components passed to ``'s `components` map must be stable, module-scope components — defining them inline during render creates a new component identity every render and remounts the subtree. Pass render-scoped data through the `componentProps` prop instead, and map plain HTML wrappers as strings (eg `{Code: 'code'}`). The in-repo oxlint rule `@repo/i18n/no-inline-translate-components` (see `packages/@repo/oxlint-plugin-i18n`) enforces this for object literals in the JSX attribute; maps built during render via `useMemo` or factory functions are equally wrong even though the rule cannot see them. See the `sanity-i18n-translate` skill (`.agents/skills/sanity-i18n-translate/SKILL.md`) for the full conversion patterns. ### Locale namespaces: plugin strings stay inside the plugin Only `studio`, `validation`, `copy-paste` and `feedback` (plus `comments`, whose plugin is always included) are registered in every studio. Every other namespace (`releases`, `tasks`, `canvas`, `structure`, ...) is registered by a plugin that a studio can leave out, and a component that reads it while that plugin is not loaded renders raw keys. Strings for code that renders outside a plugin (core components, form inputs, previews, the navbar) go in the `studio` bundle. Two in-repo oxlint rules check this: `@repo/i18n/no-plugin-namespace-outside-plugin` and `@repo/i18n/no-missing-translation-key` (keys must exist in the en-US resources of the namespace they are read from). Both read the namespace table in `packages/@repo/oxlint-plugin-i18n/src/namespaces.mjs`; update it when adding a namespace or changing which plugin registers one. ### ``: effects are torn down, DOM and state stay React's `` is used for closed `@sanity/ui` overlays, collapsed document lists, and the top-level tools when `beta.reactActivityMode` is on (`StudioLayoutComponent`). Hiding runs every effect cleanup in the subtree and showing runs the effects again, without a remount, so: - Anything that lives only in an effect-created object is lost on reveal unless the effect can rebuild it from render-time data. `@uiw/react-codemirror` destroys its `EditorView` on cleanup and re-creates it from `value`; `VisionCodeMirror` mirrors the latest document into state for that reason. Mount-only effects (`useEffect(..., [])`) run again on every reveal. - Loading states come back on reveal unless the code remembers it already loaded. A comlink channel recreated by an effect handshakes as a brand new connection (the presentation machine tracks `overlaysHaveConnected` so that reads as a reconnect, not a first connect); a `startWith(loading)` observable re-emits its initial value when `useObservable` re-subscribes it (`useDocumentLocations` skips the `startWith` once the resolver has emitted); and a context populated by registering in an effect empties out while hidden, unmounting whatever renders from it (`PresentationDocumentProvider` lists its own options at render time). - Never reorder keyed siblings that hold an `